Skip to content

Commit d060d41

Browse files
authored
fix(runner): ensure IDs are added to array elements and default values (commontoolsinc#1908)
* fix(runner): ensure IDs are added to array elements and default values This change ensures that when writing arrays to cells or pushing to array cells, all objects are given IDs so they can be stored as separate documents with links. This applies to both explicitly written arrays and default values from schemas. Changes to cell.ts: 1. Modified the `set` method to call `addIDIfNeeded` on each element when writing an array, ensuring all objects get IDs before being processed by `diffAndUpdate`. 2. Enhanced the `push` method to: - Process default values from schemas using `processDefaultValue` to ensure they're properly materialized with IDs - Apply `addIDIfNeeded` to all elements (both existing defaults and newly pushed values) to ensure consistent ID assignment 3. Improved the `update` method's schema validation to: - Use `resolveSchema` to properly handle schema references - Check for undefined schemas (which allow objects) - Consolidate the schema validation logic to determine if objects are allowed 4. Added new `addIDIfNeeded` helper function that: - Checks if a value is an object without an ID - Generates a new ID from the frame's counter if needed - Preserves existing IDs when present New tests in cell.test.ts: - "set operations with arrays" suite: - Tests that objects written as arrays each get their own document ID - Verifies that existing IDs are preserved during array writes - Uses `asSchema` with `asCell: true` to read back as cells and verify each element has a distinct document ID - "push operations with default values" suite: - Tests that default values from schemas are properly materialized with IDs - Verifies all objects (defaults + pushed) get unique IDs - Tests push operations both with and without schema defaults These changes ensure that array operations consistently create separate documents for each object, maintaining proper referential structure in the storage layer. * make adding IDs recursive, since the calendar use-case sets objects with arrays * make toCell non-enumerable so that {...obj} doesn't copy them and accidentally points at the old object * fix test that was previously broken, but with the bug hidden by our bug * feat(runner): implement Symbol.iterator for array query result proxies Add custom Symbol.iterator implementation for array query result proxies to support spreading and for...of iteration. When iterating over an array proxy, each element is now wrapped in its own query result proxy, maintaining reactivity and cell references throughout the iteration. This enables patterns like: - const spread = [...proxy.items] - for (const item of proxy.items) { ... } Each iterated element maintains its query result proxy wrapper, allowing access to toCell() and preserving the link to the underlying cell data. * test(runner): add tests for Symbol.iterator in array query result proxies Add comprehensive tests verifying Symbol.iterator behavior for array query result proxies: - for...of iteration with object elements - Spread operator with object elements - Nested array spreading with proper proxy preservation - Arrays containing cell references (links to other cells) Tests confirm that iteration and spreading maintain query result proxy wrappers for each element, allowing access to toCell() and preserving reactivity throughout iteration. The cell references test specifically validates that link resolution works correctly when iterating over arrays of cell links. * log .length read in tx for Symbol.iterator * outliner: clone tree before applying edit otherwise Cell.set turns this back into cell references and this can create loops (fwiw, we might want to turn this into a structure where each node is its own document with its own id, in which case we'd actually want this behavior)
1 parent c396155 commit d060d41

File tree

8 files changed

+592
-56
lines changed

8 files changed

+592
-56
lines changed

packages/generated-patterns/integration/patterns/compliance-checklist.pattern.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -576,12 +576,8 @@ export const complianceChecklist = recipe<ComplianceChecklistArgs>(
576576
const insights = lift(computeInsights)(currentTasks);
577577

578578
const tasksView = lift(cloneTasks)(currentTasks);
579-
const categorySummaries = lift((snapshot: ComplianceInsights) =>
580-
snapshot.categories.map((entry) => ({ ...entry }))
581-
)(insights);
582-
const gapDetails = lift((snapshot: ComplianceInsights) =>
583-
snapshot.gapList.map((entry) => ({ ...entry }))
584-
)(insights);
579+
const categorySummaries = insights.categories;
580+
const gapDetails = insights.gapList;
585581

586582
const coveragePercent = lift((snapshot: ComplianceInsights) =>
587583
snapshot.coveragePercent

packages/runner/src/builder/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export { AuthSchema } from "./schema-lib.ts";
4949
export {
5050
ID,
5151
ID_FIELD,
52+
type IDFields,
5253
NAME,
5354
type Schema,
5455
schema,

packages/runner/src/cell.ts

Lines changed: 111 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type Cell,
66
ID,
77
ID_FIELD,
8+
type IDFields,
89
isStreamValue,
910
type JSONSchema,
1011
type OpaqueRef,
@@ -24,7 +25,11 @@ import { diffAndUpdate } from "./data-updating.ts";
2425
import { resolveLink } from "./link-resolution.ts";
2526
import { ignoreReadForScheduling, txToReactivityLog } from "./scheduler.ts";
2627
import { type Cancel, isCancel, useCancelGroup } from "./cancel.ts";
27-
import { validateAndTransform } from "./schema.ts";
28+
import {
29+
processDefaultValue,
30+
resolveSchema,
31+
validateAndTransform,
32+
} from "./schema.ts";
2833
import { toURI } from "./uri-utils.ts";
2934
import {
3035
type LegacyJSONCellLink,
@@ -448,12 +453,15 @@ export class RegularCell<T> implements Cell<T> {
448453
// retry on conflict.
449454
if (!this.synced) this.sync();
450455

456+
// Looks for arrays and makes sure each object gets its own doc.
457+
const transformedValue = recursivelyAddIDIfNeeded(newValue);
458+
451459
// TODO(@ubik2) investigate whether i need to check classified as i walk down my own obj
452460
diffAndUpdate(
453461
this.runtime,
454462
this.tx,
455463
resolveLink(this.tx, this.link, "writeRedirect"),
456-
newValue,
464+
transformedValue,
457465
getTopFrame()?.cause,
458466
);
459467

@@ -486,36 +494,36 @@ export class RegularCell<T> implements Cell<T> {
486494
const resolvedLink = resolveLink(this.tx, this.link);
487495
const currentValue = this.tx.readValueOrThrow(resolvedLink);
488496

489-
// If there's no current value, initialize based on schema
497+
// If there's no current value, initialize based on schema, even if there is
498+
// no default value.
490499
if (currentValue === undefined) {
491-
if (isObject(this.schema)) {
492-
// Check if schema allows objects
493-
const allowsObject = ContextualFlowControl.isTrueSchema(this.schema) ||
494-
this.schema.type === "object" ||
495-
(Array.isArray(this.schema.type) &&
496-
this.schema.type.includes("object")) ||
497-
(this.schema.anyOf &&
498-
this.schema.anyOf.some((s) =>
499-
typeof s === "object" && s.type === "object"
500-
));
501-
502-
if (!allowsObject) {
503-
throw new Error(
504-
"Cannot update with object value - schema does not allow objects",
505-
);
506-
}
507-
} else if (this.schema === false) {
500+
const resolvedSchema = resolveSchema(this.schema, this.rootSchema);
501+
502+
// TODO(seefeld,ubik2): This should all be moved to schema helpers. This
503+
// just wants to know whether the value could be an object.
504+
const allowsObject = resolvedSchema === undefined ||
505+
ContextualFlowControl.isTrueSchema(resolvedSchema) ||
506+
(isObject(resolvedSchema) &&
507+
(resolvedSchema.type === "object" ||
508+
(Array.isArray(resolvedSchema.type) &&
509+
resolvedSchema.type.includes("object")) ||
510+
(resolvedSchema.anyOf &&
511+
resolvedSchema.anyOf.some((s) =>
512+
typeof s === "object" && s.type === "object"
513+
))));
514+
515+
if (!allowsObject) {
508516
throw new Error(
509517
"Cannot update with object value - schema does not allow objects",
510518
);
511519
}
520+
512521
this.tx.writeValueOrThrow(resolvedLink, {});
513522
}
514523

515524
// Now update each property
516525
for (const [key, value] of Object.entries(values)) {
517-
// Workaround for type checking, since T can be Cell<> and that's fine.
518-
(this.key as any)(key).set(value);
526+
(this as Cell<any>).key(key).set(value);
519527
}
520528
}
521529

@@ -546,14 +554,6 @@ export class RegularCell<T> implements Cell<T> {
546554
throw new Error("Can't push into non-array value");
547555
}
548556

549-
// If this is an object and it doesn't have an ID, add one.
550-
const valuesToWrite = value.map((val: any) =>
551-
(!isLink(val) && isObject(val) &&
552-
(val as { [ID]?: unknown })[ID] === undefined && getTopFrame())
553-
? { [ID]: getTopFrame()!.generatedIdCounter++, ...val }
554-
: val
555-
);
556-
557557
// If there is no array yet, create it first. We have to do this as a
558558
// separate operation, so that in the next steps [ID] is properly anchored
559559
// in the array.
@@ -565,8 +565,14 @@ export class RegularCell<T> implements Cell<T> {
565565
[],
566566
cause,
567567
);
568-
array = isObject(this.schema) && Array.isArray(this.schema?.default)
569-
? this.schema.default
568+
const resolvedSchema = resolveSchema(this.schema, this.rootSchema);
569+
array = isObject(resolvedSchema) && Array.isArray(resolvedSchema?.default)
570+
? processDefaultValue(
571+
this.runtime,
572+
this.tx,
573+
this.link,
574+
resolvedSchema.default,
575+
)
570576
: [];
571577
}
572578

@@ -575,7 +581,7 @@ export class RegularCell<T> implements Cell<T> {
575581
this.runtime,
576582
this.tx,
577583
resolvedLink,
578-
[...array, ...valuesToWrite],
584+
recursivelyAddIDIfNeeded([...array, ...value]),
579585
cause,
580586
);
581587
}
@@ -877,6 +883,78 @@ function subscribeToReferencedDocs<T>(
877883
};
878884
}
879885

886+
/**
887+
* Recursively adds IDs elements in arrays, unless they are already a link.
888+
*
889+
* This ensures that mutable arrays only consist of links to documents, at least
890+
* when written to only via .set, .update and .push above.
891+
*
892+
* TODO(seefeld): When an array has default entries and is rewritten as [...old,
893+
* new], this will still break, because the previous entries will point back to
894+
* the array itself instead of being new entries.
895+
*
896+
* @param value - The value to add IDs to.
897+
* @returns The value with IDs added.
898+
*/
899+
function recursivelyAddIDIfNeeded<T>(
900+
value: T,
901+
seen: Map<unknown, unknown> = new Map(),
902+
): T {
903+
// Can't add IDs without top frame.
904+
if (!getTopFrame()) return value;
905+
906+
// Not a record, no need to add IDs. Already a link, no need to add IDs.
907+
if (!isRecord(value) || isLink(value)) return value;
908+
909+
// Already seen, return previously annotated result.
910+
if (seen.has(value)) return seen.get(value) as T;
911+
912+
if (Array.isArray(value)) {
913+
const result: unknown[] = [];
914+
915+
// Set before traversing, otherwise we'll infinite recurse.
916+
seen.set(value, result);
917+
918+
result.push(...value.map((v) => {
919+
const value = recursivelyAddIDIfNeeded(v, seen);
920+
// For objects on arrays only: Add ID if not already present.
921+
if (
922+
isObject(value) && !isLink(value) && !(ID in value)
923+
) {
924+
return { [ID]: getTopFrame()!.generatedIdCounter++, ...value };
925+
} else {
926+
return value;
927+
}
928+
}));
929+
return result as T;
930+
} else {
931+
const result: Record<string, unknown> = {};
932+
933+
// Set before traversing, otherwise we'll infinite recurse.
934+
seen.set(value, result);
935+
936+
Object.entries(value).forEach(([key, v]) => {
937+
result[key] = recursivelyAddIDIfNeeded(v, seen);
938+
});
939+
940+
// Copy supported symbols from original value.
941+
[ID, ID_FIELD].forEach((symbol) => {
942+
if (symbol in value) {
943+
(result as IDFields)[symbol as keyof IDFields] =
944+
value[symbol as keyof IDFields];
945+
}
946+
});
947+
948+
return result as T;
949+
}
950+
}
951+
952+
/**
953+
* Converts cells and objects that can be turned to cells to links.
954+
*
955+
* @param value - The value to convert.
956+
* @returns The converted value.
957+
*/
880958
export function convertCellsToLinks(
881959
value: readonly any[] | Record<string, any> | any,
882960
path: string[] = [],

packages/runner/src/query-result-proxy.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,33 @@ export function createQueryResultProxy<T>(
123123
return () => createCell(runtime, link, tx, true);
124124
} else if (prop === toOpaqueRef) {
125125
return () => makeOpaqueRef(link);
126+
} else if (prop === Symbol.iterator && Array.isArray(target)) {
127+
return function () {
128+
let index = 0;
129+
return {
130+
next() {
131+
const readTx = (tx?.status().status === "ready")
132+
? tx
133+
: runtime.edit();
134+
const length = readTx.readValueOrThrow({
135+
...link,
136+
path: [...link.path, "length"],
137+
}) as number;
138+
if (index < length) {
139+
const result = {
140+
value: createQueryResultProxy(runtime, tx, {
141+
...link,
142+
path: [...link.path, String(index)],
143+
}, depth + 1),
144+
done: false,
145+
};
146+
index++;
147+
return result;
148+
}
149+
return { done: true };
150+
},
151+
};
152+
};
126153
}
127154

128155
const readTx = (tx?.status().status === "ready") ? tx : runtime.edit();

packages/runner/src/schema.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export function resolveSchema(
102102
*
103103
* For `required` objects and arrays assume {} and [] as default value.
104104
*/
105-
function processDefaultValue(
105+
export function processDefaultValue(
106106
runtime: IRuntime,
107107
tx: IExtendedStorageTransaction | undefined,
108108
link: NormalizedFullLink,
@@ -310,8 +310,15 @@ function annotateWithBackToCellSymbols(
310310
isRecord(value) && !isCell(value) && !isStream(value) &&
311311
!isQueryResultForDereferencing(value)
312312
) {
313-
value[toCell] = () => createCell(runtime, link, tx);
314-
value[toOpaqueRef] = () => makeOpaqueRef(link);
313+
// Non-enumerable, so that {...obj} won't copy these symbols
314+
Object.defineProperty(value, toCell, {
315+
value: () => createCell(runtime, link, tx),
316+
enumerable: false,
317+
});
318+
Object.defineProperty(value, toOpaqueRef, {
319+
value: () => makeOpaqueRef(link),
320+
enumerable: false,
321+
});
315322
Object.freeze(value);
316323
}
317324
return value;

0 commit comments

Comments
 (0)