Last active
October 6, 2023 02:38
-
-
Save leepfrog/751f9d1792d8c3342fdb419d05d3dcac to your computer and use it in GitHub Desktop.
EditStore first pass rough draft
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Model, { attr } from '@ember-data/model'; | |
export default class Book extends Model { | |
@attr('string') | |
declare name: string; | |
declare id: string; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Future } from '@ember-data/request/-private/types'; | |
import { StoreRequestInput } from '@ember-data/store/-private/cache-handler'; | |
import { CreateRecordProperties } from '@ember-data/store/-private/store-service'; | |
import Service from '@ember/service'; | |
import { service } from '@ember/service'; | |
import { atomicChanges } from '../request-builders/atomic'; | |
import Store from './services/store'; | |
interface Changeset { | |
op: 'create' | 'remove' | 'update'; | |
opIndex: number; | |
// TODO: Body from json:api atomic ops | |
record: SchemaInstance; // TODO: still crappy type | |
} | |
interface SchemaInstance { | |
id: string; | |
[key: string]: unknown; | |
} | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging | |
class EditStoreService extends Service { | |
/** points to service:store to fork from */ | |
@service('store') | |
declare store: Store; | |
/** forked store to proxy methods to */ | |
private _store?: Store; | |
/** array of changes while editing / undo buffer */ | |
private _ops: Changeset[] = []; | |
/** boolean (rw) - is save() busy */ | |
private _isBusy: boolean = false; | |
/** boolean (ro) - is _store !empty */ | |
get isForked() { | |
return typeof this._store !== 'undefined'; | |
} | |
/** boolean (ro) - is ops !empty */ | |
get isDirty() { | |
return this._ops.length !== 0; | |
} | |
/** boolean (ro) - is save() busy */ | |
get isBusy() { | |
return this._isBusy; | |
} | |
/** | |
Record[] - mapping of Changeset to Record objects | |
TODO: This might be buggy in case of non-squished _ops | |
*/ | |
get dirtyRecords() { | |
return this._ops.map((changeset) => { | |
changeset.record; | |
}); | |
} | |
/** clear _store / clear _ops */ | |
clear() { | |
this._store = undefined; | |
this._ops = []; | |
} | |
/** | |
squish ops, build request, handle request | |
this does not execute `clear()` | |
*/ | |
async save() { | |
this._isBusy = true; | |
if (!this._store) return; // TODO: Error | |
this._squish(); | |
const req = atomicChanges(this.dirtyRecords); | |
await this._store.request(req); | |
// TODO: What happens after request? | |
this._isBusy = false; | |
} | |
/** | |
record change operations (effectively edit undo / save buffer) | |
*/ | |
change(changeset: Changeset) { | |
this._ops = [...this._ops, changeset]; | |
} | |
/** | |
create _store instance from existing service:store | |
*/ | |
fork() { | |
this._store = this.store.fork(); | |
} | |
/** | |
Optimize operations for network | |
(Squish adds, updates, deletes) | |
TODO: Perf? | |
TODO: optimizations first-take -- | |
remove: remove-object -- remove all related add/update ops | |
create: create-object -- collect all related updated ops -> create:op | |
update: update-object -- collect all related update ops -> update:op | |
*/ | |
private _squish() { | |
let ops = this._ops; | |
const merge = (changeset1: Changeset, changeset2: Changeset) => { | |
// TODO: Make more sophisticated | |
return { ...changeset1, ...changeset2 }; | |
}; | |
const opNumber = (op: Changeset['op']) => { | |
switch (op) { | |
case 'remove': | |
return 0; | |
case 'create': | |
return 1; | |
case 'update': | |
return 2; | |
} | |
}; | |
ops.sort((changeset1, changeset2) => { | |
if (opNumber(changeset1.op) > opNumber(changeset2.op)) return -1; | |
else return 1; | |
}); | |
type Cache = Record<string, Changeset>; | |
const remove: Cache = {}; | |
const create: Cache = {}; | |
const update: Cache = {}; | |
ops = ops.reduce((ops: Changeset[], changeset: Changeset) => { | |
switch (changeset.op) { | |
// sorted, so will be processed first | |
case 'remove': | |
if (remove[changeset.record.id]) return ops; // TODO: Duplicate changeset, how to handle, currently skip silently | |
changeset.opIndex = ops.length; | |
remove[changeset.record.id] = changeset; | |
ops.push(changeset); | |
return ops; | |
// sorted, so will be processed second | |
case 'create': | |
if (remove[changeset.record.id]) return ops; // we have this pending deletion, so do not create | |
if (create[changeset.record.id]) return ops; // TODO: Duplicate changeset on creation, how to handle, currently skip silently | |
changeset.opIndex = ops.length; | |
create[changeset.record.id] = changeset; | |
ops.push(changeset); | |
return ops; | |
// sorted, so will be processed third | |
case 'update': | |
if (remove[changeset.record.id]) return ops; // we have this pending deletion, so do not update | |
if (create[changeset.record.id]) { | |
// We have this pending create | |
const i = create[changeset.record.id]!.opIndex; | |
ops[i] = merge(ops[i]!, changeset); | |
return ops; | |
} | |
if (update[changeset.record.id]) { | |
// We have this pending update | |
const i = update[changeset.record.id]!.opIndex; | |
ops[i] = merge(ops[i]!, changeset); | |
return ops; | |
} | |
changeset.opIndex = ops.length; | |
update[changeset.record.id] = changeset; | |
ops.push(changeset); | |
return ops; | |
default: | |
return ops; | |
} | |
}, [] as Changeset[]); | |
this._ops = ops; | |
} | |
/********************************************* | |
Proxy methods | |
TODO: Find out a way to use ES6 Proxy? | |
*******************************************/ | |
request<T>(requestConfig: StoreRequestInput): Future<T> { | |
console.log('== edit store is attempting to proxy request'); | |
return this._store?.request(requestConfig)!; // TODO: Buggy | |
} | |
createRecord(modelName: string, inputProperties: CreateRecordProperties) { | |
console.log('== edit store is attempting to proxy createRecord'); | |
return this._store?.createRecord(modelName, inputProperties)!; // TODO: Buggy | |
} | |
} | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging | |
interface EditStoreService extends Store {} | |
export default EditStoreService; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import EditStoreService from '../services/edit-store'; | |
import BookModel from '../models/campaign'; | |
import Controller from '@ember/controller'; | |
import RouterService from '@ember/routing/router-service'; | |
import Store from '../services/store'; | |
import { action } from '@ember/object'; | |
import { service } from '@ember/service'; | |
import { tracked } from '@glimmer/tracking'; | |
import { QueryRequestOptions } from '@ember-data/types/request'; | |
import { task } from 'ember-concurrency'; | |
import { StructuredDataDocument } from '@ember-data/types/cache/document'; | |
import { modifier } from 'ember-modifier'; | |
import { query } from '@ember-data/json-api/request'; | |
export default class IndexController extends Controller { | |
queryParams = ['isEditing']; | |
@service('edit-store') | |
declare editStore: EditStoreService; | |
@service('store') | |
declare store: Store; | |
declare model: QueryRequestOptions; | |
@tracked | |
isEditing = false; | |
@tracked | |
viewModel: Books[] = []; | |
loadViewModel = modifier(() => { | |
if (this.isEditing) { | |
if (!this.editStore.isForked) this.editStore.fork(); | |
} else { | |
if (this.editStore.isForked) this.editStore.clear(); | |
} | |
this.setViewModel.perform(); | |
}); | |
setViewModel = task(async () => { | |
const store: Store = this.editStore.isForked ? this.editStore : this.store; | |
const query = query('book', {}, {reload: true}); | |
const response: Response = await store.request(query); | |
this.viewModel = response.content.data; | |
}); | |
@action | |
startEditing(_e: MouseEvent) { | |
this.router.transitionTo({ queryParams: { isEditing: true } }); | |
} | |
@action | |
cancelEditing(_e: MouseEvent) { | |
this.router.transitionTo({ queryParams: { isEditing: false } }); | |
} | |
@action | |
async new(_e: MouseEvent) { | |
const book = this.editStore.createRecord('book', { | |
name: 'foo', | |
}) as BookModel; | |
this.viewModel = [book, ...this.viewModel]; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import JSONAPICache from '@ember-data/json-api'; | |
import { FetchManager } from '@ember-data/legacy-compat/-private'; | |
import type Model from '@ember-data/model'; | |
import type { ModelStore } from '@ember-data/model/-private/model'; | |
import { | |
buildSchema, | |
instantiateRecord, | |
modelFor, | |
teardownRecord, | |
} from '@ember-data/model/hooks'; | |
import RequestManager from '@ember-data/request'; | |
import Fetch from '@ember-data/request/fetch'; | |
import BaseStore, { | |
CacheHandler, | |
recordIdentifierFor, | |
} from '@ember-data/store'; | |
import type { Cache } from '@ember-data/types/cache/cache'; | |
import type { CacheCapabilitiesManager } from '@ember-data/types/q/cache-store-wrapper'; | |
import type { ModelSchema } from '@ember-data/types/q/ds-model'; | |
import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; | |
import JsonApiGetHandler from '../request-handlers/json-api-get'; | |
import JsonApiPostHandler from '../request-handlers/json-api-post'; | |
import JsonApiAtomicHandler from '../request-handlers/json-api-atomic'; | |
import { singularize } from 'ember-inflector'; | |
import { getOwner } from '@ember/owner'; | |
import { setOwner } from '@ember/-internals/owner'; | |
export default class Store extends BaseStore { | |
constructor(args: unknown) { | |
super(args); | |
this.requestManager = new RequestManager(); | |
this.requestManager.use([ | |
JsonApiPostHandler, | |
JsonApiAtomicHandler, | |
JsonApiGetHandler, | |
Fetch, | |
]); | |
this.requestManager.useCache(CacheHandler); | |
this.registerSchema(buildSchema(this)); | |
} | |
createCache(storeWrapper: CacheCapabilitiesManager): Cache { | |
return new JSONAPICache(storeWrapper); | |
} | |
instantiateRecord( | |
this: ModelStore, | |
identifier: StableRecordIdentifier, | |
createRecordArgs: Record<string, unknown>, | |
): Model { | |
return instantiateRecord.call(this, identifier, createRecordArgs); | |
} | |
teardownRecord(record: Model): void { | |
teardownRecord.call(this, record as Model); | |
} | |
modelFor(type: string): ModelSchema { | |
const modelType = singularize(type); | |
return modelFor.call(this, modelType) || super.modelFor(modelType); | |
} | |
// TODO @runspired @deprecate records should implement their own serialization if desired | |
serializeRecord(record: unknown, options?: Record<string, unknown>): unknown { | |
// TODO we used to check if the record was destroyed here | |
if (!this._fetchManager) { | |
this._fetchManager = new FetchManager(this); | |
} | |
return this._fetchManager | |
.createSnapshot(recordIdentifierFor(record)) | |
.serialize(options); | |
} | |
fork() { | |
const owner = getOwner(this)!; // TODO: Exception if no owner | |
const fork = owner.lookup('service:store', { singleton: false }) as Store; | |
setOwner(fork, owner); | |
return fork; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment