Skip to content

Commit 40fa283

Browse files
authored
feat: implement managed array for schemaRecord (#9240)
* wip * implement managed array for schemaRecord * remove unexpected changes to -utils.ts * stash * "publish: stash of uncommitted changes by release script" * Fix reads test to use new structure * revert node change * restore utils
1 parent 3f3faa3 commit 40fa283

File tree

9 files changed

+1283
-5
lines changed

9 files changed

+1283
-5
lines changed

packages/core-types/src/cache.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { Operation } from './cache/operations';
88
import type { CollectionRelationship, ResourceRelationship } from './cache/relationship';
99
import type { StableDocumentIdentifier, StableRecordIdentifier } from './identifier';
1010
import type { Value } from './json/raw';
11+
import type { TypeFromInstanceOrString } from './record';
1112
import type { RequestContext, StructuredDataDocument, StructuredDocument } from './request';
1213
import type { ResourceDocument, SingleResourceDataDocument } from './spec/document';
1314
import type { ApiError } from './spec/error';
@@ -137,7 +138,7 @@ export interface Cache {
137138
* @param {StableRecordIdentifier | StableDocumentIdentifier} identifier
138139
* @return {ResourceDocument | ResourceBlob | null} the known resource data
139140
*/
140-
peek(identifier: StableRecordIdentifier): ResourceBlob | null;
141+
peek<T = unknown>(identifier: StableRecordIdentifier<TypeFromInstanceOrString<T>>): T | null;
141142
peek(identifier: StableDocumentIdentifier): ResourceDocument | null;
142143

143144
/**

packages/schema-record/rollup.config.mjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export default {
1919
plugins: [
2020
// These are the modules that users should be able to import from your
2121
// addon. Anything not listed here may get optimized away.
22-
addon.publicEntrypoints(['hooks.js', 'index.js', 'record.js', 'schema.js']),
22+
addon.publicEntrypoints(['hooks.js', 'index.js', 'record.js', 'schema.js', 'managed-array.js']),
2323

2424
nodeResolve({ extensions: ['.ts'] }),
2525
babel({
+251
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { assert } from '@ember/debug';
2+
3+
import type Store from '@ember-data/store';
4+
import type { OpaqueRecordInstance } from '@ember-data/store/-types/q/record-instance';
5+
import type { FieldSchema } from '@ember-data/store/-types/q/schema-service';
6+
import type { Signal } from '@ember-data/tracking/-private';
7+
import { addToTransaction, createSignal, subscribe } from '@ember-data/tracking/-private';
8+
import type { StableRecordIdentifier } from '@warp-drive/core-types';
9+
import type { Cache } from '@warp-drive/core-types/cache';
10+
import type { ArrayValue, Value } from '@warp-drive/core-types/json/raw';
11+
12+
import type { SchemaRecord } from './record';
13+
import type { SchemaService } from './schema';
14+
15+
export const SOURCE = Symbol('#source');
16+
export const MUTATE = Symbol('#update');
17+
export const ARRAY_SIGNAL = Symbol('#signal');
18+
export const NOTIFY = Symbol('#notify');
19+
20+
export function notifyArray(arr: ManagedArray) {
21+
addToTransaction(arr[ARRAY_SIGNAL]);
22+
}
23+
24+
type KeyType = string | symbol | number;
25+
const ARRAY_GETTER_METHODS = new Set<KeyType>([
26+
Symbol.iterator,
27+
'concat',
28+
'entries',
29+
'every',
30+
'fill',
31+
'filter',
32+
'find',
33+
'findIndex',
34+
'flat',
35+
'flatMap',
36+
'forEach',
37+
'includes',
38+
'indexOf',
39+
'join',
40+
'keys',
41+
'lastIndexOf',
42+
'map',
43+
'reduce',
44+
'reduceRight',
45+
'slice',
46+
'some',
47+
'values',
48+
]);
49+
// const ARRAY_SETTER_METHODS = new Set<KeyType>(['push', 'pop', 'unshift', 'shift', 'splice', 'sort']);
50+
const SYNC_PROPS = new Set<KeyType>(['[]', 'length']);
51+
function isArrayGetter<T>(prop: KeyType): prop is keyof Array<T> {
52+
return ARRAY_GETTER_METHODS.has(prop);
53+
}
54+
// function isArraySetter<T>(prop: KeyType): prop is keyof Array<T> {
55+
// return ARRAY_SETTER_METHODS.has(prop);
56+
// }
57+
// function isSelfProp<T extends object>(self: T, prop: KeyType): prop is keyof T {
58+
// return prop in self;
59+
// }
60+
61+
function convertToInt(prop: KeyType): number | null {
62+
if (typeof prop === 'symbol') return null;
63+
64+
const num = Number(prop);
65+
66+
if (isNaN(num)) return null;
67+
68+
return num % 1 === 0 ? num : null;
69+
}
70+
71+
type ProxiedMethod = (...args: unknown[]) => unknown;
72+
73+
type ForEachCB = (record: OpaqueRecordInstance, index: number, context: typeof Proxy<unknown[]>) => void;
74+
function safeForEach(
75+
instance: typeof Proxy<unknown[]>,
76+
arr: unknown[],
77+
store: Store,
78+
callback: ForEachCB,
79+
target: unknown
80+
) {
81+
if (target === undefined) {
82+
target = null;
83+
}
84+
// clone to prevent mutation
85+
arr = arr.slice();
86+
assert('`forEach` expects a function as first argument.', typeof callback === 'function');
87+
88+
// because we retrieveLatest above we need not worry if array is mutated during iteration
89+
// by unloadRecord/rollbackAttributes
90+
// push/add/removeObject may still be problematic
91+
// but this is a more traditionally expected forEach bug.
92+
const length = arr.length; // we need to access length to ensure we are consumed
93+
94+
for (let index = 0; index < length; index++) {
95+
callback.call(target, arr[index], index, instance);
96+
}
97+
98+
return instance;
99+
}
100+
101+
export interface ManagedArray extends Omit<Array<unknown>, '[]'> {
102+
[MUTATE]?(
103+
target: unknown[],
104+
receiver: typeof Proxy<unknown[]>,
105+
prop: string,
106+
args: unknown[],
107+
_SIGNAL: Signal
108+
): unknown;
109+
}
110+
111+
export class ManagedArray {
112+
[SOURCE]: unknown[];
113+
declare address: StableRecordIdentifier;
114+
declare key: string;
115+
declare owner: SchemaRecord;
116+
declare [ARRAY_SIGNAL]: Signal;
117+
118+
constructor(
119+
store: Store,
120+
schema: SchemaService,
121+
cache: Cache,
122+
field: FieldSchema,
123+
data: unknown[],
124+
address: StableRecordIdentifier,
125+
key: string,
126+
owner: SchemaRecord
127+
) {
128+
// eslint-disable-next-line @typescript-eslint/no-this-alias
129+
const self = this;
130+
this[SOURCE] = data?.slice();
131+
this[ARRAY_SIGNAL] = createSignal(this, 'length');
132+
const _SIGNAL = this[ARRAY_SIGNAL];
133+
const boundFns = new Map<KeyType, ProxiedMethod>();
134+
this.address = address;
135+
this.key = key;
136+
this.owner = owner;
137+
let transaction = false;
138+
139+
const proxy = new Proxy(this[SOURCE], {
140+
get<R extends typeof Proxy<unknown[]>>(target: unknown[], prop: keyof R, receiver: R) {
141+
if (prop === ARRAY_SIGNAL) {
142+
return _SIGNAL;
143+
}
144+
if (prop === 'address') {
145+
return self.address;
146+
}
147+
if (prop === 'key') {
148+
return self.key;
149+
}
150+
if (prop === 'owner') {
151+
return self.owner;
152+
}
153+
154+
const index = convertToInt(prop);
155+
if (_SIGNAL.shouldReset && (index !== null || SYNC_PROPS.has(prop) || isArrayGetter(prop))) {
156+
_SIGNAL.t = false;
157+
_SIGNAL.shouldReset = false;
158+
const newData = cache.getAttr(self.address, self.key);
159+
if (newData && newData !== self[SOURCE]) {
160+
self[SOURCE].length = 0;
161+
self[SOURCE].push(...(newData as ArrayValue));
162+
}
163+
}
164+
165+
if (index !== null) {
166+
const val = target[index];
167+
if (!transaction) {
168+
subscribe(_SIGNAL);
169+
}
170+
if (field.type !== null) {
171+
const transform = schema.transforms.get(field.type);
172+
if (!transform) {
173+
throw new Error(`No '${field.type}' transform defined for use by ${address.type}.${String(prop)}`);
174+
}
175+
return transform.hydrate(val as Value, field.options ?? null, self.owner);
176+
}
177+
return val;
178+
}
179+
180+
if (isArrayGetter(prop)) {
181+
let fn = boundFns.get(prop);
182+
183+
if (fn === undefined) {
184+
if (prop === 'forEach') {
185+
fn = function () {
186+
subscribe(_SIGNAL);
187+
transaction = true;
188+
const result = safeForEach(receiver, target, store, arguments[0] as ForEachCB, arguments[1]);
189+
transaction = false;
190+
return result;
191+
};
192+
} else {
193+
fn = function () {
194+
subscribe(_SIGNAL);
195+
// array functions must run through Reflect to work properly
196+
// binding via other means will not work.
197+
transaction = true;
198+
const result = Reflect.apply(target[prop] as ProxiedMethod, receiver, arguments) as unknown;
199+
transaction = false;
200+
return result;
201+
};
202+
}
203+
boundFns.set(prop, fn);
204+
}
205+
return fn;
206+
}
207+
208+
return Reflect.get(target, prop, receiver);
209+
},
210+
set(target, prop: KeyType, value, receiver) {
211+
if (prop === 'address') {
212+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
213+
self.address = value;
214+
return true;
215+
}
216+
if (prop === 'key') {
217+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
218+
self.key = value;
219+
return true;
220+
}
221+
if (prop === 'owner') {
222+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
223+
self.owner = value;
224+
return true;
225+
}
226+
const reflect = Reflect.set(target, prop, value, receiver);
227+
228+
if (reflect) {
229+
if (field.type === null) {
230+
cache.setAttr(self.address, self.key, self[SOURCE] as Value);
231+
_SIGNAL.shouldReset = true;
232+
return true;
233+
}
234+
235+
const transform = schema.transforms.get(field.type);
236+
if (!transform) {
237+
throw new Error(`No '${field.type}' transform defined for use by ${address.type}.${String(prop)}`);
238+
}
239+
const rawValue = (self[SOURCE] as ArrayValue).map((item) =>
240+
transform.serialize(item, field.options ?? null, self.owner)
241+
);
242+
cache.setAttr(self.address, self.key, rawValue as Value);
243+
_SIGNAL.shouldReset = true;
244+
}
245+
return reflect;
246+
},
247+
}) as ManagedArray;
248+
249+
return proxy;
250+
}
251+
}

0 commit comments

Comments
 (0)