From 23566978e6a05ee279403db2cf122d78773eca5e Mon Sep 17 00:00:00 2001 From: Rich Glazerman Date: Fri, 24 May 2024 12:47:22 -0700 Subject: [PATCH] Implement schema-array for schema-record (#9384) feat: schema array Co-authored-by: Chris Thoburn --- packages/core-types/src/-private.ts | 2 + packages/core-types/src/cache.ts | 4 +- packages/json-api/src/-private/cache.ts | 180 +++++- packages/schema-record/src/managed-array.ts | 134 +++- packages/schema-record/src/record.ts | 217 ++++++- packages/schema-record/src/symbols.ts | 3 + patches/qunit@2.19.4.patch | 33 +- pnpm-lock.yaml | 199 +++--- .../tests/-utils/reactive-context.ts | 2 + .../tests/reactivity/schema-array-test.ts | 178 ++++++ .../tests/reads/schema-array-test.ts | 145 +++++ .../tests/writes/schema-array-test.ts | 577 ++++++++++++++++++ 12 files changed, 1476 insertions(+), 198 deletions(-) create mode 100644 tests/warp-drive__schema-record/tests/reactivity/schema-array-test.ts create mode 100644 tests/warp-drive__schema-record/tests/reads/schema-array-test.ts create mode 100644 tests/warp-drive__schema-record/tests/writes/schema-array-test.ts diff --git a/packages/core-types/src/-private.ts b/packages/core-types/src/-private.ts index c7621e5c8bf..f441db8cd74 100644 --- a/packages/core-types/src/-private.ts +++ b/packages/core-types/src/-private.ts @@ -95,6 +95,8 @@ type GlobalKey = | 'Destroy' | 'Identifier' | 'Editable' + | 'EmbeddedPath' + | 'EmbeddedType' | 'Parent' | 'Checkout' | 'Legacy'; diff --git a/packages/core-types/src/cache.ts b/packages/core-types/src/cache.ts index fbebce4b7d1..a219c3b4f81 100644 --- a/packages/core-types/src/cache.ts +++ b/packages/core-types/src/cache.ts @@ -339,7 +339,7 @@ export interface Cache { * @param field * @return {unknown} */ - getAttr(identifier: StableRecordIdentifier, field: string): Value | undefined; + getAttr(identifier: StableRecordIdentifier, field: string | string[]): Value | undefined; /** * Mutate the data for an attribute in the cache @@ -352,7 +352,7 @@ export interface Cache { * @param field * @param value */ - setAttr(identifier: StableRecordIdentifier, field: string, value: Value): void; + setAttr(identifier: StableRecordIdentifier, field: string | string[], value: Value): void; /** * Query the cache for the changed attributes of a resource. diff --git a/packages/json-api/src/-private/cache.ts b/packages/json-api/src/-private/cache.ts index e527f309260..5be8a7feb9a 100644 --- a/packages/json-api/src/-private/cache.ts +++ b/packages/json-api/src/-private/cache.ts @@ -1089,27 +1089,58 @@ export default class JSONAPICache implements Cache { * @param field * @return {unknown} */ - getAttr(identifier: StableRecordIdentifier, attr: string): Value | undefined { - const cached = this.__peek(identifier, true); - if (cached.localAttrs && attr in cached.localAttrs) { - return cached.localAttrs[attr]; - } else if (cached.inflightAttrs && attr in cached.inflightAttrs) { - return cached.inflightAttrs[attr]; - } else if (cached.remoteAttrs && attr in cached.remoteAttrs) { - return cached.remoteAttrs[attr]; - } else if (cached.defaultAttrs && attr in cached.defaultAttrs) { - return cached.defaultAttrs[attr]; - } else { - const attrSchema = this._capabilities.schema.fields(identifier).get(attr); + getAttr(identifier: StableRecordIdentifier, attr: string | string[]): Value | undefined { + const isSimplePath = !Array.isArray(attr) || attr.length === 1; + if (Array.isArray(attr) && attr.length === 1) { + attr = attr[0]; + } - upgradeCapabilities(this._capabilities); - const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store); - if (schemaHasLegacyDefaultValueFn(attrSchema)) { - cached.defaultAttrs = cached.defaultAttrs || (Object.create(null) as Record); - cached.defaultAttrs[attr] = defaultValue; + if (isSimplePath) { + const attribute = attr as string; + const cached = this.__peek(identifier, true); + if (cached.localAttrs && attribute in cached.localAttrs) { + return cached.localAttrs[attribute]; + } else if (cached.inflightAttrs && attribute in cached.inflightAttrs) { + return cached.inflightAttrs[attribute]; + } else if (cached.remoteAttrs && attribute in cached.remoteAttrs) { + return cached.remoteAttrs[attribute]; + } else if (cached.defaultAttrs && attribute in cached.defaultAttrs) { + return cached.defaultAttrs[attribute]; + } else { + const attrSchema = this._capabilities.schema.fields(identifier).get(attribute); + + upgradeCapabilities(this._capabilities); + const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store); + if (schemaHasLegacyDefaultValueFn(attrSchema)) { + cached.defaultAttrs = cached.defaultAttrs || (Object.create(null) as Record); + cached.defaultAttrs[attribute] = defaultValue; + } + return defaultValue; + } + } + + // TODO @runspired consider whether we need a defaultValue cache in SchemaRecord + // like we do for the simple case above. + const path: string[] = attr as string[]; + const cached = this.__peek(identifier, true); + const basePath = path[0]; + let current = cached.localAttrs && basePath in cached.localAttrs ? cached.localAttrs[basePath] : undefined; + if (current === undefined) { + current = cached.inflightAttrs && basePath in cached.inflightAttrs ? cached.inflightAttrs[basePath] : undefined; + } + if (current === undefined) { + current = cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : undefined; + } + if (current === undefined) { + return undefined; + } + for (let i = 1; i < path.length; i++) { + current = (current as ObjectValue)[path[i]]; + if (current === undefined) { + return undefined; } - return defaultValue; } + return current; } /** @@ -1123,29 +1154,114 @@ export default class JSONAPICache implements Cache { * @param field * @param value */ - setAttr(identifier: StableRecordIdentifier, attr: string, value: Value): void { + setAttr(identifier: StableRecordIdentifier, attr: string | string[], value: Value): void { + // this assert works to ensure we have a non-empty string and/or a non-empty array + assert('setAttr must receive at least one attribute path', attr.length > 0); + const isSimplePath = !Array.isArray(attr) || attr.length === 1; + + if (Array.isArray(attr) && attr.length === 1) { + attr = attr[0]; + } + + if (isSimplePath) { + const cached = this.__peek(identifier, false); + const currentAttr = attr as string; + const existing = + cached.inflightAttrs && currentAttr in cached.inflightAttrs + ? cached.inflightAttrs[currentAttr] + : cached.remoteAttrs && currentAttr in cached.remoteAttrs + ? cached.remoteAttrs[currentAttr] + : undefined; + + if (existing !== value) { + cached.localAttrs = cached.localAttrs || (Object.create(null) as Record); + cached.localAttrs[currentAttr] = value; + cached.changes = cached.changes || (Object.create(null) as Record); + cached.changes[currentAttr] = [existing, value]; + } else if (cached.localAttrs) { + delete cached.localAttrs[currentAttr]; + delete cached.changes![currentAttr]; + } + + if (cached.defaultAttrs && currentAttr in cached.defaultAttrs) { + delete cached.defaultAttrs[currentAttr]; + } + + this._capabilities.notifyChange(identifier, 'attributes', currentAttr); + return; + } + + // get current value from local else inflight else remote + // structuredClone current if not local (or always?) + // traverse path, update value at path + // notify change at first link in path. + // second pass optimization is change notifyChange signature to take an array path + + // guaranteed that we have path of at least 2 in length + const path: string[] = attr as string[]; + const cached = this.__peek(identifier, false); + + // get existing cache record for base path + const basePath = path[0]; const existing = - cached.inflightAttrs && attr in cached.inflightAttrs - ? cached.inflightAttrs[attr] - : cached.remoteAttrs && attr in cached.remoteAttrs - ? cached.remoteAttrs[attr] + cached.inflightAttrs && basePath in cached.inflightAttrs + ? cached.inflightAttrs[basePath] + : cached.remoteAttrs && basePath in cached.remoteAttrs + ? cached.remoteAttrs[basePath] : undefined; - if (existing !== value) { + + let existingAttr; + if (existing) { + existingAttr = (existing as ObjectValue)[path[1]]; + + for (let i = 2; i < path.length; i++) { + // the specific change we're making is at path[length - 1] + existingAttr = (existingAttr as ObjectValue)[path[i]]; + } + } + + if (existingAttr !== value) { cached.localAttrs = cached.localAttrs || (Object.create(null) as Record); - cached.localAttrs[attr] = value; + cached.localAttrs[basePath] = cached.localAttrs[basePath] || structuredClone(existing); cached.changes = cached.changes || (Object.create(null) as Record); - cached.changes[attr] = [existing, value]; + let currentLocal = cached.localAttrs[basePath] as ObjectValue; + let nextLink = 1; + + while (nextLink < path.length - 1) { + currentLocal = currentLocal[path[nextLink++]] as ObjectValue; + } + currentLocal[path[nextLink]] = value as ObjectValue; + + cached.changes[basePath] = [existing, cached.localAttrs[basePath] as ObjectValue]; + + // since we initiaize the value as basePath as a clone of the value at the remote basePath + // then in theory we can use JSON.stringify to compare the two values as key insertion order + // ought to be consistent. + // we try/catch this because users have a habit of doing "Bad Things"TM wherein the cache contains + // stateful values that are not JSON serializable correctly such as Dates. + // in the case that we error, we fallback to not removing the local value + // so that any changes we don't understand are preserved. Thse objects would then sometimes + // appear to be dirty unnecessarily, and for folks that open an issue we can guide them + // to make their cache data less stateful. } else if (cached.localAttrs) { - delete cached.localAttrs[attr]; - delete cached.changes![attr]; - } + try { + if (!existing) { + return; + } + const existingStr = JSON.stringify(existing); + const newStr = JSON.stringify(cached.localAttrs[basePath]); - if (cached.defaultAttrs && attr in cached.defaultAttrs) { - delete cached.defaultAttrs[attr]; + if (existingStr !== newStr) { + delete cached.localAttrs[basePath]; + delete cached.changes![basePath]; + } + } catch (e) { + // noop + } } - this._capabilities.notifyChange(identifier, 'attributes', attr); + this._capabilities.notifyChange(identifier, 'attributes', basePath); } /** diff --git a/packages/schema-record/src/managed-array.ts b/packages/schema-record/src/managed-array.ts index 6cfccd888bd..915d6bfe788 100644 --- a/packages/schema-record/src/managed-array.ts +++ b/packages/schema-record/src/managed-array.ts @@ -4,13 +4,13 @@ import { addToTransaction, createSignal, subscribe } from '@ember-data/tracking/ import { assert } from '@warp-drive/build-config/macros'; import type { StableRecordIdentifier } from '@warp-drive/core-types'; import type { Cache } from '@warp-drive/core-types/cache'; -import type { ArrayValue, Value } from '@warp-drive/core-types/json/raw'; +import type { ArrayValue, ObjectValue, Value } from '@warp-drive/core-types/json/raw'; import type { OpaqueRecordInstance } from '@warp-drive/core-types/record'; -import type { ArrayField } from '@warp-drive/core-types/schema/fields'; +import type { ArrayField, HashField, SchemaArrayField } from '@warp-drive/core-types/schema/fields'; -import type { SchemaRecord } from './record'; +import { SchemaRecord } from './record'; import type { SchemaService } from './schema'; -import { ARRAY_SIGNAL, MUTATE, SOURCE } from './symbols'; +import { ARRAY_SIGNAL, Editable, Identifier, Legacy, MUTATE, SOURCE } from './symbols'; export function notifyArray(arr: ManagedArray) { addToTransaction(arr[ARRAY_SIGNAL]); @@ -106,7 +106,7 @@ export interface ManagedArray extends Omit, '[]'> { export class ManagedArray { [SOURCE]: unknown[]; declare address: StableRecordIdentifier; - declare key: string; + declare path: string[]; declare owner: SchemaRecord; declare [ARRAY_SIGNAL]: Signal; @@ -114,11 +114,12 @@ export class ManagedArray { store: Store, schema: SchemaService, cache: Cache, - field: ArrayField, + field: ArrayField | SchemaArrayField, data: unknown[], address: StableRecordIdentifier, - key: string, - owner: SchemaRecord + path: string[], + owner: SchemaRecord, + isSchemaArray: boolean ) { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; @@ -127,10 +128,22 @@ export class ManagedArray { const _SIGNAL = this[ARRAY_SIGNAL]; const boundFns = new Map(); this.address = address; - this.key = key; + this.path = path; this.owner = owner; let transaction = false; - + type StorageKlass = typeof WeakMap>; + const mode = (field as SchemaArrayField).options?.key ?? '@identity'; + const RefStorage: StorageKlass = + mode === '@identity' + ? (WeakMap as unknown as StorageKlass) + : // CAUTION CAUTION CAUTION + // this is a pile of lies + // the Map is Map> + // but TS does not understand how to juggle modes like this + // internal to a method like ours without us duplicating the code + // into two separate methods. + Map>; + const ManagedRecordRefs = isSchemaArray ? new RefStorage() : null; const proxy = new Proxy(this[SOURCE], { get>(target: unknown[], prop: keyof R, receiver: R) { if (prop === ARRAY_SIGNAL) { @@ -139,9 +152,6 @@ export class ManagedArray { if (prop === 'address') { return self.address; } - if (prop === 'key') { - return self.key; - } if (prop === 'owner') { return self.owner; } @@ -150,7 +160,7 @@ export class ManagedArray { if (_SIGNAL.shouldReset && (index !== null || SYNC_PROPS.has(prop) || isArrayGetter(prop))) { _SIGNAL.t = false; _SIGNAL.shouldReset = false; - const newData = cache.getAttr(self.address, self.key); + const newData = cache.getAttr(address, path); if (newData && newData !== self[SOURCE]) { self[SOURCE].length = 0; self[SOURCE].push(...(newData as ArrayValue)); @@ -158,7 +168,78 @@ export class ManagedArray { } if (index !== null) { - const val = target[index]; + let val; + if (mode === '@hash') { + val = target[index]; + const hashField = schema.resource({ type: field.type! }).identity as HashField; + const hashFn = schema.hashFn(hashField); + val = hashFn(val as object, null, null); + } else { + // if mode is not @identity or @index, then access the key path. + // we should assert that `mode` is a string + // it should read directly from the cache value for that field (e.g. no derivation, no transformation) + // and, we likely should lookup the associated field and throw an error IF + // the given field does not exist OR + // the field is anything other than a GenericField or LegacyAttributeField. + if (mode !== '@identity' && mode !== '@index') { + assert('mode must be a string', typeof mode === 'string'); + const modeField = schema.resource({ type: field.type! }).fields.find((f) => f.name === mode); + assert('field must exist in schema', modeField); + assert( + 'field must be a GenericField or LegacyAttributeField', + modeField.kind === 'field' || modeField.kind === 'attribute' + ); + } + val = + mode === '@identity' + ? target[index] + : mode === '@index' + ? '@index' + : (target[index] as ObjectValue)[mode]; + } + + if (isSchemaArray) { + if (!transaction) { + subscribe(_SIGNAL); + } + + if (val) { + const recordRef = ManagedRecordRefs!.get(val); + let record = recordRef?.deref(); + + if (!record) { + const recordPath = path.slice(); + // this is a dirty lie since path is string[] but really we + // should change the types for paths to `Array` + // TODO we should allow the schema for the field to define a "key" + // for stability. Default should be `@identity` which means that + // same object reference from cache should result in same SchemaRecord + // embedded object. + recordPath.push(index as unknown as string); + record = new SchemaRecord( + store, + self.owner[Identifier], + { [Editable]: self.owner[Editable], [Legacy]: self.owner[Legacy] }, + true, + field.type, + recordPath + ); + // if mode is not @identity or @index, then access the key path now + // to determine the key value. + // chris says we can implement this as a special kind `@hash` which + // would be a function that only has access to the cache value and not + // the record itself, so derivation is possible but intentionally limited + // and non-reactive? + ManagedRecordRefs!.set(val, new WeakRef(record)); + } else { + // TODO update embeddedPath if required + } + return record; + } + + return val; + } + if (!transaction) { subscribe(_SIGNAL); } @@ -205,11 +286,6 @@ export class ManagedArray { self.address = value; return true; } - if (prop === 'key') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - self.key = value; - return true; - } if (prop === 'owner') { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment self.owner = value; @@ -219,16 +295,22 @@ export class ManagedArray { if (reflect) { if (!field.type) { - cache.setAttr(self.address, self.key, self[SOURCE] as Value); + cache.setAttr(address, path, self[SOURCE] as Value); _SIGNAL.shouldReset = true; return true; } - const transform = schema.transformation(field); - const rawValue = (self[SOURCE] as ArrayValue).map((item) => - transform.serialize(item, field.options ?? null, self.owner) - ); - cache.setAttr(self.address, self.key, rawValue as Value); + let rawValue = self[SOURCE] as ArrayValue; + if (!isSchemaArray) { + const transform = schema.transformation(field); + if (!transform) { + throw new Error(`No '${field.type}' transform defined for use by ${address.type}.${String(prop)}`); + } + rawValue = (self[SOURCE] as ArrayValue).map((item) => + transform.serialize(item, field.options ?? null, self.owner) + ); + } + cache.setAttr(address, path, rawValue as Value); _SIGNAL.shouldReset = true; } return reflect; diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts index a7bf06fcca2..679da42f908 100644 --- a/packages/schema-record/src/record.ts +++ b/packages/schema-record/src/record.ts @@ -25,6 +25,7 @@ import type { GenericField, LocalField, ObjectField, + SchemaArrayField, } from '@warp-drive/core-types/schema/fields'; import type { Link, Links } from '@warp-drive/core-types/spec/json-api-raw'; import { RecordStore } from '@warp-drive/core-types/symbols'; @@ -32,11 +33,33 @@ import { RecordStore } from '@warp-drive/core-types/symbols'; import { ManagedArray } from './managed-array'; import { ManagedObject } from './managed-object'; import type { SchemaService } from './schema'; -import { ARRAY_SIGNAL, Checkout, Destroy, Editable, Identifier, Legacy, OBJECT_SIGNAL, Parent } from './symbols'; +import { + ARRAY_SIGNAL, + Checkout, + Destroy, + Editable, + EmbeddedPath, + EmbeddedType, + Identifier, + Legacy, + OBJECT_SIGNAL, + Parent, +} from './symbols'; export { Editable, Legacy } from './symbols'; -const IgnoredGlobalFields = new Set(['then', STRUCTURED]); -const symbolList = [Destroy, RecordStore, Identifier, Editable, Parent, Checkout, Legacy, Signals]; +const IgnoredGlobalFields = new Set(['length', 'nodeType', 'then', 'setInterval', STRUCTURED]); +const symbolList = [ + Destroy, + RecordStore, + Identifier, + Editable, + Parent, + Checkout, + Legacy, + Signals, + EmbeddedPath, + EmbeddedType, +]; const RecordSymbols = new Set(symbolList); type RecordSymbol = (typeof symbolList)[number]; @@ -75,7 +98,7 @@ function computeField( record: SchemaRecord, identifier: StableRecordIdentifier, field: GenericField, - prop: string + prop: string | string[] ): unknown { const rawValue = cache.getAttr(identifier, prop); if (!field.type) { @@ -91,8 +114,9 @@ function computeArray( cache: Cache, record: SchemaRecord, identifier: StableRecordIdentifier, - field: ArrayField, - prop: string + field: ArrayField | SchemaArrayField, + path: string[], + isSchemaArray = false ) { // the thing we hand out needs to know its owner and path in a private manner // its "address" is the parent identifier (identifier) + field name (field.name) @@ -108,11 +132,11 @@ function computeArray( if (managedArray) { return managedArray; } else { - const rawValue = cache.getAttr(identifier, prop) as unknown[]; + const rawValue = cache.getAttr(identifier, path) as unknown[]; if (!rawValue) { return null; } - managedArray = new ManagedArray(store, schema, cache, field, rawValue, identifier, prop, record); + managedArray = new ManagedArray(store, schema, cache, field, rawValue, identifier, path, record, isSchemaArray); if (!managedArrayMapForRecord) { ManagedArrayMap.set(record, new Map([[field, managedArray]])); } else { @@ -240,6 +264,10 @@ defineSignal(ResourceRelationship.prototype, 'data'); defineSignal(ResourceRelationship.prototype, 'links'); defineSignal(ResourceRelationship.prototype, 'meta'); +function isPathMatch(a: string[], b: string[]) { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + function getHref(link?: Link | null): string | null { if (!link) { return null; @@ -268,43 +296,90 @@ function computeResource( export class SchemaRecord { declare [RecordStore]: Store; declare [Identifier]: StableRecordIdentifier; + declare [Parent]: StableRecordIdentifier; + declare [EmbeddedType]: string | null; + declare [EmbeddedPath]: string[] | null; declare [Editable]: boolean; declare [Legacy]: boolean; declare [Signals]: Map; + declare [Symbol.toStringTag]: `SchemaRecord<${string}>`; declare ___notifications: object; - constructor(store: Store, identifier: StableRecordIdentifier, Mode: { [Editable]: boolean; [Legacy]: boolean }) { + constructor( + store: Store, + identifier: StableRecordIdentifier, + Mode: { [Editable]: boolean; [Legacy]: boolean }, + isEmbedded = false, + embeddedType: string | null = null, + embeddedPath: string[] | null = null + ) { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; this[RecordStore] = store; - this[Identifier] = identifier; + if (isEmbedded) { + this[Parent] = identifier; + } else { + this[Identifier] = identifier; + } const IS_EDITABLE = (this[Editable] = Mode[Editable] ?? false); this[Legacy] = Mode[Legacy] ?? false; const schema = store.schema as unknown as SchemaService; const cache = store.cache; const identityField = schema.resource(identifier).identity; - const fields = schema.fields(identifier); + + this[EmbeddedType] = embeddedType; + this[EmbeddedPath] = embeddedPath; + + let fields: Map; + if (isEmbedded) { + fields = schema.fields({ type: embeddedType as string }); + } else { + fields = schema.fields(identifier); + } const signals: Map = new Map(); this[Signals] = signals; + // what signal do we need for embedded record? this.___notifications = store.notifications.subscribe( identifier, - (_: StableRecordIdentifier, type: NotificationType, key?: string) => { + (_: StableRecordIdentifier, type: NotificationType, key?: string | string[]) => { switch (type) { case 'attributes': if (key) { - const signal = signals.get(key); - if (signal) { - addToTransaction(signal); - } - const field = fields.get(key); - if (field?.kind === 'array') { - const peeked = peekManagedArray(self, field); - if (peeked) { - const arrSignal = peeked[ARRAY_SIGNAL]; - arrSignal.shouldReset = true; - addToTransaction(arrSignal); + if (Array.isArray(key)) { + if (!isEmbedded) return; // deep paths will be handled by embedded records + // TODO we should have the notification manager + // ensure it is safe for each callback to mutate this array + if (isPathMatch(embeddedPath!, key)) { + // handle the notification + // TODO we should likely handle this notification here + // also we should add a LOGGING flag + // eslint-disable-next-line no-console + console.warn(`Notification unhandled for ${key.join(',')} on ${identifier.type}`, self); + return; + } + + // TODO we should add a LOGGING flag + // console.log(`Deep notification skipped for ${key.join('.')} on ${identifier.type}`, self); + // deep notify the key path + } else { + if (isEmbedded) return; // base paths never apply to embedded records + + // TODO determine what LOGGING flag to wrap this in if any + // console.log(`Notification for ${key} on ${identifier.type}`, self); + const signal = signals.get(key); + if (signal) { + addToTransaction(signal); + } + const field = fields.get(key); + if (field?.kind === 'array' || field?.kind === 'schema-array') { + const peeked = peekManagedArray(self, field); + if (peeked) { + const arrSignal = peeked[ARRAY_SIGNAL]; + arrSignal.shouldReset = true; + addToTransaction(arrSignal); + } } } } @@ -314,11 +389,61 @@ export class SchemaRecord { ); return new Proxy(this, { + ownKeys() { + return Array.from(fields.keys()); + }, + + has(target: SchemaRecord, prop: string | number | symbol) { + return fields.has(prop as string); + }, + + getOwnPropertyDescriptor(target, prop) { + if (!fields.has(prop as string)) { + throw new Error(`No field named ${String(prop)} on ${identifier.type}`); + } + const schemaForField = fields.get(prop as string)!; + switch (schemaForField.kind) { + case 'derived': + return { + writable: false, + enumerable: true, + configurable: true, + }; + case '@local': + case 'field': + case 'attribute': + case 'resource': + case 'schema-array': + case 'array': + case 'schema-object': + case 'object': + return { + writable: IS_EDITABLE, + enumerable: true, + configurable: true, + }; + } + }, + get(target: SchemaRecord, prop: string | number | symbol, receiver: typeof Proxy) { if (RecordSymbols.has(prop as RecordSymbol)) { return target[prop as keyof SchemaRecord]; } + if (prop === Symbol.toStringTag) { + return `SchemaRecord<${identifier.type}:${identifier.id} (${identifier.lid})>`; + } + + if (prop === 'toString') { + return function () { + return `SchemaRecord<${identifier.type}:${identifier.id} (${identifier.lid})>`; + }; + } + + if (prop === Symbol.toPrimitive) { + return null; + } + if (prop === '___notifications') { return target.___notifications; } @@ -327,11 +452,17 @@ export class SchemaRecord { // for its own usage. // _, @, $, * + const propArray = isEmbedded ? embeddedPath!.slice() : []; + propArray.push(prop as string); + const field = prop === identityField?.name ? identityField : fields.get(prop as string); if (!field) { if (IgnoredGlobalFields.has(prop as string)) { return undefined; } + if (prop === 'constructor') { + return SchemaRecord; + } throw new Error(`No field named ${String(prop)} on ${identifier.type}`); } @@ -353,7 +484,7 @@ export class SchemaRecord { !target[Legacy] ); entangleSignal(signals, receiver, field.name); - return computeField(schema, cache, target, identifier, field, prop as string); + return computeField(schema, cache, target, identifier, field, propArray); case 'attribute': entangleSignal(signals, receiver, field.name); return computeAttribute(cache, identifier, prop as string); @@ -367,14 +498,15 @@ export class SchemaRecord { case 'derived': return computeDerivation(schema, receiver as unknown as SchemaRecord, identifier, field, prop as string); case 'schema-array': - throw new Error(`Not Implemented`); + entangleSignal(signals, receiver, field.name); + return computeArray(store, schema, cache, target, identifier, field, propArray, true); case 'array': assert( `SchemaRecord.${field.name} is not available in legacy mode because it has type '${field.kind}'`, !target[Legacy] ); entangleSignal(signals, receiver, field.name); - return computeArray(store, schema, cache, target, identifier, field, prop as string); + return computeArray(store, schema, cache, target, identifier, field, propArray); case 'schema-object': // validate any access off of schema, no transform to run // use raw cache value as the object to manage @@ -396,6 +528,9 @@ export class SchemaRecord { throw new Error(`Cannot set ${String(prop)} on ${identifier.type} because the record is not editable`); } + const propArray = isEmbedded ? embeddedPath!.slice() : []; + propArray.push(prop as string); + const field = fields.get(prop as string); if (!field) { throw new Error(`There is no field named ${String(prop)} on ${identifier.type}`); @@ -412,21 +547,21 @@ export class SchemaRecord { } case 'field': { if (!field.type) { - cache.setAttr(identifier, prop as string, value as Value); + cache.setAttr(identifier, propArray, value as Value); return true; } const transform = schema.transformation(field); const rawValue = transform.serialize(value, field.options ?? null, target); - cache.setAttr(identifier, prop as string, rawValue); + cache.setAttr(identifier, propArray, rawValue); return true; } case 'attribute': { - cache.setAttr(identifier, prop as string, value as Value); + cache.setAttr(identifier, propArray, value as Value); return true; } case 'array': { if (!field.type) { - cache.setAttr(identifier, prop as string, (value as ArrayValue)?.slice()); + cache.setAttr(identifier, propArray, (value as ArrayValue)?.slice()); const peeked = peekManagedArray(self, field); if (peeked) { const arrSignal = peeked[ARRAY_SIGNAL]; @@ -442,12 +577,28 @@ export class SchemaRecord { const rawValue = (value as ArrayValue).map((item) => transform.serialize(item, field.options ?? null, target) ); - cache.setAttr(identifier, prop as string, rawValue); + cache.setAttr(identifier, propArray, rawValue); + const peeked = peekManagedArray(self, field); + if (peeked) { + const arrSignal = peeked[ARRAY_SIGNAL]; + arrSignal.shouldReset = true; + } + return true; + } + case 'schema-array': { + const arrayValue = (value as ArrayValue)?.slice(); + if (!Array.isArray(arrayValue)) { + ManagedArrayMap.delete(target); + } + cache.setAttr(identifier, propArray, arrayValue); const peeked = peekManagedArray(self, field); if (peeked) { const arrSignal = peeked[ARRAY_SIGNAL]; arrSignal.shouldReset = true; } + if (!Array.isArray(value)) { + ManagedArrayMap.delete(target); + } return true; } case 'object': { @@ -459,7 +610,7 @@ export class SchemaRecord { ManagedObjectMap.delete(target); } - cache.setAttr(identifier, prop as string, newValue as Value); + cache.setAttr(identifier, propArray, newValue as Value); const peeked = peekManagedObject(self, field); if (peeked) { @@ -471,7 +622,7 @@ export class SchemaRecord { const transform = schema.transformation(field); const rawValue = transform.serialize({ ...(value as ObjectValue) }, field.options ?? null, target); - cache.setAttr(identifier, prop as string, rawValue); + cache.setAttr(identifier, propArray, rawValue); const peeked = peekManagedObject(self, field); if (peeked) { const objSignal = peeked[OBJECT_SIGNAL]; diff --git a/packages/schema-record/src/symbols.ts b/packages/schema-record/src/symbols.ts index 76cbb737656..aa2889f90b7 100644 --- a/packages/schema-record/src/symbols.ts +++ b/packages/schema-record/src/symbols.ts @@ -43,3 +43,6 @@ export const Editable = getOrSetGlobal('Editable', Symbol('Editable')); export const Parent = getOrSetGlobal('Parent', Symbol('Parent')); export const Checkout = getOrSetGlobal('Checkout', Symbol('Checkout')); export const Legacy = getOrSetGlobal('Legacy', Symbol('Legacy')); + +export const EmbeddedPath = getOrSetGlobal('EmbeddedPath', Symbol('EmbeddedPath')); +export const EmbeddedType = getOrSetGlobal('EmbeddedType', Symbol('EmbeddedType')); diff --git a/patches/qunit@2.19.4.patch b/patches/qunit@2.19.4.patch index 607526acb2f..856162c3c6d 100644 --- a/patches/qunit@2.19.4.patch +++ b/patches/qunit@2.19.4.patch @@ -1,8 +1,31 @@ diff --git a/qunit/qunit.js b/qunit/qunit.js -index 5e48b79303e8bfe33ba60a9407c2d67879a07841..6020dde7a608bb1a496824bc24c85bc2f7281326 100644 +index 5e48b79303e8bfe33ba60a9407c2d67879a07841..649cd30f59991f1870570d6ec831ed602862a772 100644 --- a/qunit/qunit.js +++ b/qunit/qunit.js -@@ -5647,6 +5647,9 @@ +@@ -8,6 +8,10 @@ + */ + (function () { + 'use strict'; ++ function getKeys(obj) { ++ if (!obj) { return []; } ++ return Object.keys(obj).concat(getKeys(Object.getPrototypeOf(obj))); ++ } + + function _typeof(obj) { + "@babel/helpers - typeof"; +@@ -1003,10 +1007,7 @@ + return '[object Object]'; + } + dump.up(); +- var keys = []; +- for (var key in map) { +- keys.push(key); +- } ++ var keys = getKeys(map); + + // Some properties are not always enumerable on Error objects. + var nonEnumerableProperties = ['message', 'name']; +@@ -5647,6 +5648,9 @@ appendToolbar(beginDetails); } function appendTest(name, testId, moduleName) { @@ -12,7 +35,7 @@ index 5e48b79303e8bfe33ba60a9407c2d67879a07841..6020dde7a608bb1a496824bc24c85bc2 var tests = id('qunit-tests'); if (!tests) { return; -@@ -5831,6 +5834,13 @@ +@@ -5831,6 +5835,13 @@ assertList.appendChild(assertLi); }); QUnit.testDone(function (details) { @@ -26,7 +49,7 @@ index 5e48b79303e8bfe33ba60a9407c2d67879a07841..6020dde7a608bb1a496824bc24c85bc2 var tests = id('qunit-tests'); var testItem = id('qunit-test-output-' + details.testId); if (!tests || !testItem) { -@@ -5849,13 +5859,10 @@ +@@ -5849,13 +5860,10 @@ var good = details.passed; var bad = details.failed; @@ -40,7 +63,7 @@ index 5e48b79303e8bfe33ba60a9407c2d67879a07841..6020dde7a608bb1a496824bc24c85bc2 if (config.collapse) { if (!collapseNext) { // Skip collapsing the first failing test -@@ -5871,7 +5878,6 @@ +@@ -5871,7 +5879,6 @@ var testTitle = testItem.firstChild; var testCounts = bad ? "" + bad + ', ' + "" + good + ', ' : ''; testTitle.innerHTML += " (" + testCounts + details.assertions.length + ')'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a2b7002aea..d355722b0de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ patchedDependencies: hash: gppmtiox6pymwamrfimkbxfrsm path: patches/@ember__test-helpers@3.3.0.patch qunit@2.19.4: - hash: h2fz5inojlzu6daraxt5bghsqy + hash: 2jwk2nz4gqke2k5hv6ptj42llu path: patches/qunit@2.19.4.patch testem@3.11.0: hash: yfkum5c5nfihh3ce3f64tnp5rq @@ -144,13 +144,13 @@ importers: version: 6.0.4(@babel/core@7.24.5)(rollup@4.17.2) '@typescript-eslint/eslint-plugin': specifier: ^7.9.0 - version: 7.9.0(@typescript-eslint/parser@7.9.0)(eslint@8.57.0)(typescript@5.4.5) + version: 7.10.0(@typescript-eslint/parser@7.10.0)(eslint@8.57.0)(typescript@5.4.5) '@typescript-eslint/parser': specifier: ^7.9.0 - version: 7.9.0(eslint@8.57.0)(typescript@5.4.5) + version: 7.10.0(eslint@8.57.0)(typescript@5.4.5) ember-eslint-parser: specifier: ^0.4.2 - version: 0.4.2(@babel/core@7.24.5)(@typescript-eslint/parser@7.9.0)(eslint@8.57.0) + version: 0.4.2(@babel/core@7.24.5)(@typescript-eslint/parser@7.10.0)(eslint@8.57.0) eslint: specifier: ^8.57.0 version: 8.57.0 @@ -159,7 +159,7 @@ importers: version: 9.1.0(eslint@8.57.0) eslint-plugin-import: specifier: ^2.29.1 - version: 2.29.1(@typescript-eslint/parser@7.9.0)(eslint@8.57.0) + version: 2.29.1(@typescript-eslint/parser@7.10.0)(eslint@8.57.0) eslint-plugin-mocha: specifier: ^10.4.3 version: 10.4.3(eslint@8.57.0) @@ -174,7 +174,7 @@ importers: version: 12.1.0(eslint@8.57.0) globals: specifier: ^15.2.0 - version: 15.2.0 + version: 15.3.0 rollup: specifier: ^4.17.2 version: 4.17.2 @@ -183,7 +183,7 @@ importers: version: 5.4.5 typescript-eslint: specifier: ^7.9.0 - version: 7.9.0(eslint@8.57.0)(typescript@5.4.5) + version: 7.10.0(eslint@8.57.0)(typescript@5.4.5) vite: specifier: ^5.2.11 version: 5.2.11(@types/node@20.12.12) @@ -289,7 +289,7 @@ importers: version: 0.0.14 qunit: specifier: 2.19.4 - version: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + version: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) typescript: specifier: ^5.4.5 version: 5.4.5 @@ -576,7 +576,7 @@ importers: version: 5.3.0 commander: specifier: ^12.0.0 - version: 12.0.0 + version: 12.1.0 ignore: specifier: ^5.3.1 version: 5.3.1 @@ -595,7 +595,7 @@ importers: devDependencies: '@types/bun': specifier: ^1.1.2 - version: 1.1.2 + version: 1.1.3 '@types/jscodeshift': specifier: 0.11.11 version: 0.11.11 @@ -610,7 +610,7 @@ importers: version: 0.0.14 qunit: specifier: 2.19.4 - version: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + version: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) packages/core-types: dependencies: @@ -1004,7 +1004,7 @@ importers: version: 5.3.0 hono: specifier: ^4.3.6 - version: 4.3.6 + version: 4.3.9 devDependencies: '@babel/core': specifier: ^7.24.5 @@ -1854,7 +1854,7 @@ importers: version: 4.1.2 qunit: specifier: 2.19.4 - version: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + version: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) semver: specifier: ^7.6.2 version: 7.6.2 @@ -2247,7 +2247,7 @@ importers: version: file:packages/codemods '@types/bun': specifier: ^1.1.2 - version: 1.1.2 + version: 1.1.3 '@types/jscodeshift': specifier: 0.11.11 version: 0.11.11 @@ -2262,7 +2262,7 @@ importers: version: 0.0.14 qunit: specifier: 2.19.4 - version: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + version: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) tsx: specifier: ^4.10.4 version: 4.10.5 @@ -2280,7 +2280,7 @@ importers: version: 0.0.14 qunit: specifier: 2.19.4 - version: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + version: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) tests/ember-data__adapter: dependencies: @@ -3186,7 +3186,7 @@ importers: version: 4.7.0 qunit: specifier: 2.19.4 - version: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + version: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) qunit-dom: specifier: ^3.1.1 version: 3.1.2 @@ -3364,7 +3364,7 @@ importers: version: 0.0.14 qunit: specifier: 2.19.4 - version: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + version: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) qunit-dom: specifier: ^3.1.1 version: 3.1.2 @@ -3568,7 +3568,7 @@ importers: version: 1.10.0 qunit: specifier: 2.19.4 - version: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + version: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) qunit-console-grouper: specifier: ^0.3.0 version: 0.3.0 @@ -3730,7 +3730,7 @@ importers: version: 4.7.0 qunit: specifier: 2.19.4 - version: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + version: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) qunit-dom: specifier: ^3.1.1 version: 3.1.2 @@ -4021,7 +4021,7 @@ importers: version: 3.4.7 qunit: specifier: 2.19.4 - version: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + version: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) qunit-dom: specifier: ^3.1.1 version: 3.1.2 @@ -4481,7 +4481,7 @@ importers: version: 4.7.0 qunit: specifier: 2.19.4 - version: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + version: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) qunit-console-grouper: specifier: ^0.3.0 version: 0.3.0 @@ -5853,7 +5853,7 @@ packages: ember-cli-version-checker: 5.1.2 git-repo-info: 2.1.1 npm-git-info: 1.0.3 - semver: 7.6.0 + semver: 7.6.2 silent-error: 1.1.1 transitivePeerDependencies: - '@glint/template' @@ -7692,10 +7692,10 @@ packages: - supports-color dev: true - /@types/bun@1.1.2: - resolution: {integrity: sha512-pRBDD3EDqPf83qe95i3EpYu5G2J8bbb78a3736vnCm2K8YWtEE5cvJUq2jkKvJhW07YTfQtbImywIwRhWL8z3Q==} + /@types/bun@1.1.3: + resolution: {integrity: sha512-i+mVz8C/lx+RprDR6Mr402iE1kmajgJPnmSfJ/NvU85sGGXSylYZ/6yc+XhVLr2E/t8o6HmjwV0evtnUOR0CFA==} dependencies: - bun-types: 1.1.8 + bun-types: 1.1.9 dev: true /@types/chai-as-promised@7.1.8: @@ -7894,8 +7894,8 @@ packages: '@types/yargs-parser': 21.0.3 dev: true - /@typescript-eslint/eslint-plugin@7.9.0(@typescript-eslint/parser@7.9.0)(eslint@8.57.0)(typescript@5.4.5): - resolution: {integrity: sha512-6e+X0X3sFe/G/54aC3jt0txuMTURqLyekmEHViqyA2VnxhLMpvA6nqmcjIy+Cr9tLDHPssA74BP5Mx9HQIxBEA==} + /@typescript-eslint/eslint-plugin@7.10.0(@typescript-eslint/parser@7.10.0)(eslint@8.57.0)(typescript@5.4.5): + resolution: {integrity: sha512-PzCr+a/KAef5ZawX7nbyNwBDtM1HdLIT53aSA2DDlxmxMngZ43O8SIePOeX8H5S+FHXeI6t97mTt/dDdzY4Fyw==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: '@typescript-eslint/parser': ^7.0.0 @@ -7906,11 +7906,11 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 7.9.0(eslint@8.57.0)(typescript@5.4.5) - '@typescript-eslint/scope-manager': 7.9.0 - '@typescript-eslint/type-utils': 7.9.0(eslint@8.57.0)(typescript@5.4.5) - '@typescript-eslint/utils': 7.9.0(eslint@8.57.0)(typescript@5.4.5) - '@typescript-eslint/visitor-keys': 7.9.0 + '@typescript-eslint/parser': 7.10.0(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/scope-manager': 7.10.0 + '@typescript-eslint/type-utils': 7.10.0(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/utils': 7.10.0(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/visitor-keys': 7.10.0 eslint: 8.57.0 graphemer: 1.4.0 ignore: 5.3.1 @@ -7921,8 +7921,8 @@ packages: - supports-color dev: false - /@typescript-eslint/parser@7.9.0(eslint@8.57.0)(typescript@5.4.5): - resolution: {integrity: sha512-qHMJfkL5qvgQB2aLvhUSXxbK7OLnDkwPzFalg458pxQgfxKDfT1ZDbHQM/I6mDIf/svlMkj21kzKuQ2ixJlatQ==} + /@typescript-eslint/parser@7.10.0(eslint@8.57.0)(typescript@5.4.5): + resolution: {integrity: sha512-2EjZMA0LUW5V5tGQiaa2Gys+nKdfrn2xiTIBLR4fxmPmVSvgPcKNW+AE/ln9k0A4zDUti0J/GZXMDupQoI+e1w==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: eslint: ^8.56.0 @@ -7931,10 +7931,10 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 7.9.0 - '@typescript-eslint/types': 7.9.0 - '@typescript-eslint/typescript-estree': 7.9.0(typescript@5.4.5) - '@typescript-eslint/visitor-keys': 7.9.0 + '@typescript-eslint/scope-manager': 7.10.0 + '@typescript-eslint/types': 7.10.0 + '@typescript-eslint/typescript-estree': 7.10.0(typescript@5.4.5) + '@typescript-eslint/visitor-keys': 7.10.0 debug: 4.3.4(supports-color@8.1.1) eslint: 8.57.0 typescript: 5.4.5 @@ -7942,16 +7942,16 @@ packages: - supports-color dev: false - /@typescript-eslint/scope-manager@7.9.0: - resolution: {integrity: sha512-ZwPK4DeCDxr3GJltRz5iZejPFAAr4Wk3+2WIBaj1L5PYK5RgxExu/Y68FFVclN0y6GGwH8q+KgKRCvaTmFBbgQ==} + /@typescript-eslint/scope-manager@7.10.0: + resolution: {integrity: sha512-7L01/K8W/VGl7noe2mgH0K7BE29Sq6KAbVmxurj8GGaPDZXPr8EEQ2seOeAS+mEV9DnzxBQB6ax6qQQ5C6P4xg==} engines: {node: ^18.18.0 || >=20.0.0} dependencies: - '@typescript-eslint/types': 7.9.0 - '@typescript-eslint/visitor-keys': 7.9.0 + '@typescript-eslint/types': 7.10.0 + '@typescript-eslint/visitor-keys': 7.10.0 dev: false - /@typescript-eslint/type-utils@7.9.0(eslint@8.57.0)(typescript@5.4.5): - resolution: {integrity: sha512-6Qy8dfut0PFrFRAZsGzuLoM4hre4gjzWJB6sUvdunCYZsYemTkzZNwF1rnGea326PHPT3zn5Lmg32M/xfJfByA==} + /@typescript-eslint/type-utils@7.10.0(eslint@8.57.0)(typescript@5.4.5): + resolution: {integrity: sha512-D7tS4WDkJWrVkuzgm90qYw9RdgBcrWmbbRkrLA4d7Pg3w0ttVGDsvYGV19SH8gPR5L7OtcN5J1hTtyenO9xE9g==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: eslint: ^8.56.0 @@ -7960,8 +7960,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 7.9.0(typescript@5.4.5) - '@typescript-eslint/utils': 7.9.0(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/typescript-estree': 7.10.0(typescript@5.4.5) + '@typescript-eslint/utils': 7.10.0(eslint@8.57.0)(typescript@5.4.5) debug: 4.3.4(supports-color@8.1.1) eslint: 8.57.0 ts-api-utils: 1.3.0(typescript@5.4.5) @@ -7970,13 +7970,13 @@ packages: - supports-color dev: false - /@typescript-eslint/types@7.9.0: - resolution: {integrity: sha512-oZQD9HEWQanl9UfsbGVcZ2cGaR0YT5476xfWE0oE5kQa2sNK2frxOlkeacLOTh9po4AlUT5rtkGyYM5kew0z5w==} + /@typescript-eslint/types@7.10.0: + resolution: {integrity: sha512-7fNj+Ya35aNyhuqrA1E/VayQX9Elwr8NKZ4WueClR3KwJ7Xx9jcCdOrLW04h51de/+gNbyFMs+IDxh5xIwfbNg==} engines: {node: ^18.18.0 || >=20.0.0} dev: false - /@typescript-eslint/typescript-estree@7.9.0(typescript@5.4.5): - resolution: {integrity: sha512-zBCMCkrb2YjpKV3LA0ZJubtKCDxLttxfdGmwZvTqqWevUPN0FZvSI26FalGFFUZU/9YQK/A4xcQF9o/VVaCKAg==} + /@typescript-eslint/typescript-estree@7.10.0(typescript@5.4.5): + resolution: {integrity: sha512-LXFnQJjL9XIcxeVfqmNj60YhatpRLt6UhdlFwAkjNc6jSUlK8zQOl1oktAP8PlWFzPQC1jny/8Bai3/HPuvN5g==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: typescript: '*' @@ -7984,8 +7984,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/types': 7.9.0 - '@typescript-eslint/visitor-keys': 7.9.0 + '@typescript-eslint/types': 7.10.0 + '@typescript-eslint/visitor-keys': 7.10.0 debug: 4.3.4(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 @@ -7997,27 +7997,27 @@ packages: - supports-color dev: false - /@typescript-eslint/utils@7.9.0(eslint@8.57.0)(typescript@5.4.5): - resolution: {integrity: sha512-5KVRQCzZajmT4Ep+NEgjXCvjuypVvYHUW7RHlXzNPuak2oWpVoD1jf5xCP0dPAuNIchjC7uQyvbdaSTFaLqSdA==} + /@typescript-eslint/utils@7.10.0(eslint@8.57.0)(typescript@5.4.5): + resolution: {integrity: sha512-olzif1Fuo8R8m/qKkzJqT7qwy16CzPRWBvERS0uvyc+DHd8AKbO4Jb7kpAvVzMmZm8TrHnI7hvjN4I05zow+tg==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: eslint: ^8.56.0 dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@typescript-eslint/scope-manager': 7.9.0 - '@typescript-eslint/types': 7.9.0 - '@typescript-eslint/typescript-estree': 7.9.0(typescript@5.4.5) + '@typescript-eslint/scope-manager': 7.10.0 + '@typescript-eslint/types': 7.10.0 + '@typescript-eslint/typescript-estree': 7.10.0(typescript@5.4.5) eslint: 8.57.0 transitivePeerDependencies: - supports-color - typescript dev: false - /@typescript-eslint/visitor-keys@7.9.0: - resolution: {integrity: sha512-iESPx2TNLDNGQLyjKhUvIKprlP49XNEK+MvIf9nIO7ZZaZdbnfWKHnXAgufpxqfA0YryH8XToi4+CjBgVnFTSQ==} + /@typescript-eslint/visitor-keys@7.10.0: + resolution: {integrity: sha512-9ntIVgsi6gg6FIq9xjEO4VQJvwOqA3jaBFQJ/6TK5AvEup2+cECI6Fh7QiBxmfMHXU0V0J4RyPeOU1VDNzl9cg==} engines: {node: ^18.18.0 || >=20.0.0} dependencies: - '@typescript-eslint/types': 7.9.0 + '@typescript-eslint/types': 7.10.0 eslint-visitor-keys: 3.4.3 dev: false @@ -9606,6 +9606,13 @@ packages: '@types/ws': 8.5.10 dev: true + /bun-types@1.1.9: + resolution: {integrity: sha512-3YuLiH4Ne/ghk7K6mHiaqCqKOMrtB0Z5p1WAskHSVgi0iMZgsARV4yGkbfi565YsStvUq6GXTWB3ga7M8cznkA==} + dependencies: + '@types/node': 20.12.12 + '@types/ws': 8.5.10 + dev: true + /bytes@1.0.0: resolution: {integrity: sha512-/x68VkHLeTl3/Ll8IvxdwzhrT+IyKc52e/oyHhA2RwqPqswSnjVbSddfPRwAsJtbilMAPSRWwAlpxdYsSWOTKQ==} @@ -10109,8 +10116,8 @@ packages: typical: 4.0.0 dev: true - /commander@12.0.0: - resolution: {integrity: sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==} + /commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} /commander@2.15.1: @@ -11488,7 +11495,7 @@ packages: engines: {node: '>= 0.10.0'} dev: true - /ember-eslint-parser@0.4.2(@babel/core@7.24.5)(@typescript-eslint/parser@7.9.0)(eslint@8.57.0): + /ember-eslint-parser@0.4.2(@babel/core@7.24.5)(@typescript-eslint/parser@7.10.0)(eslint@8.57.0): resolution: {integrity: sha512-DcKLI+2RgznicKOnxFAW/5ABGEk8JMCADw56wy1hvD/r1vNIIZZnoJC7rowx9XenPBhB75kt3/4ApaHxeYr2sA==} engines: {node: '>=16.0.0'} peerDependencies: @@ -11501,7 +11508,7 @@ packages: '@babel/core': 7.24.5(supports-color@8.1.1) '@babel/eslint-parser': 7.23.10(@babel/core@7.24.5)(eslint@8.57.0) '@glimmer/syntax': 0.92.0 - '@typescript-eslint/parser': 7.9.0(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/parser': 7.10.0(eslint@8.57.0)(typescript@5.4.5) content-tag: 1.2.2 eslint-scope: 7.2.2 html-tags: 3.3.1 @@ -11529,7 +11536,7 @@ packages: fs-extra: 11.2.0 js-yaml: 4.1.0 npmlog: 7.0.1 - qunit: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + qunit: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) rimraf: 5.0.5 semver: 7.6.2 silent-error: 1.1.1 @@ -11613,7 +11620,7 @@ packages: '@embroider/macros': 1.16.1(@babel/core@7.24.5)(@glint/template@1.4.0) ember-cli-test-loader: 3.1.0(@babel/core@7.24.5) ember-source: 5.8.0(@babel/core@7.24.5)(@glimmer/component@1.1.2)(@glint/template@1.4.0) - qunit: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + qunit: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) transitivePeerDependencies: - '@babel/core' - '@glint/template' @@ -11730,7 +11737,7 @@ packages: inflection: 2.0.1 route-recognizer: 0.3.4 router_js: 8.0.5(route-recognizer@0.3.4) - semver: 7.6.0 + semver: 7.6.2 silent-error: 1.1.1 simple-html-tokenizer: 0.5.11 webpack: 5.91.0 @@ -12086,7 +12093,7 @@ packages: - supports-color dev: false - /eslint-module-utils@2.8.1(@typescript-eslint/parser@7.9.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.0): + /eslint-module-utils@2.8.1(@typescript-eslint/parser@7.10.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.0): resolution: {integrity: sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==} engines: {node: '>=4'} peerDependencies: @@ -12107,7 +12114,7 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 7.9.0(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/parser': 7.10.0(eslint@8.57.0)(typescript@5.4.5) debug: 3.2.7 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 @@ -12127,7 +12134,7 @@ packages: eslint-compat-utils: 0.5.0(eslint@8.57.0) dev: false - /eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.9.0)(eslint@8.57.0): + /eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.10.0)(eslint@8.57.0): resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} engines: {node: '>=4'} peerDependencies: @@ -12137,7 +12144,7 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 7.9.0(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/parser': 7.10.0(eslint@8.57.0)(typescript@5.4.5) array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 array.prototype.flat: 1.3.2 @@ -12146,7 +12153,7 @@ packages: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.9.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.10.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -12443,7 +12450,7 @@ packages: pretty-ms: 9.0.0 signal-exit: 4.1.0 strip-final-newline: 4.0.0 - yoctocolors: 2.0.0 + yoctocolors: 2.0.2 dev: true /exists-sync@0.0.3: @@ -13406,7 +13413,7 @@ packages: fs.realpath: 1.0.0 minimatch: 8.0.4 minipass: 4.2.8 - path-scurry: 1.10.2 + path-scurry: 1.11.1 /global-modules@1.0.0: resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==} @@ -13436,8 +13443,8 @@ packages: dependencies: type-fest: 0.20.2 - /globals@15.2.0: - resolution: {integrity: sha512-FQ5YwCHZM3nCmtb5FzEWwdUc9K5d3V/w9mzcz8iGD1gC/aOTHc6PouYu0kkKipNJqHAT7m51sqzQjEjIP+cK0A==} + /globals@15.3.0: + resolution: {integrity: sha512-cCdyVjIUVTtX8ZsPkq1oCsOsLmGIswqnjZYMJJTGaNApj1yHtLSymKhwH51ttirREn75z3p4k051clwg7rvNKA==} engines: {node: '>=18'} dev: false @@ -13715,8 +13722,8 @@ packages: dependencies: parse-passwd: 1.0.0 - /hono@4.3.6: - resolution: {integrity: sha512-2IqXwrxWF4tG2AR7b5tMYn+KEnWK8UvdC/NUSbOKWj/Kj11OJqel58FxyiXLK5CcKLiL8aGtTe4lkBKXyaHMBQ==} + /hono@4.3.9: + resolution: {integrity: sha512-6c5LVE23HnIS8iBhY+XPmYJlPeeClznOi7mBNsAsJCgxo8Ciz75LTjqRUf5wv4RYq8kL+1KPLUZHCtKmbZssNg==} engines: {node: '>=16.0.0'} /hosted-git-info@4.1.0: @@ -16061,20 +16068,12 @@ packages: dependencies: path-root-regex: 0.1.2 - /path-scurry@1.10.2: - resolution: {integrity: sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==} - engines: {node: '>=16 || 14 >=14.17'} - dependencies: - lru-cache: 10.2.2 - minipass: 7.0.4 - /path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} dependencies: lru-cache: 10.2.2 minipass: 7.0.4 - dev: true /path-temp@2.1.0: resolution: {integrity: sha512-cMMJTAZlion/RWRRC48UbrDymEIt+/YSD/l8NqjneyDw2rDOBQcP5yRkMB4CYGn47KMhZvbblBP7Z79OsMw72w==} @@ -16595,7 +16594,7 @@ packages: resolution: {integrity: sha512-urHvzhDxihYrMBpsT/Fk7So79CPfvS0ZwZw2VPA+0JV1Q1XP2IhDg/PLHTt+r2j7787iQAOpILx2GjwAa6CpnA==} dev: true - /qunit@2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy): + /qunit@2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu): resolution: {integrity: sha512-aqUzzUeCqlleWYKlpgfdHHw9C6KxkB9H3wNfiBg5yHqQMzy0xw/pbCRHYFkjl8MsP/t8qkTQE+JTYL71azgiew==} engines: {node: '>=10'} hasBin: true @@ -18534,8 +18533,8 @@ packages: dependencies: is-typedarray: 1.0.0 - /typescript-eslint@7.9.0(eslint@8.57.0)(typescript@5.4.5): - resolution: {integrity: sha512-7iTn9c10teHHCys5Ud/yaJntXZrjt3h2mrx3feJGBOLgQkF3TB1X89Xs3aVQ/GgdXRAXpk2bPTdpRwHP4YkUow==} + /typescript-eslint@7.10.0(eslint@8.57.0)(typescript@5.4.5): + resolution: {integrity: sha512-thO8nyqptXdfWHQrMJJiJyftpW8aLmwRNs11xA8pSrXneoclFPstQZqXvDWuH1WNL4CHffqHvYUeCHTit6yfhQ==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: eslint: ^8.56.0 @@ -18544,9 +18543,9 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/eslint-plugin': 7.9.0(@typescript-eslint/parser@7.9.0)(eslint@8.57.0)(typescript@5.4.5) - '@typescript-eslint/parser': 7.9.0(eslint@8.57.0)(typescript@5.4.5) - '@typescript-eslint/utils': 7.9.0(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/eslint-plugin': 7.10.0(@typescript-eslint/parser@7.10.0)(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/parser': 7.10.0(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/utils': 7.10.0(eslint@8.57.0)(typescript@5.4.5) eslint: 8.57.0 typescript: 5.4.5 transitivePeerDependencies: @@ -19400,8 +19399,8 @@ packages: engines: {node: '>=12.20'} dev: true - /yoctocolors@2.0.0: - resolution: {integrity: sha512-esbDnt0Z1zI1KgvOZU90hJbL6BkoUbrP9yy7ArNZ6TmxBxydMJTYMf9FZjmwwcA8ZgEQzriQ3hwZ0NYXhlFo8Q==} + /yoctocolors@2.0.2: + resolution: {integrity: sha512-Ct97huExsu7cWeEjmrXlofevF8CvzUglJ4iGUet5B8xn1oumtAZBpHU4GzYuoE6PVqcZ5hghtBrSlhwHuR1Jmw==} engines: {node: '>=18'} dev: true @@ -19483,7 +19482,7 @@ packages: '@warp-drive/core-types': file:packages/core-types(@babel/core@7.24.5)(@glint/template@1.4.0) ember-cli-babel: 8.2.0(@babel/core@7.24.5) ember-inflector: 4.0.2(@babel/core@7.24.5) - qunit: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + qunit: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) transitivePeerDependencies: - '@babel/core' - '@glint/template' @@ -19568,7 +19567,7 @@ packages: hasBin: true dependencies: chalk: 5.3.0 - commander: 12.0.0 + commander: 12.1.0 ignore: 5.3.1 jscodeshift: 0.15.2 strip-ansi: 7.1.0 @@ -19732,7 +19731,7 @@ packages: '@hono/node-server': 1.11.1 '@warp-drive/core-types': file:packages/core-types(@babel/core@7.24.5)(@glint/template@1.4.0) chalk: 5.3.0 - hono: 4.3.6 + hono: 4.3.9 dev: true file:packages/json-api(@babel/core@7.24.5)(@ember-data/graph@5.4.0-alpha.71)(@ember-data/request-utils@5.4.0-alpha.71)(@ember-data/store@5.4.0-alpha.71)(@glint/template@1.4.0)(@warp-drive/core-types@0.0.0-alpha.57)(ember-inflector@4.0.2): @@ -20104,7 +20103,7 @@ packages: '@warp-drive/core-types': file:packages/core-types(@babel/core@7.24.5)(@glint/template@1.4.0) '@warp-drive/diagnostic': file:packages/diagnostic(@ember/test-helpers@3.3.0)(ember-cli-test-loader@3.1.0) chalk: 4.1.2 - qunit: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + qunit: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) semver: 7.6.2 testem: 3.11.0(patch_hash=yfkum5c5nfihh3ce3f64tnp5rq)(lodash@4.17.21) transitivePeerDependencies: @@ -20193,7 +20192,7 @@ packages: '@warp-drive/core-types': file:packages/core-types(@babel/core@7.24.5)(@glint/template@1.4.0) '@warp-drive/diagnostic': file:packages/diagnostic(@ember/test-helpers@3.3.0)(ember-cli-test-loader@3.1.0) chalk: 4.1.2 - qunit: 2.19.4(patch_hash=h2fz5inojlzu6daraxt5bghsqy) + qunit: 2.19.4(patch_hash=2jwk2nz4gqke2k5hv6ptj42llu) semver: 7.6.2 testem: 3.11.0(patch_hash=yfkum5c5nfihh3ce3f64tnp5rq)(lodash@4.17.21) transitivePeerDependencies: diff --git a/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts b/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts index 9d6f4171636..9a94c003c99 100644 --- a/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts +++ b/tests/warp-drive__schema-record/tests/-utils/reactive-context.ts @@ -46,6 +46,8 @@ export async function reactiveContext( field.kind === 'field' || field.kind === 'derived' || field.kind === 'array' || + field.kind === 'object' || + field.kind === 'schema-array' || field.kind === '@id' || // @ts-expect-error we secretly allow this field.kind === '@hash' diff --git a/tests/warp-drive__schema-record/tests/reactivity/schema-array-test.ts b/tests/warp-drive__schema-record/tests/reactivity/schema-array-test.ts new file mode 100644 index 00000000000..defdb4a773c --- /dev/null +++ b/tests/warp-drive__schema-record/tests/reactivity/schema-array-test.ts @@ -0,0 +1,178 @@ +import { rerender } from '@ember/test-helpers'; + +import { module, test } from 'qunit'; + +import { setupRenderingTest } from 'ember-qunit'; + +import type Store from '@ember-data/store'; +import type { ObjectValue } from '@warp-drive/core-types/json/raw'; +import { Type } from '@warp-drive/core-types/symbols'; +import { registerDerivations, withDefaults } from '@warp-drive/schema-record/schema'; + +import { reactiveContext } from '../-utils/reactive-context'; + +interface address { + street: string; + city: string; + state: string; + zip: string | number; +} + +interface User { + id: string | null; + $type: 'user'; + name: string; + addresses: Array
| null; + favoriteNumbers: string[]; + age: number; + netWorth: number; + coolometer: number; + rank: number; +} + +module('Reactivity | schema-array fields can receive remote updates', function (hooks) { + setupRenderingTest(hooks); + + test('we can use simple fields with no `type`', async function (assert) { + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerDerivations(schema); + + function hashAddress(data: T, options: ObjectValue | null, prop: string | null): string { + const newData = data as address; + return `${newData.street}|${newData.city}|${newData.state}|${newData.zip}`; + } + hashAddress[Type] = 'address'; + schema.registerHashFn(hashAddress); + schema.registerResource({ + identity: { name: 'fullAddress', kind: '@hash', type: 'address' }, + type: 'address', + fields: [ + { + name: 'street', + kind: 'field', + }, + { + name: 'city', + kind: 'field', + }, + { + name: 'state', + kind: 'field', + }, + { + name: 'zip', + kind: 'field', + }, + ], + }); + + schema.registerResource( + withDefaults({ + type: 'user', + fields: [ + { + name: 'favoriteNumbers', + kind: 'array', + }, + { + name: 'addresses', + type: 'address', + kind: 'schema-array', + options: { key: '@hash' }, + }, + ], + }) + ); + + const fields = schema.resource({ type: 'user' }); + + const record = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + favoriteNumbers: ['1', '2'], + addresses: [ + { street: '123 Main St', city: 'Anytown', state: 'NY', zip: '12345' }, + { street: '456 Elm St', city: 'Anytown', state: 'NY', zip: '12345' }, + ], + }, + }, + }) as User; + + assert.strictEqual(record.id, '1', 'id is accessible'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + assert.deepEqual(record.favoriteNumbers, ['1', '2'], 'favoriteNumbers is accessible'); + assert.strictEqual(record.addresses?.length, 2, 'addresses is accessible'); + assert.strictEqual(record.addresses![0], record.addresses![0], 'addresses are stable by index'); + assert.strictEqual(record.addresses![1], record.addresses![1], 'addresses are stable by index'); + assert.notStrictEqual( + record.addresses![1], + record.addresses![0], + 'embeded SchemaRecord instances are not accidentally reused' + ); + assert.strictEqual(record.addresses![0]!.street, '123 Main St', 'addresses are accessible'); + assert.strictEqual(record.addresses![1]!.street, '456 Elm St', 'addresses are accessible'); + + const addressRecord0 = record.addresses![0]!; + const addressRecord1 = record.addresses![1]!; + + const { counters, fieldOrder } = await reactiveContext.call(this, record, fields); + const favoriteNumbersIndex = fieldOrder.indexOf('favoriteNumbers'); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.$type, 1, '$typeCount is 1'); + assert.strictEqual(counters.favoriteNumbers, 1, 'favoriteNumbersCount is 1'); + assert.strictEqual(counters.addresses, 1, 'addressesCount is 1'); + + assert + .dom(`li:nth-child(${favoriteNumbersIndex + 1})`) + .hasText('favoriteNumbers: 1,2', 'favoriteNumbers is rendered'); + + // remote update + store.push({ + data: { + type: 'user', + id: '1', + attributes: { + favoriteNumbers: ['3', '4'], + addresses: [ + { street: '123 Main St', city: 'Anytown', state: 'NY', zip: '12345' }, + { street: '678 Broadway St', city: 'Tinsletown', state: 'CA', zip: '54321' }, + { street: '911 Emergency St', city: 'Paradise', state: 'CA', zip: '90211' }, + ], + }, + }, + }); + + assert.strictEqual(record.id, '1', 'id is accessible'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + assert.deepEqual(record.favoriteNumbers, ['3', '4'], 'favoriteNumbers is accessible'); + assert.strictEqual(record.addresses?.length, 3, 'addresses is accessible'); + assert.strictEqual(record.addresses![0], record.addresses![0], 'addresses are stable by index'); + assert.strictEqual(record.addresses![1], record.addresses![1], 'addresses are stable by index'); + assert.strictEqual(record.addresses![2], record.addresses![2], 'addresses are stable by index'); + assert.notStrictEqual( + record.addresses![1], + record.addresses![0], + 'embeded SchemaRecord instances are not accidentally reused' + ); + assert.strictEqual(record.addresses![0]!.street, '123 Main St', 'addresses are accessible'); + assert.strictEqual(record.addresses![1]!.street, '678 Broadway St', 'addresses are accessible'); + assert.strictEqual(record.addresses![2]!.street, '911 Emergency St', 'addresses are accessible'); + assert.strictEqual(record.addresses![0], addressRecord0, 'addressRecord0 is stable'); + assert.notStrictEqual(record.addresses![1], addressRecord1, 'addressRecord1 is a new object'); + + await rerender(); + + assert.strictEqual(counters.id, 1, 'idCount is 1'); + assert.strictEqual(counters.$type, 1, '$typeCount is 1'); + assert.strictEqual(counters.favoriteNumbers, 2, 'favoriteNumbersCount is 2'); + assert.strictEqual(counters.addresses, 2, 'addressesCount is 2'); + + assert + .dom(`li:nth-child(${favoriteNumbersIndex + 1})`) + .hasText('favoriteNumbers: 3,4', 'favoriteNumbers is rendered'); + }); +}); diff --git a/tests/warp-drive__schema-record/tests/reads/schema-array-test.ts b/tests/warp-drive__schema-record/tests/reads/schema-array-test.ts new file mode 100644 index 00000000000..34691eade13 --- /dev/null +++ b/tests/warp-drive__schema-record/tests/reads/schema-array-test.ts @@ -0,0 +1,145 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import type Store from '@ember-data/store'; +import { recordIdentifierFor } from '@ember-data/store'; +import type { JsonApiResource } from '@ember-data/store/-types/q/record-data-json-api'; +import type { ResourceType } from '@warp-drive/core-types/symbols'; +import { registerDerivations, withDefaults } from '@warp-drive/schema-record/schema'; + +interface address { + street: string; + city: string; + state: string; + zip: string | number; +} + +interface CreateUserType { + id: string | null; + $type: 'user'; + name: string | null; + addresses: address[] | null; + [ResourceType]: 'user'; +} + +module('Reads | schema-array fields', function (hooks) { + setupTest(hooks); + + test('we can use simple schema-array fields', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerDerivations(schema); + + schema.registerResource({ + identity: null, + type: 'address', + fields: [ + { + name: 'street', + kind: 'field', + }, + { + name: 'city', + kind: 'field', + }, + { + name: 'state', + kind: 'field', + }, + { + name: 'zip', + kind: 'field', + }, + ], + }); + schema.registerResource( + withDefaults({ + type: 'user', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'addresses', + type: 'address', + kind: 'schema-array', + }, + ], + }) + ); + + const sourceArray = [ + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ]; + const record = store.createRecord('user', { + name: 'Rey Skybarker', + addresses: sourceArray, + }); + + assert.strictEqual(record.id, null, 'id is accessible'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + assert.true(Array.isArray(record.addresses), 'we can access favoriteNumber array'); + assert.propContains( + record.addresses?.slice(), + [ + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ], + 'We have the correct array members' + ); + assert.strictEqual(record.addresses, record.addresses, 'We have a stable array reference'); + assert.notStrictEqual(record.addresses, sourceArray); + + // test that the data entered the cache properly + const identifier = recordIdentifierFor(record); + const cachedResourceData = store.cache.peek(identifier); + + assert.notStrictEqual( + cachedResourceData?.attributes?.favoriteNumbers, + sourceArray, + 'with no transform we will still divorce the array reference' + ); + assert.deepEqual( + cachedResourceData?.attributes?.addresses, + [ + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ], + 'the cache values are correct for the array field' + ); + }); +}); diff --git a/tests/warp-drive__schema-record/tests/writes/schema-array-test.ts b/tests/warp-drive__schema-record/tests/writes/schema-array-test.ts new file mode 100644 index 00000000000..2325b0868ce --- /dev/null +++ b/tests/warp-drive__schema-record/tests/writes/schema-array-test.ts @@ -0,0 +1,577 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import type Store from '@ember-data/store'; +import { recordIdentifierFor } from '@ember-data/store'; +import type { JsonApiResource } from '@ember-data/store/-types/q/record-data-json-api'; +import type { ResourceType } from '@warp-drive/core-types/symbols'; +import { registerDerivations, withDefaults } from '@warp-drive/schema-record/schema'; + +interface address { + street: string; + city: string; + state: string; + zip: string | number; +} + +interface CreateUserType { + id: string | null; + $type: 'user'; + name: string | null; + addresses: Array
| null; + [ResourceType]: 'user'; +} + +module('Writes | schema-array fields', function (hooks) { + setupTest(hooks); + + test('we can update to a new array', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerDerivations(schema); + schema.registerResource({ + identity: null, + type: 'address', + fields: [ + { + name: 'street', + kind: 'field', + }, + { + name: 'city', + kind: 'field', + }, + { + name: 'state', + kind: 'field', + }, + { + name: 'zip', + kind: 'field', + }, + ], + }); + schema.registerResource( + withDefaults({ + type: 'user', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'addresses', + type: 'address', + kind: 'schema-array', + }, + ], + }) + ); + + const sourceArray = [ + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ]; + const record = store.createRecord('user', { + name: 'Rey Skybarker', + addresses: sourceArray, + }); + + assert.strictEqual(record.id, null, 'id is accessible'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + assert.true(Array.isArray(record.addresses), 'we can access favoriteNumber array'); + assert.propContains( + record.addresses?.slice(), + [ + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ], + 'We have the correct array members' + ); + record.addresses = [ + { + street: '789 Maple St', + city: 'Thistown', + state: 'TX', + zip: '67890', + }, + { + street: '012 Oak St', + city: 'ThatTown', + state: 'FL', + zip: '09876', + }, + ]; + assert.propContains( + record.addresses?.slice(), + [ + { + street: '789 Maple St', + city: 'Thistown', + state: 'TX', + zip: '67890', + }, + { + street: '012 Oak St', + city: 'ThatTown', + state: 'FL', + zip: '09876', + }, + ], + 'We have the correct array members' + ); + assert.strictEqual(record.addresses, record.addresses, 'We have a stable array reference'); + assert.notStrictEqual(record.addresses, sourceArray); + + // test that the data entered the cache properly + const identifier = recordIdentifierFor(record); + const cachedResourceData = store.cache.peek(identifier); + + assert.notStrictEqual( + cachedResourceData?.attributes?.favoriteNumbers, + sourceArray, + 'with no transform we will still divorce the array reference' + ); + assert.deepEqual( + cachedResourceData?.attributes?.addresses, + [ + { + street: '789 Maple St', + city: 'Thistown', + state: 'TX', + zip: '67890', + }, + { + street: '012 Oak St', + city: 'ThatTown', + state: 'FL', + zip: '09876', + }, + ], + 'the cache values are correct for the array field' + ); + }); + + test('we can update individual objects in the array to new objects', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerDerivations(schema); + schema.registerResource({ + identity: null, + type: 'address', + fields: [ + { + name: 'street', + kind: 'field', + }, + { + name: 'city', + kind: 'field', + }, + { + name: 'state', + kind: 'field', + }, + { + name: 'zip', + kind: 'field', + }, + ], + }); + schema.registerResource( + withDefaults({ + type: 'user', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'addresses', + type: 'address', + kind: 'schema-array', + }, + ], + }) + ); + + const sourceArray = [ + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ]; + const record = store.createRecord('user', { + name: 'Rey Skybarker', + addresses: sourceArray, + }); + + assert.strictEqual(record.id, null, 'id is accessible'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + assert.true(Array.isArray(record.addresses), 'we can access favoriteNumber array'); + assert.propContains( + record.addresses?.slice(), + [ + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ], + 'We have the correct array members' + ); + record.addresses![0] = { + street: '789 Maple St', + city: 'Thistown', + state: 'TX', + zip: '67890', + }; + assert.propContains( + record.addresses?.slice(), + [ + { + street: '789 Maple St', + city: 'Thistown', + state: 'TX', + zip: '67890', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ], + 'We have the correct array members' + ); + assert.strictEqual(record.addresses, record.addresses, 'We have a stable array reference'); + assert.notStrictEqual(record.addresses, sourceArray); + + // test that the data entered the cache properly + const identifier = recordIdentifierFor(record); + const cachedResourceData = store.cache.peek(identifier); + + assert.notStrictEqual( + cachedResourceData?.attributes?.favoriteNumbers, + sourceArray, + 'with no transform we will still divorce the array reference' + ); + assert.deepEqual( + cachedResourceData?.attributes?.addresses, + [ + { + street: '789 Maple St', + city: 'Thistown', + state: 'TX', + zip: '67890', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ], + 'the cache values are correct for the array field' + ); + }); + + test('we can update individual objects in the array to null', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerDerivations(schema); + schema.registerResource({ + identity: null, + type: 'address', + fields: [ + { + name: 'street', + kind: 'field', + }, + { + name: 'city', + kind: 'field', + }, + { + name: 'state', + kind: 'field', + }, + { + name: 'zip', + kind: 'field', + }, + ], + }); + schema.registerResource( + withDefaults({ + type: 'user', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'addresses', + type: 'address', + kind: 'schema-array', + }, + ], + }) + ); + + const sourceArray = [ + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ]; + const record = store.createRecord('user', { + name: 'Rey Skybarker', + addresses: sourceArray, + }); + + assert.strictEqual(record.id, null, 'id is accessible'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + assert.true(Array.isArray(record.addresses), 'we can access favoriteNumber array'); + assert.propContains( + record.addresses?.slice(), + [ + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ], + 'We have the correct array members' + ); + record.addresses![0] = null; + assert.propContains( + record.addresses?.slice(), + [ + null, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ], + 'We have the correct array members' + ); + assert.strictEqual(record.addresses, record.addresses, 'We have a stable array reference'); + assert.notStrictEqual(record.addresses, sourceArray); + + // test that the data entered the cache properly + const identifier = recordIdentifierFor(record); + const cachedResourceData = store.cache.peek(identifier); + + assert.notStrictEqual( + cachedResourceData?.attributes?.favoriteNumbers, + sourceArray, + 'with no transform we will still divorce the array reference' + ); + assert.deepEqual( + cachedResourceData?.attributes?.addresses, + [ + null, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ], + 'the cache values are correct for the array field' + ); + }); + + test('we can update individual fields in objects in the array to new values', function (assert) { + const store = this.owner.lookup('service:store') as Store; + const { schema } = store; + registerDerivations(schema); + schema.registerResource({ + identity: null, + type: 'address', + fields: [ + { + name: 'street', + kind: 'field', + }, + { + name: 'city', + kind: 'field', + }, + { + name: 'state', + kind: 'field', + }, + { + name: 'zip', + kind: 'field', + }, + ], + }); + schema.registerResource( + withDefaults({ + type: 'user', + fields: [ + { + name: 'name', + kind: 'field', + }, + { + name: 'addresses', + type: 'address', + kind: 'schema-array', + }, + ], + }) + ); + + const sourceArray = [ + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ]; + const record = store.createRecord('user', { + name: 'Rey Skybarker', + addresses: sourceArray, + }); + + assert.strictEqual(record.id, null, 'id is accessible'); + assert.strictEqual(record.$type, 'user', '$type is accessible'); + assert.strictEqual(record.name, 'Rey Skybarker', 'name is accessible'); + assert.true(Array.isArray(record.addresses), 'we can access favoriteNumber array'); + assert.propContains( + record.addresses?.slice(), + [ + { + street: '123 Main St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ], + 'We have the correct array members' + ); + record.addresses![0]!.street = '789 Maple St'; + + assert.propContains( + record.addresses?.slice(), + [ + { + street: '789 Maple St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ], + 'We have the correct array members' + ); + assert.strictEqual(record.addresses, record.addresses, 'We have a stable array reference'); + assert.notStrictEqual(record.addresses, sourceArray); + + // test that the data entered the cache properly + const identifier = recordIdentifierFor(record); + const cachedResourceData = store.cache.peek(identifier); + + assert.notStrictEqual( + cachedResourceData?.attributes?.favoriteNumbers, + sourceArray, + 'with no transform we will still divorce the array reference' + ); + assert.deepEqual( + cachedResourceData?.attributes?.addresses, + [ + { + street: '789 Maple St', + city: 'Anytown', + state: 'NY', + zip: '12345', + }, + { + street: '456 Elm St', + city: 'Othertown', + state: 'CA', + zip: '54321', + }, + ], + 'the cache values are correct for the array field' + ); + }); +});