Skip to content

Instantly share code, notes, and snippets.

@leepfrog
Last active October 6, 2023 02:38
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save leepfrog/751f9d1792d8c3342fdb419d05d3dcac to your computer and use it in GitHub Desktop.
Save leepfrog/751f9d1792d8c3342fdb419d05d3dcac to your computer and use it in GitHub Desktop.
EditStore first pass rough draft
import Model, { attr } from '@ember-data/model';
export default class Book extends Model {
@attr('string')
declare name: string;
declare id: string;
}
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;
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];
}
}
<div {{this.loadViewModel}}>
{{#if this.isEditing}}
<button {{on 'click' this.cancelEditing}}>cancel</button>
<button {{on 'click' this.new}}>new book</button>
{{else}}
<button {{on 'click' this.enterEditing}}>edit</button>
{{/if}}
{{#each this.viewModel as |book|}}
<h2>{{book.name}}</h2>
{{/each}}
</div>
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