Skip to content

Commit 1c2ed9d

Browse files
authored
feat: ensure objects behave amazeballs (#9768)
* feat: ensure objects behave amazeballs * add tests for record iteration * add more tests
1 parent c961bc8 commit 1c2ed9d

File tree

11 files changed

+2078
-61
lines changed

11 files changed

+2078
-61
lines changed

internal-tooling/src/tasks/sync-scripts.ts

+9
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ export async function main() {
135135
// ensure that the package.json scripts are up to date with defaults
136136
/////////////////////////////////////////////////////////////////////
137137

138+
if (project.isTest) {
139+
if (!('private' in project.pkg)) {
140+
project.pkg.private = true;
141+
project.isPrivate = true;
142+
pkgEdited = true;
143+
log(`\t\t🔧 Added private flag to package.json`);
144+
}
145+
}
146+
138147
if (!project.isPrivate) {
139148
if (!project.pkg.scripts) {
140149
project.pkg.scripts = {};

packages/schema-record/src/-private/fields/managed-object.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export class ManagedObject {
8383
}
8484

8585
if (prop === Symbol.toPrimitive) {
86-
return null;
86+
return () => null;
8787
}
8888
if (prop === Symbol.toStringTag) {
8989
return `ManagedObject<${identifier.type}:${identifier.id} (${identifier.lid})>`;
@@ -98,7 +98,12 @@ export class ManagedObject {
9898
}
9999
if (prop === 'toHTML') {
100100
return function () {
101-
return '<div>ManagedObject</div>';
101+
return '<span>ManagedObject</span>';
102+
};
103+
}
104+
if (prop === 'toJSON') {
105+
return function () {
106+
return structuredClone(self[SOURCE]);
102107
};
103108
}
104109

packages/schema-record/src/-private/record.ts

+113-39
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,24 @@ const symbolList = [
6666
const RecordSymbols = new Set(symbolList);
6767

6868
type RecordSymbol = (typeof symbolList)[number];
69+
type ProxiedMethod = (...args: unknown[]) => unknown;
6970

7071
function isPathMatch(a: string[], b: string[]) {
7172
return a.length === b.length && a.every((v, i) => v === b[i]);
7273
}
7374

75+
function isNonEnumerableProp(prop: string | number | symbol) {
76+
return (
77+
prop === 'constructor' ||
78+
prop === 'prototype' ||
79+
prop === '__proto__' ||
80+
prop === 'toString' ||
81+
prop === 'toJSON' ||
82+
prop === 'toHTML' ||
83+
typeof prop === 'symbol'
84+
);
85+
}
86+
7487
const Editables = new WeakMap<SchemaRecord, SchemaRecord>();
7588
export class SchemaRecord {
7689
declare [RecordStore]: Store;
@@ -105,24 +118,27 @@ export class SchemaRecord {
105118

106119
const schema = store.schema as unknown as SchemaService;
107120
const cache = store.cache;
108-
const identityField = schema.resource(identifier).identity;
121+
const identityField = schema.resource(isEmbedded ? { type: embeddedType as string } : identifier).identity;
122+
const BoundFns = new Map<string | symbol, ProxiedMethod>();
109123

110124
this[EmbeddedType] = embeddedType;
111125
this[EmbeddedPath] = embeddedPath;
112126

113-
let fields: Map<string, FieldSchema>;
114-
if (isEmbedded) {
115-
fields = schema.fields({ type: embeddedType as string });
116-
} else {
117-
fields = schema.fields(identifier);
118-
}
127+
const fields: Map<string, FieldSchema> = isEmbedded
128+
? schema.fields({ type: embeddedType as string })
129+
: schema.fields(identifier);
119130

120131
const signals: Map<string, Signal> = new Map();
121132
this[Signals] = signals;
122133

123134
const proxy = new Proxy(this, {
124135
ownKeys() {
125-
return Array.from(fields.keys());
136+
const identityKey = identityField?.name;
137+
const keys = Array.from(fields.keys());
138+
if (identityKey) {
139+
keys.unshift(identityKey);
140+
}
141+
return keys;
126142
},
127143

128144
has(target: SchemaRecord, prop: string | number | symbol) {
@@ -133,17 +149,30 @@ export class SchemaRecord {
133149
},
134150

135151
getOwnPropertyDescriptor(target, prop) {
136-
if (!fields.has(prop as string)) {
137-
throw new Error(`No field named ${String(prop)} on ${identifier.type}`);
152+
const schemaForField = prop === identityField?.name ? identityField : fields.get(prop as string)!;
153+
assert(`No field named ${String(prop)} on ${identifier.type}`, schemaForField);
154+
155+
if (isNonEnumerableProp(prop)) {
156+
return {
157+
writable: false,
158+
enumerable: false,
159+
configurable: true,
160+
};
138161
}
139-
const schemaForField = fields.get(prop as string)!;
162+
140163
switch (schemaForField.kind) {
141164
case 'derived':
142165
return {
143166
writable: false,
144167
enumerable: true,
145168
configurable: true,
146169
};
170+
case '@id':
171+
return {
172+
writable: identifier.id === null,
173+
enumerable: true,
174+
configurable: true,
175+
};
147176
case '@local':
148177
case 'field':
149178
case 'attribute':
@@ -161,6 +190,12 @@ export class SchemaRecord {
161190
enumerable: true,
162191
configurable: true,
163192
};
193+
default:
194+
return {
195+
writable: false,
196+
enumerable: false,
197+
configurable: false,
198+
};
164199
}
165200
},
166201

@@ -169,26 +204,6 @@ export class SchemaRecord {
169204
return target[prop as keyof SchemaRecord];
170205
}
171206

172-
if (prop === Symbol.toStringTag) {
173-
return `SchemaRecord<${identifier.type}:${identifier.id} (${identifier.lid})>`;
174-
}
175-
176-
if (prop === 'toString') {
177-
return function () {
178-
return `SchemaRecord<${identifier.type}:${identifier.id} (${identifier.lid})>`;
179-
};
180-
}
181-
182-
if (prop === 'toHTML') {
183-
return function () {
184-
return `<div>SchemaRecord<${identifier.type}:${identifier.id} (${identifier.lid})></div>`;
185-
};
186-
}
187-
188-
if (prop === Symbol.toPrimitive) {
189-
return null;
190-
}
191-
192207
// TODO make this a symbol
193208
if (prop === '___notifications') {
194209
return target.___notifications;
@@ -203,19 +218,78 @@ export class SchemaRecord {
203218
if (IgnoredGlobalFields.has(prop as string)) {
204219
return undefined;
205220
}
221+
222+
/////////////////////////////////////////////////////////////
223+
//// Note these bound function behaviors are essentially ////
224+
//// built-in but overrideable derivations. ////
225+
//// ////
226+
//// The bar for this has to be "basic expectations of ////
227+
/// an object" – very, very high ////
228+
/////////////////////////////////////////////////////////////
229+
230+
if (prop === Symbol.toStringTag || prop === 'toString') {
231+
let fn = BoundFns.get('toString');
232+
if (!fn) {
233+
fn = function () {
234+
entangleSignal(signals, receiver, '@identity');
235+
return `Record<${identifier.type}:${identifier.id} (${identifier.lid})>`;
236+
};
237+
BoundFns.set(prop, fn);
238+
}
239+
return fn;
240+
}
241+
242+
if (prop === 'toHTML') {
243+
let fn = BoundFns.get('toHTML');
244+
if (!fn) {
245+
fn = function () {
246+
entangleSignal(signals, receiver, '@identity');
247+
return `<span>Record<${identifier.type}:${identifier.id} (${identifier.lid})></span>`;
248+
};
249+
BoundFns.set(prop, fn);
250+
}
251+
return fn;
252+
}
253+
254+
if (prop === 'toJSON') {
255+
let fn = BoundFns.get('toJSON');
256+
if (!fn) {
257+
fn = function () {
258+
const json: Record<string, unknown> = {};
259+
for (const key in receiver) {
260+
json[key] = receiver[key as keyof typeof receiver];
261+
}
262+
263+
return json;
264+
};
265+
BoundFns.set(prop, fn);
266+
}
267+
return fn;
268+
}
269+
270+
if (prop === Symbol.toPrimitive) return () => null;
271+
272+
if (prop === Symbol.iterator) {
273+
let fn = BoundFns.get(Symbol.iterator);
274+
if (!fn) {
275+
fn = function* () {
276+
for (const key in receiver) {
277+
yield [key, receiver[key as keyof typeof receiver]];
278+
}
279+
};
280+
BoundFns.set(Symbol.iterator, fn);
281+
}
282+
return fn;
283+
}
284+
206285
if (prop === 'constructor') {
207286
return SchemaRecord;
208287
}
209288
// too many things check for random symbols
210-
if (typeof prop === 'symbol') {
211-
return undefined;
212-
}
213-
let type = identifier.type;
214-
if (isEmbedded) {
215-
type = embeddedType!;
216-
}
289+
if (typeof prop === 'symbol') return undefined;
217290

218-
throw new Error(`No field named ${String(prop)} on ${type}`);
291+
assert(`No field named ${String(prop)} on ${isEmbedded ? embeddedType! : identifier.type}`);
292+
return undefined;
219293
}
220294

221295
const field = maybeField.kind === 'alias' ? maybeField.options : maybeField;

packages/schema-record/src/-private/schema.ts

+26-16
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
type FieldSchema,
1818
type GenericField,
1919
type HashField,
20+
type IdentityField,
2021
isResourceSchema,
2122
type LegacyAttributeField,
2223
type LegacyRelationshipSchema,
@@ -32,19 +33,18 @@ import { Identifier } from './symbols';
3233

3334
const Support = getOrSetGlobal('Support', new WeakMap<WeakKey, Record<string, unknown>>());
3435

35-
export const SchemaRecordFields: FieldSchema[] = [
36-
{
37-
type: '@constructor',
38-
name: 'constructor',
39-
kind: 'derived',
40-
},
41-
{
42-
type: '@identity',
43-
name: '$type',
44-
kind: 'derived',
45-
options: { key: 'type' },
46-
},
47-
];
36+
const ConstructorField = {
37+
type: '@constructor',
38+
name: 'constructor',
39+
kind: 'derived',
40+
} satisfies DerivedField;
41+
const TypeField = {
42+
type: '@identity',
43+
name: '$type',
44+
kind: 'derived',
45+
options: { key: 'type' },
46+
} satisfies DerivedField;
47+
const DefaultIdentityField = { name: 'id', kind: '@id' } satisfies IdentityField;
4848

4949
function _constructor(record: SchemaRecord) {
5050
let state = Support.get(record as WeakKey);
@@ -56,15 +56,25 @@ function _constructor(record: SchemaRecord) {
5656
return (state._constructor = state._constructor || {
5757
name: `SchemaRecord<${recordIdentifierFor(record).type}>`,
5858
get modelName() {
59-
throw new Error('Cannot access record.constructor.modelName on non-Legacy Schema Records.');
59+
assert(`record.constructor.modelName is not available outside of legacy mode`, false);
60+
return undefined;
6061
},
6162
});
6263
}
6364
_constructor[Type] = '@constructor';
6465

66+
/**
67+
* Utility for constructing a ResourceSchema with the recommended fields
68+
* for the Polaris experience.
69+
*/
6570
export function withDefaults(schema: WithPartial<ResourceSchema, 'identity'>): ResourceSchema {
66-
schema.identity = schema.identity || { name: 'id', kind: '@id' };
67-
schema.fields.push(...SchemaRecordFields);
71+
schema.identity = schema.identity || DefaultIdentityField;
72+
73+
// because fields gets iterated in definition order,
74+
// we add TypeField to the beginning so that it will
75+
// appear right next to the identity field
76+
schema.fields.unshift(TypeField);
77+
schema.fields.push(ConstructorField);
6878
return schema as ResourceSchema;
6979
}
7080

tests/warp-drive__schema-record/package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
"build:tests": "IS_TESTING=true EMBER_CLI_TEST_COMMAND=true ember build --output-path=dist-test --suppress-sizes",
1919
"lint": "eslint . --quiet --cache --cache-strategy=content",
2020
"check:types": "glint",
21-
"start": "ember test --test-port=0 --serve --no-launch",
22-
"test": "ember test --test-port=0 --path=dist-test"
21+
"start": "bun run build:tests --watch",
22+
"test": "ember test --test-port=0 --path=dist-test",
23+
"test:start": "bun run test --serve --no-launch"
2324
},
2425
"devDependencies": {
2526
"@babel/core": "^7.26.9",

tests/warp-drive__schema-record/tests/legacy/legacy-mode-activation-test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,8 @@ module('Legacy Mode', function (hooks) {
135135
} catch (e) {
136136
assert.strictEqual(
137137
(e as Error).message,
138-
'Cannot access record.constructor.modelName on non-Legacy Schema Records.',
139-
'record.constructor.modelName throws'
138+
'record.constructor.modelName is not available outside of legacy mode',
139+
`record.constructor.modelName throws: ${(e as Error).message}`
140140
);
141141
}
142142
assert.strictEqual(record.constructor.name, 'SchemaRecord<user>', 'it has a useful constructor name');

0 commit comments

Comments
 (0)