diff --git a/packages/background-charm-service/src/utils.ts b/packages/background-charm-service/src/utils.ts index e20895855..53ebfa1ef 100644 --- a/packages/background-charm-service/src/utils.ts +++ b/packages/background-charm-service/src/utils.ts @@ -123,7 +123,7 @@ export async function setBGCharm({ console.log( "charmsCell", - JSON.stringify(charmsCell.getAsCellLink(), null, 2), + JSON.stringify(charmsCell.getAsLink(), null, 2), ); const charms = charmsCell.get() || []; diff --git a/packages/charm/src/iframe/recipe.ts b/packages/charm/src/iframe/recipe.ts index 421b9008f..99902a57e 100644 --- a/packages/charm/src/iframe/recipe.ts +++ b/packages/charm/src/iframe/recipe.ts @@ -1,5 +1,9 @@ -import { JSONSchema } from "@commontools/runner"; -import { Cell, getEntityId, type Runtime } from "@commontools/runner"; +import { + Cell, + getEntityId, + type JSONSchema, + type Runtime, +} from "@commontools/runner"; import { Charm, getRecipeIdFromCharm } from "../manager.ts"; export type IFrameRecipe = { diff --git a/packages/charm/src/iterate.ts b/packages/charm/src/iterate.ts index bf466f50c..4e764e108 100644 --- a/packages/charm/src/iterate.ts +++ b/packages/charm/src/iterate.ts @@ -264,19 +264,21 @@ export function scrub(data: any): any { } /** - * Turn cells references into aliases, this forces writes to go back - * to the original cell. + * Turn cells references into writes redirects, this forces writes to go back to + * the original cell. + * @param data The data to process + * @param baseSpace Optional base space DID to make links relative to */ -function turnCellsIntoAliases(data: any): any { +function turnCellsIntoWriteRedirects(data: any, baseSpace?: MemorySpace): any { if (isCell(data)) { - return { $alias: data.getAsCellLink() }; + return data.getAsWriteRedirectLink(baseSpace ? { baseSpace } : undefined); } else if (Array.isArray(data)) { - return data.map((value) => turnCellsIntoAliases(value)); + return data.map((value) => turnCellsIntoWriteRedirects(value, baseSpace)); } else if (isObject(data)) { return Object.fromEntries( Object.entries(data).map(( [key, value], - ) => [key, turnCellsIntoAliases(value)]), + ) => [key, turnCellsIntoWriteRedirects(value, baseSpace)]), ); } else return data; } @@ -478,7 +480,11 @@ export async function castNewRecipe( const scrubbed = scrub(form.input.references); // First, extract any existing schema if we have data - const existingSchema = createJsonSchema(scrubbed); + const existingSchema = createJsonSchema( + scrubbed, + false, + charmManager.runtime, + ); // Prototype workflow: combine steps const { newSpec, newRecipeSrc, llmRequestId } = @@ -486,7 +492,7 @@ export async function castNewRecipe( ? await singlePhaseCodeGeneration(form, existingSchema) : await twoPhaseCodeGeneration(form, existingSchema); - const input = turnCellsIntoAliases(scrubbed); + const input = turnCellsIntoWriteRedirects(scrubbed, charmManager.getSpace()); globalThis.dispatchEvent( new CustomEvent("job-update", { diff --git a/packages/charm/src/manager.ts b/packages/charm/src/manager.ts index f34d68329..f86ba00d9 100644 --- a/packages/charm/src/manager.ts +++ b/packages/charm/src/manager.ts @@ -1,29 +1,24 @@ import { + type Cell, Classification, - isAlias, + EntityId, + getEntityId, + isCell, + isLink, JSONSchema, + type MemorySpace, Module, NAME, + parseLink, Recipe, + Runtime, Schema, TYPE, UI, -} from "@commontools/runner"; -import { - type Cell, - createRef, - EntityId, - followAliases, - getEntityId, - isCell, - isCellLink, - isDoc, - maybeGetCellLink, - type MemorySpace, - Runtime, + URI, } from "@commontools/runner"; import { type Session } from "@commontools/identity"; -import { isObject } from "@commontools/utils/types"; +import { isObject, isRecord } from "@commontools/utils/types"; /** * Extracts the ID from a charm. @@ -435,7 +430,7 @@ export class CharmManager { let argumentLink: any; try { - argumentLink = argumentCell.getAsCellLink(); + argumentLink = argumentCell.getAsLegacyCellLink(); if (!argumentLink || !argumentLink.cell) return result; argumentValue = argumentLink.cell.getAtPath(argumentLink.path); @@ -508,11 +503,8 @@ export class CharmManager { // If we've reached the end and have a resultRef, return it if (value.resultRef) { - // Use maybeGetCellLink for safer access to resultRef - const resultLink = maybeGetCellLink(value.resultRef); - if (resultLink) { - return getEntityId(resultLink.cell); - } + const { id: source } = parseLink(value.resultRef, cell)!; + if (source) return getEntityId(source); } } } catch (err) { @@ -534,7 +526,7 @@ export class CharmManager { visited = new Set(), // Track objects directly, not string representations depth = 0, ) => { - if (!value || typeof value !== "object" || depth > maxDepth) return; + if (!isRecord(value) || depth > maxDepth) return; // Prevent cycles in our traversal by tracking object references directly if (visited.has(value)) return; @@ -542,164 +534,21 @@ export class CharmManager { try { // Handle values that are themselves cells, docs, or cell links - if (isCell(value)) { - try { - const cellLink = value.getAsCellLink(); - if (cellLink && cellLink.cell) { - const cellId = getEntityId(cellLink.cell); - if (cellId) addMatchingCharm(cellId); - - const sourceRefId = followSourceToResultRef( - this.runtime.getCellFromLink(cellLink), - new Set(), - 0, - ); - if (sourceRefId) addMatchingCharm(sourceRefId); - } - } catch (err) { - console.debug("Error handling cell:", err); + if (isLink(value)) { + const link = parseLink(value, parent); + if (link.id) { + addMatchingCharm(getEntityId(link.id)!); } - return; // Don't process contents of cells - } - if (isDoc(value)) { - try { - const docId = getEntityId(value); - if (docId) addMatchingCharm(docId); - - const sourceRefId = followSourceToResultRef( - value.asCell(), - new Set(), - 0, - ); - if (sourceRefId) addMatchingCharm(sourceRefId); - } catch (err) { - console.debug("Error handling doc:", err); - } - return; // Don't process contents of docs - } - - if (isCellLink(value)) { - try { - const cellId = getEntityId(value.cell); - if (cellId) addMatchingCharm(cellId); - - const sourceRefId = followSourceToResultRef( - this.runtime.getCellFromLink(value), - new Set(), - 0, - ); - if (sourceRefId) addMatchingCharm(sourceRefId); - } catch (err) { - console.debug("Error handling cell link:", err); - } - return; // Don't process contents of cell links - } - - // Process aliases - follow them to their sources - if (isAlias(value)) { - try { - // Use followAliases, which is safer than manual traversal - const cellLink = followAliases(value, parent.getDoc()); - if (cellLink && cellLink.cell) { - const cellId = getEntityId(cellLink.cell); - if (cellId) addMatchingCharm(cellId); - - const sourceRefId = followSourceToResultRef( - this.runtime.getCellFromLink(cellLink), - new Set(), - 0, - ); - if (sourceRefId) addMatchingCharm(sourceRefId); - } - } catch (err) { - console.debug("Error following aliases:", err); - } - return; // Aliases have been fully handled - } - - // Try to get a cell link from various types of values - const cellLink = maybeGetCellLink(value, parent.getDoc()); - if (cellLink) { - try { - const cellId = getEntityId(cellLink.cell); - if (cellId) addMatchingCharm(cellId); - - const sourceRefId = followSourceToResultRef( - this.runtime.getCellFromLink(cellLink), - new Set(), - 0, - ); - if (sourceRefId) addMatchingCharm(sourceRefId); - } catch (err) { - console.debug("Error handling cell link from value:", err); - } - return; // Direct cell references fully handled - } - - // Direct $alias handling (for cases not caught by isAlias) - if (value.$alias && value.$alias.cell) { - try { - const aliasId = getEntityId(value.$alias.cell); - if (aliasId) addMatchingCharm(aliasId); - - const sourceRefId = followSourceToResultRef( - value.$alias.cell.asCell(), - new Set(), - 0, - ); - if (sourceRefId) addMatchingCharm(sourceRefId); - } catch (err) { - console.debug("Error handling alias reference:", err); - } - } - - // Direct cell reference handling (for cases not caught by maybeGetCellLink) - if (value.cell && value.path !== undefined) { - try { - const cellId = getEntityId(value.cell); - if (cellId) addMatchingCharm(cellId); - - const sourceRefId = followSourceToResultRef( - value.cell.asCell(), - new Set(), - 0, - ); - if (sourceRefId) addMatchingCharm(sourceRefId); - } catch (err) { - console.debug("Error handling direct cell reference:", err); - } - } - - // Safe recursive processing of arrays - if (Array.isArray(value)) { + const sourceRefId = followSourceToResultRef( + this.runtime.getCellFromLink(link), + new Set(), + 0, + ); + if (sourceRefId) addMatchingCharm(sourceRefId); + } else if (Array.isArray(value)) { + // Safe recursive processing of arrays for (let i = 0; i < value.length; i++) { - // Skip null/undefined items - if (value[i] == null) continue; - - // Skip items that might be cells to avoid Copy trap - if ( - typeof value[i] === "object" && - (isCell(value[i]) || isDoc(value[i]) || isCellLink(value[i])) - ) { - try { - // Process each cell directly - processValue( - value[i], - parent, - new Set([...visited]), - depth + 1, - ); - } catch (err) { - console.debug( - `Error processing special array item at index ${i}:`, - err, - ); - } - continue; - } - - // Process regular items try { processValue( value[i], @@ -720,14 +569,6 @@ export class CharmManager { for (let i = 0; i < keys.length; i++) { const key = keys[i]; - // Skip properties that might be or contain Cell objects - if ( - key === "sourceCell" || key === "cell" || key === "value" || - key === "getAsCellLink" || key === "getSourceCell" - ) { - continue; - } - try { processValue( value[key], @@ -811,19 +652,14 @@ export class CharmManager { cell: Cell, visited = new Set(), depth = 0, - ): EntityId | undefined => { + ): URI | undefined => { if (depth > maxDepth) return undefined; // Prevent infinite recursion - const docId = getEntityId(cell); - if (!docId || !docId["/"]) return undefined; - - const docIdStr = typeof docId["/"] === "string" - ? docId["/"] - : JSON.stringify(docId["/"]); + const cellURI = cell.sourceURI; // Prevent cycles - if (visited.has(docIdStr)) return undefined; - visited.add(docIdStr); + if (visited.has(cellURI)) return undefined; + visited.add(cellURI); // If document has a sourceCell, follow it const value = cell.getRaw(); @@ -837,10 +673,10 @@ export class CharmManager { // If we've reached the end and have a resultRef, return it if (value && typeof value === "object" && value.resultRef) { - return getEntityId(value.resultRef); + return parseLink(value.resultRef, cell)?.id; } - return docId; // Return the current document's ID if no further references + return cellURI; // Return the current document's ID if no further references }; // Helper to check if a document refers to our target charm @@ -850,80 +686,27 @@ export class CharmManager { visited = new Set(), // Track objects directly, not string representations depth = 0, ): boolean => { - if (!value || typeof value !== "object" || depth > maxDepth) return false; + if (!isRecord(value) || depth > maxDepth) return false; // Prevent cycles in our traversal by tracking object references directly if (visited.has(value)) return false; visited.add(value); try { - // Handle cells, docs, and cell links directly - if (isCell(value)) { + if (isLink(value)) { try { - const cellLink = value.getAsCellLink(); - if (cellLink && cellLink.cell) { - // Check if this cell's doc is our target - const cellId = getEntityId(cellLink.cell); - if (cellId && cellId["/"] === charmId["/"]) { - return true; - } + const link = parseLink(value, parent); - // Check if this cell's source chain leads to our target - const sourceRefId = followSourceToResultRef( - this.runtime.getCellFromLink(cellLink), - new Set(), - 0, - ); - if (sourceRefId && sourceRefId["/"] === charmId["/"]) { - return true; - } - } - } catch (err) { - console.debug("Error handling cell in checkRefersToTarget:", err); - } - return false; // Don't process cell contents - } - - if (isDoc(value)) { - try { - // Check if this doc is our target - const docId = getEntityId(value); - if (docId && docId["/"] === charmId["/"]) { - return true; - } - - // Check if this doc's source chain leads to our target - const sourceRefId = followSourceToResultRef( - value.asCell(), - new Set(), - 0, - ); - if (sourceRefId && sourceRefId["/"] === charmId["/"]) { - return true; - } - } catch (err) { - console.debug("Error handling doc in checkRefersToTarget:", err); - } - return false; // Don't process doc contents - } - - if (isCellLink(value)) { - try { // Check if the cell link's doc is our target - const cellId = getEntityId(value.cell); - if (cellId && cellId["/"] === charmId["/"]) { - return true; - } + if (link.id === charm.sourceURI) return true; // Check if cell link's source chain leads to our target - const sourceRefId = followSourceToResultRef( - value.cell.asCell(), + const sourceResultRefURI = followSourceToResultRef( + this.runtime.getCellFromLink(link), new Set(), 0, ); - if (sourceRefId && sourceRefId["/"] === charmId["/"]) { - return true; - } + if (sourceResultRefURI === charm.sourceURI) return true; } catch (err) { console.debug( "Error handling cell link in checkRefersToTarget:", @@ -933,150 +716,9 @@ export class CharmManager { return false; // Don't process cell link contents } - // Use isAlias and followAliases for aliases - if (isAlias(value)) { - try { - // Follow all aliases to their source - const cellLink = followAliases(value, parent.getDoc()); - if (cellLink && cellLink.cell) { - // Check if the aliased doc is our target - const cellId = getEntityId(cellLink.cell); - if (cellId && cellId["/"] === charmId["/"]) { - return true; - } - - // Check if source chain leads to our target - const sourceRefId = followSourceToResultRef( - this.runtime.getCellFromLink(cellLink), - new Set(), - 0, - ); - if (sourceRefId && sourceRefId["/"] === charmId["/"]) { - return true; - } - } - } catch (err) { - console.debug( - "Error following aliases in checkRefersToTarget:", - err, - ); - } - return false; // Aliases have been fully handled - } - - // Use maybeGetCellLink to handle various reference types - const cellLink = maybeGetCellLink(value, parent.getDoc()); - if (cellLink) { - try { - // Check if the linked doc is our target - const cellId = getEntityId(cellLink.cell); - if (cellId && cellId["/"] === charmId["/"]) { - return true; - } - - // Check if source chain leads to our target - const sourceRefId = followSourceToResultRef( - this.runtime.getCellFromLink(cellLink), - new Set(), - 0, - ); - if (sourceRefId && sourceRefId["/"] === charmId["/"]) { - return true; - } - } catch (err) { - console.debug( - "Error handling maybeGetCellLink in checkRefersToTarget:", - err, - ); - } - return false; // Cell link has been fully handled - } - - // Direct $alias handling (for cases not caught by isAlias) - if (value.$alias && value.$alias.cell) { - try { - // Check if the alias points to our target - const aliasId = getEntityId(value.$alias.cell); - if (aliasId && aliasId["/"] === charmId["/"]) { - return true; - } - - // Check if source chain leads to our target - const sourceRefId = followSourceToResultRef( - value.$alias.cell, - new Set(), - 0, - ); - if (sourceRefId && sourceRefId["/"] === charmId["/"]) { - return true; - } - } catch (err) { - console.debug( - "Error handling direct alias in checkRefersToTarget:", - err, - ); - } - } - - // Direct cell reference handling (for cases not caught by maybeGetCellLink) - if (value.cell && value.path !== undefined) { - try { - // Check if cell reference points to our target - const cellId = getEntityId(value.cell); - if (cellId && cellId["/"] === charmId["/"]) { - return true; - } - - // Check if source chain leads to our target - const sourceRefId = followSourceToResultRef( - value.cell, - new Set(), - 0, - ); - if (sourceRefId && sourceRefId["/"] === charmId["/"]) { - return true; - } - } catch (err) { - console.debug( - "Error handling direct cell ref in checkRefersToTarget:", - err, - ); - } - } - // Safe recursive processing of arrays if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { - // Skip null/undefined items - if (value[i] == null) continue; - - // Handle cells carefully - if ( - typeof value[i] === "object" && - (isCell(value[i]) || isDoc(value[i]) || isCellLink(value[i])) - ) { - try { - // Process cells directly to avoid copy trap - if ( - checkRefersToTarget( - value[i], - parent, - new Set([...visited]), - depth + 1, - ) - ) { - return true; - } - } catch (err) { - console.debug( - `Error checking special array item at index ${i}:`, - err, - ); - } - continue; - } - - // Regular value processing try { if ( checkRefersToTarget( @@ -1092,20 +734,12 @@ export class CharmManager { console.debug(`Error checking array item at index ${i}:`, err); } } - } else if (typeof value === "object") { + } else if (isRecord(value)) { // Process regular object properties const keys = Object.keys(value); for (let i = 0; i < keys.length; i++) { const key = keys[i]; - // Skip properties that might be or contain Cell objects - if ( - key === "sourceCell" || key === "cell" || key === "value" || - key === "getAsCellLink" || key === "getSourceCell" - ) { - continue; - } - try { if ( checkRefersToTarget( @@ -1133,53 +767,28 @@ export class CharmManager { for (const otherCharm of allCharms) { if (isSameEntity(otherCharm, charm)) continue; // Skip self - // First check the charm document - try { - const otherCellLink = otherCharm.getAsCellLink(); - if (!otherCellLink.cell) continue; - - const charmValue = otherCellLink.cell.get(); - - // Check if the charm document references our target - if (charmValue && typeof charmValue === "object") { - if ( - checkRefersToTarget( - charmValue, - this.runtime.getCellFromLink(otherCellLink), - new Set(), - 0, - ) - ) { - addReadingCharm(otherCharm); - continue; // Skip additional checks for this charm - } - } - } catch (err) { - // Error checking charm references - continue to check argument references + if (checkRefersToTarget(otherCharm, otherCharm, new Set(), 0)) { + addReadingCharm(otherCharm); + continue; // Skip additional checks for this charm } // Also specifically check the argument data where references are commonly found try { const argumentCell = this.getArgument(otherCharm); if (argumentCell) { - const argumentLink = argumentCell.getAsCellLink(); - if (argumentLink && argumentLink.cell) { - const argumentValue = argumentLink.cell.getAtPath( - argumentLink.path, - ); + const argumentValue = argumentCell.getRaw(); - // Check if the argument references our target - if (argumentValue && typeof argumentValue === "object") { - if ( - checkRefersToTarget( - argumentValue, - this.runtime.getCellFromLink(argumentLink), - new Set(), - 0, - ) - ) { - addReadingCharm(otherCharm); - } + // Check if the argument references our target + if (argumentValue && typeof argumentValue === "object") { + if ( + checkRefersToTarget( + argumentValue, + argumentCell, + new Set(), + 0, + ) + ) { + addReadingCharm(otherCharm); } } } diff --git a/packages/charm/test/charm-references.test.ts b/packages/charm/test/charm-references.test.ts index 724257f0b..673ba5489 100644 --- a/packages/charm/test/charm-references.test.ts +++ b/packages/charm/test/charm-references.test.ts @@ -1,14 +1,6 @@ import { assertEquals } from "@std/assert"; -import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; -import { CharmManager } from "../src/manager.ts"; -import { - Cell, - DocImpl, - EntityId, - getEntityId, - maybeGetCellLink, -} from "@commontools/runner"; -import { NAME } from "@commontools/runner"; +import { describe, it } from "@std/testing/bdd"; +import { EntityId } from "@commontools/runner"; // Create a mock environment for testing reference detection describe("Charm reference detection", () => { diff --git a/packages/charm/test/reference-detection.test.ts b/packages/charm/test/reference-detection.test.ts index dab58eb97..880dcff37 100644 --- a/packages/charm/test/reference-detection.test.ts +++ b/packages/charm/test/reference-detection.test.ts @@ -1,6 +1,5 @@ import { assertEquals } from "@std/assert"; import { describe, it } from "@std/testing/bdd"; -import { maybeGetCellLink } from "@commontools/runner"; /** * These tests focus on the core functionality used by our charm reference detection diff --git a/packages/cli/lib/charm.ts b/packages/cli/lib/charm.ts index ac41ace0b..485a8fdad 100644 --- a/packages/cli/lib/charm.ts +++ b/packages/cli/lib/charm.ts @@ -288,8 +288,6 @@ export async function linkCharms( sourceResultCell = sourceResultCell.key(segment); } - const sourceCellLink = sourceResultCell.getAsCellLink(); - // Navigate to the target path const targetKey = targetPath.pop(); if (!targetKey) { @@ -310,7 +308,7 @@ export async function linkCharms( targetInputCell = targetInputCell.key(segment); } - targetInputCell.key(targetKey).set(sourceCellLink); + targetInputCell.key(targetKey).set(sourceResultCell); await manager.runtime.idle(); await manager.synced(); diff --git a/packages/jumble/integration/basic-flow.test.ts b/packages/jumble/integration/basic-flow.test.ts index 9b78d7541..a36669917 100644 --- a/packages/jumble/integration/basic-flow.test.ts +++ b/packages/jumble/integration/basic-flow.test.ts @@ -17,10 +17,13 @@ const TAKE_SNAPSHOTS = false; const TOOLSHED_API_URL = Deno.env.get("TOOLSHED_API_URL") ?? "http://localhost:8000/"; const FRONTEND_URL = Deno.env.get("FRONTEND_URL") ?? "http://localhost:5173/"; -const HEADLESS = true; +const HEADLESS = !Deno.env.get("RUN_IN_BROWSER"); const ASTRAL_TIMEOUT = 60_000; const RECIPE_PATH = "../../recipes/simpleValue.tsx"; -const COMMON_CLI_PATH = path.join(import.meta.dirname!, "../../../scripts/main.ts"); +const COMMON_CLI_PATH = path.join( + import.meta.dirname!, + "../../../scripts/main.ts", +); const SNAPSHOTS_DIR = join(Deno.cwd(), "test_snapshots"); console.log(`TOOLSHED_API_URL=${TOOLSHED_API_URL}`); diff --git a/packages/jumble/src/iframe-ctx.ts b/packages/jumble/src/iframe-ctx.ts index 1ea2dff73..8fbd864fe 100644 --- a/packages/jumble/src/iframe-ctx.ts +++ b/packages/jumble/src/iframe-ctx.ts @@ -251,7 +251,7 @@ export const setupIframe = (runtime: Runtime) => }; // Schedule the action with appropriate reactivity log - const reads = isCell(context) ? [context.getAsCellLink()] : []; + const reads = isCell(context) ? [context.getAsLegacyCellLink()] : []; const cancel = runtime.scheduler.schedule(action, { reads, writes: [] }); return { action, cancel }; }, diff --git a/packages/runner/README.md b/packages/runner/README.md index 9d36a7381..27bc37ff0 100644 --- a/packages/runner/README.md +++ b/packages/runner/README.md @@ -34,7 +34,7 @@ import { StorageManager } from "@commontools/runner/storage/cache.deno"; const runtime = new Runtime({ storageManager: new StorageManager({ address: "https://example.com/storage", - signer: myIdentitySigner + signer: myIdentitySigner, }), consoleHandler: myConsoleHandler, // Optional errorHandlers: [myErrorHandler], // Optional @@ -88,7 +88,7 @@ relationship between documents and cells: - **Cell**: The user-facing abstraction that provides a reactive view over one or more documents. Cells are defined by schemas and can traverse document - relationships through cell links and aliases. + relationships through sigil-based links. While DocImpl handles the low-level storage concerns, Cells provide the higher-level programming model with schema validation, reactivity, and @@ -105,13 +105,11 @@ storage: - Schemas can define nested cells with `asCell: true` - Schema validation happens automatically when setting values -### CellLink and Aliases +### Sigil-based Links -Cells can reference other cells through links and aliases: +Cells can reference other cells through a unified sigil-based linking system. This approach replaces the previous distinction between CellLinks and Aliases. -- **CellLink**: A reference to another cell, containing a space ID and document - ID -- **Aliases**: Named references within documents that point to other documents +- **Sigil Links**: A flexible, JSON-based format for representing references to other cells. They can be simple links to other documents or write-redirects (previously aliases). - These mechanisms allow building complex, interconnected data structures - The system automatically traverses links when needed @@ -178,6 +176,7 @@ const runtime = new Runtime({ }); ``` +```typescript // Create a cell with schema and default values const settingsCell = runtime.getCell( "my-space", // The space this cell belongs to @@ -234,7 +233,6 @@ const cleanup = settingsCell.sink((value) => { // Clean up subscription when done cleanup(); - ``` ### Cells with Type-Safe Schemas @@ -260,6 +258,7 @@ const runtime = new Runtime({ }); ``` +```typescript // Define a schema with type assertions for TypeScript inference const userSchema = { type: "object", @@ -312,7 +311,6 @@ settingsCell.set({ theme: "dark", notifications: false }); // Key navigation preserves schema const nameCell = userCell.key("name"); console.log(nameCell.get()); // "Alice" - ``` ### Running Recipes @@ -338,6 +336,7 @@ const runtime = new Runtime({ }); ``` +```typescript // Define a recipe with input and output schemas const doubleNumberRecipe = recipe( // Input schema @@ -386,7 +385,6 @@ console.log(result.get()); // { result: 20 } // Stop recipe execution when no longer needed runtime.runner.stop(result); - ``` ### Storage @@ -405,7 +403,7 @@ const signer = await Identity.fromPassphrase("my-passphrase"); // Create storage manager (for production, use StorageManager.open() with remote storage) const storageManager = StorageManager.open({ as: signer, - address: new URL("https://example.com/api") + address: new URL("https://example.com/api"), }); // Create a runtime instance with configuration @@ -419,6 +417,7 @@ const runtime = new Runtime({ }); ``` +```typescript // Sync a cell with storage await runtime.storage.syncCell(userCell); @@ -430,7 +429,6 @@ await runtime.storage.synced(); // When cells with the same causal ID are synced across instances, // they will automatically be kept in sync with the latest value - ``` ## Advanced Features @@ -456,6 +454,7 @@ const runtime = new Runtime({ }); ``` +```typescript // Original data source cell const sourceCell = runtime.getCell( "my-space", @@ -503,14 +502,14 @@ const mappingCell = runtime.getCell( firstTag: { type: "string" }, }, default: { - // References to source cell values - id: { cell: sourceCell, path: ["id"] }, + // References to source cell values using sigil links + id: sourceCell.key("id").getAsLink(), // Turn single value to array - changes: [{ cell: sourceCell, path: ["metadata", "createdAt"] }], + changes: [sourceCell.key("metadata").key("createdAt").getAsLink()], // Rename field and uplift from nested element - kind: { cell: sourceCell, path: ["metadata", "type"] }, + kind: sourceCell.key("metadata").key("type").getAsLink(), // Reference to first array element - firstTag: { cell: sourceCell, path: ["tags", 0] }, + firstTag: sourceCell.key("tags").key(0).getAsLink(), }, }, ); @@ -524,7 +523,6 @@ console.log(result); // kind: "user", // firstTag: "tag1" // } - ``` ### Nested Reactivity @@ -547,6 +545,7 @@ const runtime = new Runtime({ }); ``` +```typescript const rootCell = runtime.getCell( "my-space", "nested-example", @@ -604,7 +603,6 @@ rootCell.key("current").key("label").set("updated"); // "Label value: updated" // "Nested value: { label: 'updated' }" // "Root changed: { value: 'root', current: { label: 'updated' } }" - ``` ## Migration from Singleton Pattern @@ -663,7 +661,8 @@ interface RuntimeOptions { ### Storage Manager -Storage manager is used by runtime to open storage providers when reading or writing documents into a corresponding space. +Storage manager is used by runtime to open storage providers when reading or +writing documents into a corresponding space. ```ts export interface IStorageManager { @@ -671,7 +670,8 @@ export interface IStorageManager { } ``` -The storage manager opens storage providers for different memory spaces. The StorageManager provides convenient factory methods: +The storage manager opens storage providers for different memory spaces. The +StorageManager provides convenient factory methods: ```ts import { StorageManager } from "@commontools/runner/storage/cache "; @@ -689,7 +689,8 @@ const storageManager = StorageManager.open({ }); ``` -The `@commontools/storage/cache` provides a default implementation of the `IStorageManager` interface. +The `@commontools/storage/cache` provides a default implementation of the +`IStorageManager` interface. - `"volatile://"` - In-memory storage (for testing) - `"https://example.com/storage"` - Remote storage with schema queries @@ -709,7 +710,7 @@ components interact: 2. **Validation** → Schema validation ensures data conforms to expected structure (so far only on get, not yet on write) 3. **Processing** → Recipes transform data according to their logic -4. **Reactivity** → Changes propagate to dependent cells and recipes +4. **Reactivity** → Changes propagate to dependent cells and recipes through the unified sigil-based linking system 5. **Storage** → Updated data is persisted to storage if configured 6. **Synchronization** → Changes are synchronized across clients if enabled diff --git a/packages/runner/src/builder/built-in.ts b/packages/runner/src/builder/built-in.ts index 9802fff55..73cd0961a 100644 --- a/packages/runner/src/builder/built-in.ts +++ b/packages/runner/src/builder/built-in.ts @@ -146,17 +146,15 @@ export function str( * by the order of invocation, which is less stable. * @param value - Optional, the initial value of the cell. */ -declare global { - function createCell( - schema?: JSONSchema, - name?: string, - value?: T, - ): Cell; - function createCell( - schema: S, - name?: string, - value?: Schema, - ): Cell>; -} +declare function createCell( + schema?: JSONSchema, + name?: string, + value?: T, +): Cell; +declare function createCell( + schema: S, + name?: string, + value?: Schema, +): Cell>; export type { createCell }; diff --git a/packages/runner/src/builder/json-utils.ts b/packages/runner/src/builder/json-utils.ts index 94d52e8ec..29b07a21f 100644 --- a/packages/runner/src/builder/json-utils.ts +++ b/packages/runner/src/builder/json-utils.ts @@ -1,10 +1,8 @@ -import { isObject, isRecord } from "@commontools/utils/types"; -import { type CellLink, isCell, isCellLink, isDoc } from "../index.ts"; -import { createShadowRef } from "./opaque-ref.ts"; +import { isRecord } from "@commontools/utils/types"; +import { type LegacyAlias } from "../sigil-types.ts"; +import { isLegacyAlias, isLink } from "../link-utils.ts"; import { - type Alias, canBeOpaqueRef, - isAlias, isOpaqueRef, isRecipe, isShadowRef, @@ -20,9 +18,11 @@ import { unsafe_originalRecipe, } from "./types.ts"; import { getTopFrame } from "./recipe.ts"; -import { deepEqual, getValueAtPath } from "../path-utils.ts"; +import { deepEqual } from "../path-utils.ts"; +import { IRuntime } from "../runtime.ts"; +import { parseLink } from "../link-utils.ts"; -export function toJSONWithAliases( +export function toJSONWithLegacyAliases( value: Opaque, paths: Map, PropertyKey[]>, ignoreSelfAliases: boolean = false, @@ -58,12 +58,12 @@ export function toJSONWithAliases( ...(exported?.schema ? { schema: exported.schema } : {}), ...(exported?.rootSchema ? { rootSchema: exported.rootSchema } : {}), }, - } satisfies Alias; + } satisfies LegacyAlias; } else throw new Error(`Cell not found in paths`); } - if (isAlias(value)) { - const alias = (value as Alias).$alias; + if (isLegacyAlias(value)) { + const alias = (value as LegacyAlias).$alias; if (isShadowRef(alias.cell)) { const cell = alias.cell.shadowOf; if (cell.export().frame !== getTopFrame()) { @@ -83,14 +83,14 @@ export function toJSONWithAliases( $alias: { path: [...paths.get(cell)!, ...alias.path] as (string | number)[], }, - } satisfies Alias; + } satisfies LegacyAlias; } else if (!("cell" in alias) || typeof alias.cell === "number") { return { $alias: { cell: ((alias.cell as number) ?? 0) + 1, path: alias.path as (string | number)[], }, - } satisfies Alias; + } satisfies LegacyAlias; } else { throw new Error(`Invalid alias cell`); } @@ -98,7 +98,7 @@ export function toJSONWithAliases( if (Array.isArray(value)) { return (value as Opaque).map((v: Opaque, i: number) => - toJSONWithAliases(v, paths, ignoreSelfAliases, [...path, i]) + toJSONWithLegacyAliases(v, paths, ignoreSelfAliases, [...path, i]) ); } @@ -106,7 +106,7 @@ export function toJSONWithAliases( const result: any = {}; let hasValue = false; for (const key in value as any) { - const jsonValue = toJSONWithAliases( + const jsonValue = toJSONWithLegacyAliases( value[key], paths, ignoreSelfAliases, @@ -129,30 +129,31 @@ export function toJSONWithAliases( export function createJsonSchema( example: any, addDefaults = false, + runtime?: IRuntime, ): JSONSchemaMutable { + const seen = new Map(); + function analyzeType(value: any): JSONSchema { - if (isCell(value)) { - if (value.schema) { - return value.schema; - } else { - value = value.get(); + if (isLink(value)) { + const link = parseLink(value); + const linkAsStr = JSON.stringify(link); + if (seen.has(linkAsStr)) { + // Return a copy of the schema to avoid mutating the original. + return JSON.parse(JSON.stringify(seen.get(linkAsStr)!)); } - } - if (isDoc(value)) value = { cell: value, path: [] } satisfies CellLink; - - if (isCellLink(value)) { - value = value.cell.getAtPath(value.path); - return analyzeType(value); - } + const cell = runtime?.getCellFromLink(link); + if (!cell) return {}; // TODO(seefeld): Should be `true` - if (isAlias(value)) { - if (isDoc(value.$alias.cell)) { - value = value.$alias.cell.getAtPath(value.$alias.path); - } else { - value = getValueAtPath(example, value.$alias.path); + let schema = cell.schema; + if (!schema) { + // If we find pointing back here, assume an empty schema. This is + // overwritten below. (TODO(seefeld): This should create `$ref: "#/.."`) + seen.set(linkAsStr, {} as JSONSchemaMutable); + schema = analyzeType(cell.getRaw()); } - return analyzeType(value); + seen.set(linkAsStr, schema as JSONSchemaMutable); + return schema; } const type = typeof value; @@ -162,33 +163,19 @@ export function createJsonSchema( case "object": if (Array.isArray(value)) { schema.type = "array"; - // Check the array type. The array type is determined by the first element - // of the array, or if objects, a superset of all properties of the object elements. - // If array is empty, `items` is `{}`. if (value.length === 0) { - schema.items = {}; + schema.items = {}; // TODO(seefeld): Should be `true` } else { - const first = value[0]; - if (isObject(first)) { - const properties: { [key: string]: any } = {}; - for (let i = 0; i < value.length; i++) { - const item = value?.[i]; - if (isRecord(item)) { - Object.keys(item).forEach((key) => { - if (!(key in properties)) { - properties[key] = analyzeType( - value?.[i]?.[key], - ); - } - }); - } - } - schema.items = { - type: "object", - properties, - }; + const schemas = value.map((v) => analyzeType(v)).map((s) => + JSON.stringify(s) + ); + const uniqueSchemas = [...new Set(schemas)].map((s) => + JSON.parse(s) + ); + if (uniqueSchemas.length === 1) { + schema.items = uniqueSchemas[0]; } else { - schema.items = analyzeType(first) as JSONSchemaMutable; + schema.items = { anyOf: uniqueSchemas }; } } } else if (value !== null) { diff --git a/packages/runner/src/builder/module.ts b/packages/runner/src/builder/module.ts index d6cef3042..550cfa42c 100644 --- a/packages/runner/src/builder/module.ts +++ b/packages/runner/src/builder/module.ts @@ -18,7 +18,7 @@ import { connectInputAndOutputs, } from "./node-utils.ts"; import { moduleToJSON } from "./json-utils.ts"; -import { traverseValue } from "../traverse-utils.ts"; +import { traverseValue } from "./traverse-utils.ts"; import { getTopFrame } from "./recipe.ts"; export function createNodeFactory( diff --git a/packages/runner/src/builder/node-utils.ts b/packages/runner/src/builder/node-utils.ts index 2d8a3ad2a..35e32d211 100644 --- a/packages/runner/src/builder/node-utils.ts +++ b/packages/runner/src/builder/node-utils.ts @@ -10,7 +10,7 @@ import { type OpaqueRef, } from "./types.ts"; import { ContextualFlowControl } from "../cfc.ts"; -import { traverseValue } from "../traverse-utils.ts"; +import { traverseValue } from "./traverse-utils.ts"; export function connectInputAndOutputs(node: NodeRef) { function connect(value: any): any { diff --git a/packages/runner/src/builder/recipe.ts b/packages/runner/src/builder/recipe.ts index 6f97b710e..a1ee27833 100644 --- a/packages/runner/src/builder/recipe.ts +++ b/packages/runner/src/builder/recipe.ts @@ -30,10 +30,10 @@ import { createJsonSchema, moduleToJSON, recipeToJSON, - toJSONWithAliases, + toJSONWithLegacyAliases, } from "./json-utils.ts"; import { setValueAtPath } from "../path-utils.ts"; -import { traverseValue } from "../traverse-utils.ts"; +import { traverseValue } from "./traverse-utils.ts"; /** Declare a recipe * @@ -257,10 +257,10 @@ function factoryFromRecipe( }); // Creates a query (i.e. aliases) into the cells for the result - const result = toJSONWithAliases(outputs ?? {}, paths, true)!; + const result = toJSONWithLegacyAliases(outputs ?? {}, paths, true)!; // Collect default values for the inputs - const defaults = toJSONWithAliases( + const defaults = toJSONWithLegacyAliases( inputs.export().defaultValue ?? {}, paths, true, @@ -307,9 +307,12 @@ function factoryFromRecipe( applyArgumentIfcToResult(argumentSchema, resultSchemaArg) || {}; const serializedNodes = Array.from(nodes).map((node) => { - const module = toJSONWithAliases(node.module, paths) as unknown as Module; - const inputs = toJSONWithAliases(node.inputs, paths)!; - const outputs = toJSONWithAliases(node.outputs, paths)!; + const module = toJSONWithLegacyAliases( + node.module, + paths, + ) as unknown as Module; + const inputs = toJSONWithLegacyAliases(node.inputs, paths)!; + const outputs = toJSONWithLegacyAliases(node.outputs, paths)!; return { module, inputs, outputs } satisfies Node; }); diff --git a/packages/runner/src/traverse-utils.ts b/packages/runner/src/builder/traverse-utils.ts similarity index 97% rename from packages/runner/src/traverse-utils.ts rename to packages/runner/src/builder/traverse-utils.ts index 048640d35..0688b5a14 100644 --- a/packages/runner/src/traverse-utils.ts +++ b/packages/runner/src/builder/traverse-utils.ts @@ -5,7 +5,7 @@ import { isRecipe, isShadowRef, type Opaque, -} from "./builder/types.ts"; +} from "./types.ts"; /** * Traverse a value, _not_ entering cells diff --git a/packages/runner/src/builder/types.ts b/packages/runner/src/builder/types.ts index 46215ee90..ee3f124c5 100644 --- a/packages/runner/src/builder/types.ts +++ b/packages/runner/src/builder/types.ts @@ -135,26 +135,11 @@ export type SchemaContext = { rootSchema: JSONSchema | boolean; }; -export type Alias = { - $alias: { - cell?: unknown; - path: PropertyKey[]; - schema?: JSONSchema; - rootSchema?: JSONSchema; - }; -}; - -export function isAlias(value: unknown): value is Alias { - return isObject(value) && "$alias" in value && isObject(value.$alias) && - "path" in value.$alias && - Array.isArray(value.$alias.path); -} - -export type StreamAlias = { +export type StreamValue = { $stream: true; }; -export function isStreamAlias(value: unknown): value is StreamAlias { +export function isStreamValue(value: unknown): value is StreamValue { return isObject(value) && "$stream" in value && value.$stream === true; } @@ -177,7 +162,7 @@ export function isModule(value: unknown): value is Module { export type Node = { description?: string; - module: Module | Alias; + module: Module; // TODO(seefeld): Add `Alias` here once supported inputs: JSONValue; outputs: JSONValue; }; diff --git a/packages/runner/src/builtins/if-else.ts b/packages/runner/src/builtins/if-else.ts index d39b80b2b..adce8b0f1 100644 --- a/packages/runner/src/builtins/if-else.ts +++ b/packages/runner/src/builtins/if-else.ts @@ -23,7 +23,7 @@ export function ifElse( const condition = inputsCell.withLog(log).key(0).get(); const ref = inputsCell.withLog(log).key(condition ? 1 : 2) - .getAsCellLink(); + .getAsLink({ base: result.asCell() }); result.send(ref, log); }; diff --git a/packages/runner/src/cell.ts b/packages/runner/src/cell.ts index 58c86ee7e..bd815fde0 100644 --- a/packages/runner/src/cell.ts +++ b/packages/runner/src/cell.ts @@ -1,10 +1,11 @@ import { isObject, isRecord } from "@commontools/utils/types"; +import { type MemorySpace } from "@commontools/memory/interface"; import { getTopFrame } from "./builder/recipe.ts"; import { type Cell, ID, ID_FIELD, - isStreamAlias, + isStreamValue, type JSONSchema, type Schema, } from "./builder/types.ts"; @@ -15,11 +16,24 @@ import { type QueryResult, } from "./query-result-proxy.ts"; import { diffAndUpdate } from "./data-updating.ts"; -import { resolveLinkToAlias, resolveLinkToValue } from "./link-resolution.ts"; +import { + resolveLinkToValue, + resolveLinkToWriteRedirect, +} from "./link-resolution.ts"; import type { ReactivityLog } from "./scheduler.ts"; import { type EntityId } from "./doc-map.ts"; import { type Cancel, isCancel, useCancelGroup } from "./cancel.ts"; import { validateAndTransform } from "./schema.ts"; +import { toURI } from "./uri-utils.ts"; +import { + type JSONCellLink, + type LegacyCellLink, + LINK_V1_TAG, + type SigilLink, + type SigilWriteRedirectLink, + type URI, +} from "./sigil-types.ts"; +import { areLinksSame, isLink } from "./link-utils.ts"; /** * This is the regular Cell interface, generated by DocImpl.asCell(). @@ -42,8 +56,8 @@ import { validateAndTransform } from "./schema.ts"; * @returns {void} * * @method push Adds an item to the end of an array cell. - * @param {U | DocImpl | CellLink} value - The value to add, where U is the - * array element type. + * @param {U | DocImpl | LegacyCellLink} value - The value to add, where U is + * the array element type. * @returns {void} * * @method equals Compares two cells for equality. @@ -73,16 +87,19 @@ import { validateAndTransform } from "./schema.ts"; * @param {ReactivityLog} log - Optional reactivity log. * @returns {QueryResult>} * - * @method getAsCellLink Returns a cell link for the cell. - * @returns {CellLink} + * @method getAsLegacyCellLink Returns a cell link for the cell (legacy format). + * @returns {LegacyCellLink} * - * @method getRaw Raw access method, without following aliases (which would - * write to the destination instead of the cell itself). + * @method getAsLink Returns a cell link for the cell (new sigil format). + * @returns {SigilLink} + * + * @method getRaw Raw access method, without following aliases (which would + * write to the destination instead of the cell itself). * @returns {any} - Raw document data * - * @method setRaw Raw write method that bypasses Cell validation, - * transformation, and alias resolution. Writes directly to the cell without - * following aliases. + * @method setRaw Raw write method that bypasses Cell validation, + * transformation, and alias resolution. Writes directly to the cell without + * following aliases. * @param {any} value - Raw value to write directly to document * @returns {boolean} - Result from underlying doc.send() * @@ -97,7 +114,10 @@ import { validateAndTransform } from "./schema.ts"; * @returns {{cell: {"/": string} | undefined, path: PropertyKey[]}} * * @property entityId Returns the current entity ID of the cell. - * @returns {EntityId | undefined} + * @returns {EntityId} + * + * @property sourceURI Returns the source URI of the cell. + * @returns {URI} * * @property schema Optional schema for the cell. * @returns {JSONSchema | undefined} @@ -114,7 +134,7 @@ import { validateAndTransform } from "./schema.ts"; * @returns {T} * * @property cellLink The cell link representing this cell. - * @returns {CellLink} + * @returns {LegacyCellLink} */ declare module "@commontools/api" { interface Cell { @@ -127,10 +147,10 @@ declare module "@commontools/api" { push( ...value: Array< | (T extends Array ? (Cellify | U | DocImpl) : any) - | CellLink + | Cell > ): void; - equals(other: Cell): boolean; + equals(other: any): boolean; key ? keyof S : keyof T>( valueKey: K, ): Cell< @@ -149,7 +169,21 @@ declare module "@commontools/api" { path?: Path, log?: ReactivityLog, ): QueryResult>; - getAsCellLink(): CellLink; + getAsLegacyCellLink(): LegacyCellLink; + getAsLink( + options?: { + base?: Cell; + baseSpace?: MemorySpace; + includeSchema?: boolean; + }, + ): SigilLink; + getAsWriteRedirectLink( + options?: { + base?: Cell; + baseSpace?: MemorySpace; + includeSchema?: boolean; + }, + ): SigilWriteRedirectLink; getDoc(): DocImpl; getRaw(): any; setRaw(value: any): boolean; @@ -172,12 +206,15 @@ declare module "@commontools/api" { & ("argument" extends keyof Schema ? unknown : { argument: any }) >; - toJSON(): { cell: { "/": string } | undefined; path: PropertyKey[] }; + toJSON(): JSONCellLink; schema?: JSONSchema; rootSchema?: JSONSchema; value: T; - cellLink: CellLink; - entityId: EntityId | undefined; + cellLink: LegacyCellLink; + space: MemorySpace; + entityId: EntityId; + sourceURI: URI; + path: PropertyKey[]; [isCellMarker]: true; copyTrap: boolean; } @@ -185,6 +222,8 @@ declare module "@commontools/api" { export type { Cell } from "@commontools/api"; +export type { MemorySpace } from "@commontools/memory/interface"; + /** * Cellify is a type utility that allows any part of type T to be wrapped in * Cell<>, and allow any part of T that is currently wrapped in Cell<> to be @@ -214,37 +253,27 @@ export interface Stream { [isStreamMarker]: true; } -/** - * Cell link. - * - * A cell link is a doc and a path within that doc. - */ -export type CellLink = { - space?: string; - cell: DocImpl; - path: PropertyKey[]; - schema?: JSONSchema; - rootSchema?: JSONSchema; -}; - export function createCell( doc: DocImpl, path: PropertyKey[] = [], log?: ReactivityLog, schema?: JSONSchema, rootSchema: JSONSchema | undefined = schema, + noResolve = false, ): Cell { // Resolve the path to check whether it's a stream. We're not logging this right now. // The corner case where during it's lifetime this changes from non-stream to stream // or vice versa will not be detected. - const ref = resolveLinkToValue(doc, path, undefined, schema, rootSchema); + const ref = noResolve + ? { cell: doc, path, schema, rootSchema } + : resolveLinkToValue(doc, path, undefined, schema, rootSchema); // Use schema from alias if provided and no explicit schema was set if (!schema && ref.schema) { schema = ref.schema; rootSchema = ref.rootSchema || ref.schema; } - if (isStreamAlias(ref.cell.getAtPath(ref.path))) { + if (isStreamValue(ref.cell.getAtPath(ref.path))) { return createStreamCell( ref.cell, ref.path, @@ -309,7 +338,7 @@ function createRegularCell( set: (newValue: Cellify) => // TODO(@ubik2) investigate whether i need to check classified as i walk down my own obj diffAndUpdate( - resolveLinkToAlias(doc, path, log, schema, rootSchema), + resolveLinkToWriteRedirect(doc, path, log, schema, rootSchema), newValue, log, getTopFrame()?.cause, @@ -327,7 +356,8 @@ function createRegularCell( if (currentValue === undefined) { if (schema) { // Check if schema allows objects - const allowsObject = schema.type === "object" || (Array.isArray(schema.type) && schema.type.includes("object")) || + const allowsObject = schema.type === "object" || + (Array.isArray(schema.type) && schema.type.includes("object")) || (schema.anyOf && schema.anyOf.some((s) => typeof s === "object" && s.type === "object" @@ -351,7 +381,7 @@ function createRegularCell( push: ( ...values: Array< | (T extends Array ? (Cellify | U | DocImpl) : any) - | CellLink + | Cell > ) => { // Follow aliases and references, since we want to get to an assumed @@ -365,20 +395,12 @@ function createRegularCell( } // If this is an object and it doesn't have an ID, add one. - const valuesToWrite = values.map((value: any) => { - if ( - !isCell(value) && !isCellLink(value) && !isDoc(value) && - isObject(value) && - (value as { [ID]?: unknown })[ID] === undefined && getTopFrame() - ) { - return { - [ID]: getTopFrame()!.generatedIdCounter++, - ...value, - }; - } else { - return value; - } - }); + const valuesToWrite = values.map((value: any) => + (!isLink(value) && isObject(value) && + (value as { [ID]?: unknown })[ID] === undefined && getTopFrame()) + ? { [ID]: getTopFrame()!.generatedIdCounter++, ...value } + : value + ); // If there is no array yet, create it first. We have to do this as a // separate operation, so that in the next steps [ID] is properly anchored @@ -412,8 +434,7 @@ function createRegularCell( return [...(newBaseValue as unknown[]), ...newValues]; }); }, - equals: (other: Cell) => - JSON.stringify(self) === JSON.stringify(other), + equals: (other: any) => areLinksSame(self, other), key: ? keyof S : keyof T>( valueKey: K, ): T extends Cell ? Cell : Cell => { @@ -439,27 +460,63 @@ function createRegularCell( subscribeToReferencedDocs(callback, doc, path, schema, rootSchema), getAsQueryResult: (subPath: PropertyKey[] = [], newLog?: ReactivityLog) => createQueryResultProxy(doc, [...path, ...subPath], newLog ?? log), - getAsCellLink: () => // Add space here, so that JSON.stringify() of this retains the space. - ({ space: doc.space, cell: doc, path, schema, rootSchema }), + getAsLegacyCellLink: (): LegacyCellLink => { + return { space: doc.space, cell: doc, path, schema, rootSchema }; + }, + getAsLink: ( + options?: { + base?: Cell; + baseSpace?: MemorySpace; + includeSchema?: boolean; + }, + ): SigilLink => { + return createSigilLink( + doc, + path, + schema, + rootSchema, + options, + ) as SigilLink; + }, + getAsWriteRedirectLink: ( + options?: { + base?: Cell; + baseSpace?: MemorySpace; + includeSchema?: boolean; + }, + ): SigilWriteRedirectLink => { + return createSigilLink( + doc, + path, + schema, + rootSchema, + { ...options, overwrite: "redirect" }, + ) as SigilWriteRedirectLink; + }, getDoc: () => doc, getRaw: () => doc.getAtPath(path), setRaw: (value: any) => doc.setAtPath(path, value), getSourceCell: (newSchema?: JSONSchema) => doc.sourceCell?.asCell([], log, newSchema, newSchema) as Cell, - toJSON: () => - // TODO(seefeld): Should this include the schema, as cells are defiined by doclink & schema? - ({ cell: doc.toJSON(), path }) satisfies { - cell: { "/": string } | undefined; - path: PropertyKey[]; - }, + toJSON: (): JSONCellLink => // Keep old format for backward compatibility + ({ cell: doc.toJSON()!, path: path as (string | number)[] }), get value(): T { return self.get(); }, - get cellLink(): CellLink { + get cellLink(): LegacyCellLink { return { space: doc.space, cell: doc, path, schema, rootSchema }; }, + get space(): MemorySpace { + return doc.space; + }, get entityId(): EntityId | undefined { - return getEntityId(self.getAsCellLink()); + return getEntityId({ cell: doc, path }); + }, + get sourceURI(): URI { + return toURI(doc.entityId); + }, + get path(): PropertyKey[] { + return path; }, [isCellMarker]: true, get copyTrap(): boolean { @@ -529,6 +586,64 @@ function subscribeToReferencedDocs( }; } +/** + * Creates a sigil reference (link or alias) with shared logic + */ +function createSigilLink( + doc: DocImpl, + path: PropertyKey[], + schema?: JSONSchema, + rootSchema?: JSONSchema, + options: { + base?: Cell; + baseSpace?: MemorySpace; + includeSchema?: boolean; + overwrite?: "redirect" | "this"; // default is "this" + } = {}, +): SigilLink { + // Create the base structure + const sigil: SigilLink = { + "/": { + [LINK_V1_TAG]: { + path: path.map((p) => p.toString()), + }, + }, + }; + + const reference = sigil["/"][LINK_V1_TAG]; + + // Handle base cell for relative references + if (options.base) { + const baseDoc = options.base.getDoc(); + + // Only include id if it's different from base + if (getEntityId(doc)!["/"] !== getEntityId(baseDoc)?.["/"]) { + reference.id = toURI(doc.entityId); + } + + // Only include space if it's different from base + if (doc.space && doc.space !== baseDoc.space) reference.space = doc.space; + } else { + reference.id = toURI(doc.entityId); + + // Handle baseSpace option - only include space if different from baseSpace + if (doc.space !== options.baseSpace) reference.space = doc.space; + } + + // Include schema if requested + if (options.includeSchema && schema) { + reference.schema = schema; + reference.rootSchema = rootSchema; + } + + // Include overwrite if present and it's a redirect + if (options.overwrite && options.overwrite !== "this") { + reference.overwrite = "redirect"; + } + + return sigil; +} + /** * Check if value is a simple cell. * @@ -551,15 +666,3 @@ export function isStream(value: any): value is Stream { } const isStreamMarker = Symbol("isStream"); - -/** - * Check if value is a cell link. - * - * @param {any} value - The value to check. - * @returns {boolean} - */ -export function isCellLink(value: any): value is CellLink { - return ( - isRecord(value) && isDoc(value.cell) && Array.isArray(value.path) - ); -} diff --git a/packages/runner/src/data-updating.ts b/packages/runner/src/data-updating.ts index 243b1502a..e188755e2 100644 --- a/packages/runner/src/data-updating.ts +++ b/packages/runner/src/data-updating.ts @@ -1,19 +1,23 @@ import { isRecord } from "@commontools/utils/types"; -import { - type Alias, - ID, - ID_FIELD, - isAlias, - type JSONSchema, - type JSONValue, -} from "./builder/types.ts"; +import { ID, ID_FIELD, type JSONSchema } from "./builder/types.ts"; import { ContextualFlowControl } from "./cfc.ts"; import { type DocImpl, isDoc } from "./doc.ts"; import { createRef } from "./doc-map.ts"; -import { type CellLink, isCell, isCellLink } from "./cell.ts"; +import { isCell } from "./cell.ts"; +import { isAnyCellLink } from "./link-utils.ts"; +import { type LegacyCellLink } from "./sigil-types.ts"; import { type ReactivityLog } from "./scheduler.ts"; -import { followAliases } from "./link-resolution.ts"; -import { maybeUnwrapProxy, arrayEqual } from "./type-utils.ts"; +import { followWriteRedirects } from "./link-resolution.ts"; +import { + areLinksSame, + isLink, + isWriteRedirectLink, + parseToLegacyCellLink, +} from "./link-utils.ts"; +import { + getCellLinkOrThrow, + isQueryResultForDereferencing, +} from "./query-result-proxy.ts"; // Sets a value at a path, following aliases and recursing into objects. Returns // success, meaning no frozen docs were in the way. That is, also returns true @@ -25,8 +29,8 @@ export function setNestedValue( log?: ReactivityLog, ): boolean { const destValue = doc.getAtPath(path); - if (isAlias(destValue)) { - const ref = followAliases(destValue, doc, log); + if (isWriteRedirectLink(destValue)) { + const ref = followWriteRedirects(destValue, doc, log); return setNestedValue(ref.cell, ref.path, value, log); } @@ -37,7 +41,7 @@ export function setNestedValue( isRecord(value) && Array.isArray(value) === Array.isArray(destValue) && !isDoc(value) && - !isCellLink(value) && + !isAnyCellLink(value) && !isCell(value) ) { let success = true; @@ -62,10 +66,8 @@ export function setNestedValue( } return success; - } else if (isCellLink(value) && isCellLink(destValue)) { - if ( - value.cell !== destValue.cell || !arrayEqual(value.path, destValue.path) - ) { + } else if (isLink(value) && isLink(destValue)) { + if (!areLinksSame(value, destValue, doc.asCell())) { doc.setAtPath(path, value, log); } return true; @@ -95,7 +97,7 @@ export function setNestedValue( * @returns Whether any changes were made. */ export function diffAndUpdate( - current: CellLink, + current: LegacyCellLink, newValue: unknown, log?: ReactivityLog, context?: unknown, @@ -105,7 +107,7 @@ export function diffAndUpdate( return changes.length > 0; } -type ChangeSet = { location: CellLink; value: unknown }[]; +type ChangeSet = { location: LegacyCellLink; value: unknown }[]; /** * Traverses objects and returns an array of changes that should be written. An @@ -129,7 +131,7 @@ type ChangeSet = { location: CellLink; value: unknown }[]; * @returns An array of changes that should be written. */ export function normalizeAndDiff( - current: CellLink, + current: LegacyCellLink, newValue: unknown, log?: ReactivityLog, context?: unknown, @@ -153,19 +155,22 @@ export function normalizeAndDiff( if (current.path.length > 1) { const parent = current.cell.getAtPath(current.path.slice(0, -1)); if (Array.isArray(parent)) { + const base = current.cell.asCell(current.path); for (const v of parent) { - if (isCellLink(v)) { - const sibling = v.cell.getAtPath(v.path); + if (isLink(v)) { + const sibling = parseToLegacyCellLink(v, base); if ( - isRecord(sibling) && - sibling[fieldName as PropertyKey] === id + sibling.cell.getAtPath([ + ...sibling.path, + fieldName as PropertyKey, + ]) === id ) { // We found a sibling with the same id, so ... return [ // ... reuse the existing document ...normalizeAndDiff(current, v, log, context), // ... and update it to the new value - ...normalizeAndDiff(v, rest, log, context), + ...normalizeAndDiff(sibling, rest, log, context), ]; } } @@ -177,19 +182,23 @@ export function normalizeAndDiff( } // Unwrap proxies and handle special types - newValue = maybeUnwrapProxy(newValue); - if (isDoc(newValue)) newValue = { cell: newValue, path: [] }; - if (isCell(newValue)) newValue = newValue.getAsCellLink(); + if (isQueryResultForDereferencing(newValue)) { + newValue = getCellLinkOrThrow(newValue); + } + + if (isDoc(newValue)) { + newValue = { cell: newValue, path: [] } satisfies LegacyCellLink; + } + if (isCell(newValue)) newValue = newValue.getAsLegacyCellLink(); // Get current value to compare against const currentValue = current.cell.getAtPath(current.path); // A new alias can overwrite a previous alias. No-op if the same. - if (isAlias(newValue)) { + if (isWriteRedirectLink(newValue)) { if ( - isAlias(currentValue) && - newValue.$alias.cell === currentValue.$alias.cell && - arrayEqual(newValue.$alias.path, currentValue.$alias.path) + isWriteRedirectLink(currentValue) && + areLinksSame(currentValue, newValue, current.cell.asCell()) ) { return []; } else { @@ -199,18 +208,17 @@ export function normalizeAndDiff( } // Handle alias in current value (at this point: if newValue is not an alias) - if (isAlias(currentValue)) { + if (isWriteRedirectLink(currentValue)) { // Log reads of the alias, so that changing aliases cause refreshes log?.reads.push({ ...current }); - const ref = followAliases(currentValue, current.cell, log); + const ref = followWriteRedirects(currentValue, current.cell, log); return normalizeAndDiff(ref, newValue, log, context); } - if (isCellLink(newValue)) { + if (isAnyCellLink(newValue)) { if ( - isCellLink(currentValue) && - currentValue.cell === newValue.cell && - arrayEqual(currentValue.path, newValue.path) + isAnyCellLink(currentValue) && + areLinksSame(newValue, currentValue, current.cell.asCell()) ) { return []; } else { @@ -221,10 +229,7 @@ export function normalizeAndDiff( } // Handle ID-based object (convert to entity) - if ( - isRecord(newValue) && - newValue[ID] !== undefined - ) { + if (isRecord(newValue) && newValue[ID] !== undefined) { const { [ID]: id, ...rest } = newValue; let path = current.path; @@ -319,10 +324,7 @@ export function normalizeAndDiff( if (isRecord(newValue)) { // If the current value is not a (regular) object, set it to an empty object // Note that the alias case is handled above - if ( - typeof currentValue !== "object" || currentValue === null || - isCellLink(currentValue) - ) { + if (!isRecord(currentValue) || isAnyCellLink(currentValue)) { changes.push({ location: current, value: {} }); } @@ -369,7 +371,7 @@ export function normalizeAndDiff( return changes; } - // Handle primitive values and other cases + // Handle primitive values and other cases (Object.is handles NaN and -0) if (!Object.is(currentValue, newValue)) { changes.push({ location: current, value: newValue }); } @@ -420,11 +422,11 @@ export function addCommonIDfromObjectID( if ( isRecord(obj) && !isCell(obj) && - !isCellLink(obj) && !isDoc(obj) + !isAnyCellLink(obj) && !isDoc(obj) ) { Object.values(obj).forEach((v) => traverse(v)); } } traverse(obj); -} \ No newline at end of file +} diff --git a/packages/runner/src/doc-map.ts b/packages/runner/src/doc-map.ts index b19e2db6f..f074d0583 100644 --- a/packages/runner/src/doc-map.ts +++ b/packages/runner/src/doc-map.ts @@ -6,8 +6,10 @@ import { getCellLinkOrThrow, isQueryResultForDereferencing, } from "./query-result-proxy.ts"; -import { type CellLink, isCell, isCellLink } from "./cell.ts"; +import { isCell } from "./cell.ts"; +import { parseLink } from "./link-utils.ts"; import type { IDocumentMap, IRuntime, MemorySpace } from "./runtime.ts"; +import { fromURI } from "./uri-utils.ts"; export type EntityId = { "/": string | Uint8Array; @@ -78,26 +80,25 @@ export function createRef( */ export function getEntityId(value: any): { "/": string } | undefined { if (typeof value === "string") { + // Handle URI format with "of:" prefix + if (value.startsWith("of:")) value = fromURI(value); return value.startsWith("{") ? JSON.parse(value) : { "/": value }; } if (isRecord(value) && "/" in value) { return JSON.parse(JSON.stringify(value)); } - let ref: CellLink | undefined = undefined; + const link = parseLink(value); - if (isQueryResultForDereferencing(value)) ref = getCellLinkOrThrow(value); - else if (isCellLink(value)) ref = value; - else if (isCell(value)) ref = value.getAsCellLink(); - else if (isDoc(value)) ref = { cell: value, path: [] }; + if (!link || !link.id) return undefined; - if (!ref?.cell.entityId) return undefined; + const entityId = { "/": fromURI(link.id) }; - if (ref.path.length > 0) { + if (link.path && link.path.length > 0) { return JSON.parse( - JSON.stringify(createRef({ path: ref.path }, ref.cell.entityId)), + JSON.stringify(createRef({ path: link.path }, entityId)), ); - } else return JSON.parse(JSON.stringify(ref.cell.entityId)); + } else return entityId; } /** @@ -173,23 +174,31 @@ export class DocumentMap implements IDocumentMap { constructor(readonly runtime: IRuntime) {} + getDocByEntityId( + space: MemorySpace, + entityId: EntityId | string, + createIfNotFound?: true, + sourceIfCreated?: DocImpl, + ): DocImpl; + getDocByEntityId( + space: MemorySpace, + entityId: EntityId | string, + createIfNotFound: false, + sourceIfCreated?: DocImpl, + ): DocImpl | undefined; getDocByEntityId( space: MemorySpace, entityId: EntityId | string, createIfNotFound = true, sourceIfCreated?: DocImpl, ): DocImpl | undefined { - const id = typeof entityId === "string" - ? entityId - : JSON.stringify(entityId); - let doc = this.entityIdToDocMap.get(space, id); + const normalizedId = normalizeEntityId(entityId); + + let doc = this.entityIdToDocMap.get(space, JSON.stringify(normalizedId)); if (doc) return doc; if (!createIfNotFound) return undefined; - if (typeof entityId === "string") { - entityId = JSON.parse(entityId) as EntityId; - } - doc = createDoc(undefined as T, entityId, space, this.runtime); + doc = this.createDoc(undefined as T, normalizedId, space); doc.sourceCell = sourceIfCreated; return doc; } @@ -244,7 +253,19 @@ export class DocumentMap implements IDocumentMap { space: MemorySpace, ): DocImpl { // Use the full createDoc implementation with runtime parameter - const doc = createDoc(value, entityId, space, this.runtime); - return doc; + return createDoc(value, entityId, space, this.runtime); + } +} + +function normalizeEntityId(entityId: EntityId | string): EntityId { + if (typeof entityId === "string") { + if (entityId.startsWith("of:")) { + return { "/": fromURI(entityId) }; + } + return JSON.parse(entityId) as EntityId; + } else if (isRecord(entityId) && "/" in entityId) { + return entityId; + } else { + throw new Error("Invalid entity ID: " + JSON.stringify(entityId)); } } diff --git a/packages/runner/src/doc.ts b/packages/runner/src/doc.ts index 49a8e67a7..6c16e9ea2 100644 --- a/packages/runner/src/doc.ts +++ b/packages/runner/src/doc.ts @@ -19,9 +19,7 @@ import type { IRuntime } from "./runtime.ts"; import { type ReactivityLog } from "./scheduler.ts"; import { type Cancel } from "./cancel.ts"; import { Labels, MemorySpace } from "./storage.ts"; -import { arrayEqual } from "./type-utils.ts"; - -// Remove the arrayEqual function since we import it now +import { arrayEqual } from "./path-utils.ts"; /** * Lowest level cell implementation. @@ -296,7 +294,9 @@ export function createDoc( let changed = false; if (path.length > 0) { - if (value === undefined) value = (typeof path[0] === "number" ? [] : {}) as T; + if (value === undefined) { + value = (typeof path[0] === "number" ? [] : {}) as T; + } changed = setValueAtPath(value, path, newValue); } else if (!deepEqual(value, newValue)) { changed = true; @@ -351,7 +351,7 @@ export function createDoc( return sourceCell; }, set sourceCell(cell: DocImpl | undefined) { - if (sourceCell && sourceCell !== cell) { + if (sourceCell && JSON.stringify(sourceCell) !== JSON.stringify(cell)) { throw new Error( `Source cell already set: ${JSON.stringify(sourceCell)} -> ${ JSON.stringify(cell) diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts index 6bd05a755..71e4330de 100644 --- a/packages/runner/src/index.ts +++ b/packages/runner/src/index.ts @@ -8,14 +8,15 @@ export type { } from "./runtime.ts"; export { raw } from "./module.ts"; export type { DocImpl } from "./doc.ts"; -export type { Cell, CellLink, Stream } from "./cell.ts"; +export type { Cell, Stream } from "./cell.ts"; +export type { LegacyCellLink, URI } from "./sigil-types.ts"; export type { EntityId } from "./doc-map.ts"; export { createRef, getEntityId } from "./doc-map.ts"; export type { QueryResult } from "./query-result-proxy.ts"; export type { Action, ErrorWithContext, ReactivityLog } from "./scheduler.ts"; export * as StorageInspector from "./storage/inspector.ts"; export { isDoc } from "./doc.ts"; -export { isCell, isCellLink, isStream } from "./cell.ts"; +export { isCell, isStream } from "./cell.ts"; export { getCellLinkOrThrow, getCellLinkOrValue, @@ -34,7 +35,16 @@ export { EngineProgramResolver, } from "./harness/index.ts"; export { addCommonIDfromObjectID } from "./data-updating.ts"; -export { followAliases, maybeGetCellLink } from "./link-resolution.ts"; +export { followWriteRedirects } from "./link-resolution.ts"; +export { + areLinksSame, + isCellLink, + isLink, + isWriteRedirectLink, + parseLink, + parseLinkOrThrow, + parseToLegacyCellLink, +} from "./link-utils.ts"; export * from "./recipe-manager.ts"; // Builder functionality (migrated from @commontools/builder package) @@ -60,7 +70,6 @@ export { recipeFromFrame, } from "./builder/recipe.ts"; export { - type Alias, AuthSchema, type Cell as BuilderCell, type Child, @@ -69,11 +78,10 @@ export { type HandlerFactory, ID, ID_FIELD, - isAlias, isModule, isOpaqueRef, isRecipe, - isStreamAlias, + isStreamValue, type JSONObject, type JSONSchema, type JSONSchemaMutable, @@ -92,8 +100,7 @@ export { schema, type SchemaContext, type SchemaWithoutCell, - type Stream as BuilderStream, - type StreamAlias, + type StreamValue, type toJSON, toOpaqueRef, TYPE, diff --git a/packages/runner/src/link-resolution.ts b/packages/runner/src/link-resolution.ts index 66ca701bb..1454188d4 100644 --- a/packages/runner/src/link-resolution.ts +++ b/packages/runner/src/link-resolution.ts @@ -1,17 +1,19 @@ -import { - type Alias, - isAlias, - type JSONSchema, -} from "./builder/types.ts"; +import { type JSONSchema } from "./builder/types.ts"; import { ContextualFlowControl } from "./cfc.ts"; import { type DocImpl, isDoc } from "./doc.ts"; -import { type Cell, type CellLink, isCell, isCellLink } from "./cell.ts"; +import { type Cell, createCell } from "./cell.ts"; +import { + type LegacyAlias, + type LegacyCellLink, + type SigilWriteRedirectLink, +} from "./sigil-types.ts"; import { type ReactivityLog } from "./scheduler.ts"; +import { arrayEqual } from "./path-utils.ts"; import { - getCellLinkOrThrow, - isQueryResultForDereferencing, -} from "./query-result-proxy.ts"; -import { arrayEqual } from "./type-utils.ts"; + isWriteRedirectLink, + parseLink, + parseToLegacyCellLink, +} from "./link-utils.ts"; /** * Track visited cell links and memoize results during path resolution @@ -19,11 +21,11 @@ import { arrayEqual } from "./type-utils.ts"; */ interface Visits { /** Tracks visited cell links to detect cycles */ - seen: CellLink[]; + seen: LegacyCellLink[]; /** Cache for resolvePath results */ - resolvePathCache: Map; + resolvePathCache: Map; /** Cache for followLinks results */ - followLinksCache: Map; + followLinksCache: Map; } /** @@ -54,25 +56,28 @@ export function resolveLinkToValue( log?: ReactivityLog, schema?: JSONSchema, rootSchema?: JSONSchema, -): CellLink { +): LegacyCellLink { const visits = createVisits(); const ref = resolvePath(doc, path, log, schema, rootSchema, visits); return followLinks(ref, log, visits); } -export function resolveLinkToAlias( +export function resolveLinkToWriteRedirect( doc: DocImpl, path: PropertyKey[], log?: ReactivityLog, schema?: JSONSchema, rootSchema?: JSONSchema, -): CellLink { +): LegacyCellLink { const visits = createVisits(); const ref = resolvePath(doc, path, log, schema, rootSchema, visits); return followLinks(ref, log, visits, true); } -export function resolveLinks(ref: CellLink, log?: ReactivityLog): CellLink { +export function resolveLinks( + ref: LegacyCellLink, + log?: ReactivityLog, +): LegacyCellLink { const visits = createVisits(); return followLinks(ref, log, visits); } @@ -84,7 +89,7 @@ function resolvePath( schema?: JSONSchema, rootSchema?: JSONSchema, visits: Visits = createVisits(), -): CellLink { // Follow aliases, doc links, etc. in path, so that we end up on the right +): LegacyCellLink { // Follow aliases, doc links, etc. in path, so that we end up on the right // doc, meaning the one that contains the value we want to access without any // redirects in between. // @@ -108,7 +113,7 @@ function resolvePath( } // Try to find a cached result for a shorter path - let startRef: CellLink = { cell: doc, path: [] }; + let startRef: LegacyCellLink = { cell: doc, path: [] }; let keys = [...path]; // Look for the longest matching prefix path in the cache @@ -166,19 +171,19 @@ function resolvePath( // log all taken links, so not the returned one, and thus nothing if the ref // already pointed to a value. export function followLinks( - ref: CellLink, + ref: LegacyCellLink, log: ReactivityLog | undefined, visits: Visits, - onlyAliases = false, -): CellLink { + onlyWriteRedirects = false, +): LegacyCellLink { // Check if we already followed these links - const cacheKey = createPathCacheKey(ref.cell, ref.path, onlyAliases); + const cacheKey = createPathCacheKey(ref.cell, ref.path, onlyWriteRedirects); const cached = visits.followLinksCache.get(cacheKey); if (cached) { return cached; } - let nextRef: CellLink | undefined; + let nextRef: LegacyCellLink | undefined; let result = ref; do { @@ -201,8 +206,18 @@ export function followLinks( const target = result.cell.getAtPath(result.path); - nextRef = !onlyAliases || isAlias(target) - ? maybeGetCellLink(target, result.cell) + nextRef = !onlyWriteRedirects || isWriteRedirectLink(target) + ? parseToLegacyCellLink( + target, + createCell( + result.cell, + [], // Use empty path to reference the document itself + undefined, + undefined, + undefined, + true, + ), + ) : undefined; if (nextRef !== undefined) { @@ -241,40 +256,36 @@ export function followLinks( return result; } -export function maybeGetCellLink( - value: unknown, - parent?: DocImpl, -): CellLink | undefined { - if (isQueryResultForDereferencing(value)) return getCellLinkOrThrow(value); - else if (isCellLink(value)) return value; - else if (isDoc(value)) return { cell: value, path: [] } satisfies CellLink; - else if (isCell(value)) return value.getAsCellLink(); - else if (isAlias(value)) { - if (!parent && !value.$alias.cell) { - throw new Error( - `Alias without cell and no parent provided: ${JSON.stringify(value)}`, - ); - } - return { cell: parent, ...value.$alias } as CellLink; - } else return undefined; -} - // Follows aliases and returns cell reference describing the last alias. // Only logs interim aliases, not the first one, and not the non-alias value. -export function followAliases( - alias: Alias, - docOrCell: DocImpl | Cell, +export function followWriteRedirects( + writeRedirect: LegacyAlias | SigilWriteRedirectLink, + base: DocImpl | Cell, log?: ReactivityLog, -): CellLink { - if (isAlias(alias)) { - const doc = isCell(docOrCell) ? docOrCell.getDoc() : docOrCell; +): LegacyCellLink { + if (isDoc(base)) base = base.asCell(); + else base = base as Cell; // Makes TS happy + + if (isWriteRedirectLink(writeRedirect)) { + const link = parseLink(writeRedirect, base); return followLinks( - { cell: doc, ...alias.$alias } as CellLink, + { + cell: base.getDoc().runtime.documentMap.getDocByEntityId( + link.space!, + link.id!, + ), + path: link.path, + space: link.space, + schema: link.schema, + rootSchema: link.rootSchema, + } as LegacyCellLink, log, createVisits(), true, ); } else { - throw new Error(`Alias expected: ${JSON.stringify(alias)}`); + throw new Error( + `Write redirect expected: ${JSON.stringify(writeRedirect)}`, + ); } } diff --git a/packages/runner/src/link-utils.ts b/packages/runner/src/link-utils.ts new file mode 100644 index 000000000..16fc399b4 --- /dev/null +++ b/packages/runner/src/link-utils.ts @@ -0,0 +1,467 @@ +import { isObject, isRecord } from "@commontools/utils/types"; +import { type JSONSchema } from "./builder/types.ts"; +import { type DocImpl, isDoc } from "./doc.ts"; +import { type Cell, isCell, type MemorySpace } from "./cell.ts"; +import { + type JSONCellLink, + type LegacyAlias, + type LegacyCellLink, + LINK_V1_TAG, + type SigilLink, + type SigilValue, + type SigilWriteRedirectLink, + type URI, +} from "./sigil-types.ts"; +import { toURI } from "./uri-utils.ts"; +import { arrayEqual } from "./path-utils.ts"; +import { + getCellLinkOrThrow, + isQueryResultForDereferencing, + QueryResultInternals, +} from "./query-result-proxy.ts"; + +/** + * Normalized link structure returned by parsers + */ +export type NormalizedLink = { + id?: URI; // URI format with "of:" prefix + path: string[]; + space?: MemorySpace; + schema?: JSONSchema; + rootSchema?: JSONSchema; + overwrite?: "redirect"; // "this" gets normalized away to undefined +}; + +/** + * Normalized link with required id and space (when base Cell is provided) + */ +export type NormalizedFullLink = { + id: URI; // URI format with "of:" prefix + path: string[]; + space: MemorySpace; + schema?: JSONSchema; + rootSchema?: JSONSchema; + overwrite?: "redirect"; // "this" gets normalized away to undefined +}; + +export type CellLink = + | Cell + | DocImpl + | LegacyCellLink + | SigilLink + | JSONCellLink + | LegacyAlias + | QueryResultInternals + | { "/": string }; + +/** + * Check if value is a sigil value with any type + * + * Any object that is strictly `{ "/": Record }`, no other props + */ +export function isSigilValue(value: any): value is SigilValue { + return isRecord(value) && + "/" in value && + Object.keys(value).length === 1 && + isObject(value["/"]); +} + +/** + * Check if value is a legacy cell link. + * + * @param {any} value - The value to check. + * @returns {boolean} + */ +export function isCellLink(value: any): value is LegacyCellLink | JSONCellLink { + return isLegacyCellLink(value) || isJSONCellLink(value); +} + +/** + * Check if value is a legacy cell link. + * + * @param {any} value - The value to check. + * @returns {boolean} + */ +export function isLegacyCellLink(value: any): value is LegacyCellLink { + return ( + isRecord(value) && isDoc(value.cell) && Array.isArray(value.path) + ); +} + +/** + * Check if value is a JSON cell link (storage format). + */ +export function isJSONCellLink(value: any): value is JSONCellLink { + return ( + isRecord(value) && + isRecord(value.cell) && + typeof value.cell["/"] === "string" && + Array.isArray(value.path) + ); +} + +/** + * Check if value is a sigil link. + */ +export function isSigilLink(value: any): value is SigilLink { + return (isSigilValue(value) && LINK_V1_TAG in value["/"]); +} + +/** + * Check if value is a sigil alias (link with overwrite field). + */ +export function isSigilWriteRedirectLink( + value: any, +): value is SigilWriteRedirectLink { + return isSigilLink(value) && + value["/"][LINK_V1_TAG].overwrite === "redirect"; +} + +/** + * Check if value is any kind of cell link format. + */ +export function isAnyCellLink( + value: any, +): value is LegacyCellLink | SigilLink | JSONCellLink | LegacyAlias { + return isCellLink(value) || isJSONCellLink(value) || isSigilLink(value) || + isLegacyAlias(value); +} + +/** + * Check if value is any kind of link or linkable entity + */ +export function isLink( + value: any, +): value is CellLink { + return ( + isQueryResultForDereferencing(value) || + isAnyCellLink(value) || + isCell(value) || + isDoc(value) || + (isRecord(value) && "/" in value && typeof value["/"] === "string") // EntityId format + ); +} + +/** + * Check if value is an alias in any format (old $alias or new sigil) + */ +export function isWriteRedirectLink( + value: any, +): value is LegacyAlias | SigilWriteRedirectLink { + // Check legacy $alias format + if (isLegacyAlias(value)) { + return true; + } + + // Check new sigil format (link@1 with overwrite field) + if (isSigilLink(value)) { + return value["/"][LINK_V1_TAG].overwrite === "redirect"; + } + + return false; +} + +export function isLegacyAlias(value: any): value is LegacyAlias { + return isRecord(value) && "$alias" in value && isRecord(value.$alias) && + Array.isArray(value.$alias.path); +} + +/** + * Parse any link-like value to normalized format + * + * Overloads just help make fields non-optional that can be guaranteed to exist + * in various combinations. + */ +export function parseLink( + value: + | Cell + | DocImpl, + base?: Cell | NormalizedLink, +): NormalizedFullLink; +export function parseLink( + value: CellLink, + base: Cell | NormalizedFullLink, +): NormalizedFullLink; +export function parseLink( + value: CellLink, + base?: Cell | NormalizedLink, +): NormalizedLink; +export function parseLink( + value: any, + base?: Cell | NormalizedLink, +): NormalizedLink | undefined; +export function parseLink( + value: any, + base?: Cell | NormalizedLink, +): NormalizedLink | undefined { + // Has to be first, since below we check for "/" in value and we don't want to + // see userland "/". + if (isQueryResultForDereferencing(value)) value = getCellLinkOrThrow(value); + + if (isCell(value)) { + return { + id: toURI(value.getDoc().entityId), + path: value.path.map((p) => p.toString()), + space: value.space, + schema: value.schema, + rootSchema: value.rootSchema, + }; + } + + if (isDoc(value)) { + // Extract from DocImpl + return { + id: toURI(value.entityId), + path: [], + space: value.space, + }; + } + + // Handle new sigil format + if (isSigilLink(value)) { + const link = value["/"][LINK_V1_TAG]; + + // Resolve relative references + let id = link.id; + const path = link.path || []; + const resolvedSpace = link.space || base?.space; + + // If no id provided, use base cell's document + if (!id && base) { + id = isCell(base) ? toURI(base.getDoc().entityId) : base.id; + } + + return { + id: id, + path: path.map((p) => p.toString()), + space: resolvedSpace, + schema: link.schema, + rootSchema: link.rootSchema, + overwrite: link.overwrite === "redirect" ? "redirect" : undefined, + }; + } + + // Handle legacy CellLink format (runtime format with DocImpl) + if (isLegacyCellLink(value)) { + return { + id: toURI(value.cell.entityId), + path: value.path.map((p) => p.toString()), + space: value.cell.space, + schema: value.schema, + rootSchema: value.rootSchema, + }; + } + + // Handle JSON CellLink format (storage format with { "/": string }) + if (isJSONCellLink(value)) { + return { + id: toURI(value.cell["/"]), + path: value.path.map((p) => p.toString()), + space: base?.space, // Space must come from context for JSON links + }; + } + + if (isRecord(value) && "/" in value) { + return { + id: toURI(value["/"]), + path: [], + space: base?.space, // Space must come from context for JSON links + }; + } + + // Handle legacy alias format + if (isLegacyAlias(value)) { + const alias = value.$alias; + let id: URI | undefined; + let resolvedSpace = base?.space; + + // If cell is provided, convert to URI + if (alias.cell) { + if (isDoc(alias.cell)) { + id = toURI(alias.cell.entityId); + resolvedSpace = alias.cell.space; + } else if (isRecord(alias.cell) && "/" in alias.cell) { + id = toURI(alias.cell); + } + } + + // If no cell provided, use base cell's document + if (!id && base) { + id = isCell(base) ? toURI(base.getDoc().entityId) : base.id; + } + + return { + id: id, + path: Array.isArray(alias.path) + ? alias.path.map((p) => p.toString()) + : [], + space: resolvedSpace, + schema: alias.schema as JSONSchema | undefined, + rootSchema: alias.rootSchema as JSONSchema | undefined, + }; + } + + return undefined; +} + +/** + * Parse any link-like value to normalized format, throwing on failure + */ +export function parseLinkOrThrow( + value: any, + baseCell?: Cell, +): NormalizedLink { + const result = parseLink(value, baseCell); + if (!result) { + throw new Error(`Cannot parse value as link: ${JSON.stringify(value)}`); + } + return result; +} + +/** + * Parse a link to a legacy CellLink format + * + * @param value - The value to parse + * @param baseCell - The base cell to use for resolving relative references + * @returns The parsed cell link, or undefined if the value cannot be parsed + */ +export function parseToLegacyCellLink( + value: CellLink, + baseCell?: Cell, +): LegacyCellLink; +export function parseToLegacyCellLink( + value: any, + baseCell?: Cell, +): LegacyCellLink | undefined; +export function parseToLegacyCellLink( + value: any, + baseCell?: Cell, +): LegacyCellLink | undefined { + const partial = parseToLegacyCellLinkWithMaybeACell(value, baseCell); + if (!partial) return undefined; + if (!isDoc(partial.cell)) throw new Error("No id or base cell provided"); + return partial as LegacyCellLink; +} + +/** + * Parse a link to a legacy Alias format + * + * @param value - The value to parse + * @param baseCell - The base cell to use for resolving relative references + * @returns The parsed alias, or undefined if the value cannot be parsed + */ +export function parseToLegacyAlias( + value: CellLink, +): LegacyAlias; +export function parseToLegacyAlias(value: any): LegacyAlias | undefined; +export function parseToLegacyAlias(value: any): LegacyAlias | undefined { + const partial = parseToLegacyCellLinkWithMaybeACell(value); + if (!partial) return undefined; + return { $alias: partial } as LegacyAlias; +} + +function parseToLegacyCellLinkWithMaybeACell( + value: any, + baseCell?: Cell, +): Partial | undefined { + // Has to be first, since below we check for "/" in value and we don't want to + // see userland "/". + if (isQueryResultForDereferencing(value)) value = getCellLinkOrThrow(value); + + // parseLink "forgets" the legacy docs, so we for now parse it here as well. + // This is in case no baseCell was provided. + const doc = isDoc(value) + ? value + : isCell(value) + ? value.getDoc() + : (isRecord(value) && isDoc((value as any).cell)) + ? (value as any).cell + : (isRecord(value) && (value as any).$alias && + isDoc((value as any).$alias.cell)) + ? (value as any).$alias.cell + : undefined; + + const link = parseLink(value, baseCell); + if (!link) return undefined; + + const cellValue = doc ?? + (link.id && baseCell + ? baseCell.getDoc().runtime!.documentMap.getDocByEntityId( + link.space ?? baseCell!.space!, + link.id!, + true, + ) + : undefined); + + return { + cell: cellValue, + path: link.path ?? [], + space: link.space, + schema: link.schema, + rootSchema: link.rootSchema, + } satisfies Partial; +} + +/** + * Compare two link values for equality, supporting all link formats + */ +export function areLinksSame( + value1: any, + value2: any, + base?: Cell | NormalizedLink, +): boolean { + // If both are the same object, they're equal + if (value1 === value2) return true; + + // If either is null/undefined, they're only equal if both are + if (!value1 || !value2) return value1 === value2; + + // Try parsing both as links + const link1 = parseLink(value1, base); + const link2 = parseLink(value2, base); + + // If one parses and the other doesn't, they're not equal + if (!link1 || !link2) return false; + + // Compare normalized links + return ( + link1.id === link2.id && + link1.space === link2.space && + arrayEqual(link1.path, link2.path) + ); +} + +export function createSigilLinkFromParsedLink( + link: NormalizedLink, + base?: Cell | NormalizedLink, +): SigilLink { + const sigilLink: SigilLink = { + "/": { + [LINK_V1_TAG]: { + path: link.path, + schema: link.schema, + rootSchema: link.rootSchema, + }, + }, + }; + + // Only add space if different from base + if (link.space !== base?.space) { + sigilLink["/"][LINK_V1_TAG].space = link.space; + } + + // Only add id if different from base + const baseId = base + ? (isCell(base) ? toURI(base.getDoc().entityId) : base.id) + : undefined; + if (link.id !== baseId) { + sigilLink["/"][LINK_V1_TAG].id = link.id; + } + + // Only add overwrite if it's a redirect + if (link.overwrite === "redirect") { + sigilLink["/"][LINK_V1_TAG].overwrite = link.overwrite; + } + + return sigilLink; +} diff --git a/packages/runner/src/path-utils.ts b/packages/runner/src/path-utils.ts index 3215cdb27..9662547a3 100644 --- a/packages/runner/src/path-utils.ts +++ b/packages/runner/src/path-utils.ts @@ -64,3 +64,10 @@ export const deepEqual = (a: any, b: any): boolean => { } return a !== a && b !== b; // NaN check }; + +export function arrayEqual(a?: PropertyKey[], b?: PropertyKey[]): boolean { + if (!a || !b) return a === b; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} diff --git a/packages/runner/src/query-result-proxy.ts b/packages/runner/src/query-result-proxy.ts index d25d279b1..af3b4c2d7 100644 --- a/packages/runner/src/query-result-proxy.ts +++ b/packages/runner/src/query-result-proxy.ts @@ -2,7 +2,7 @@ import { isRecord } from "@commontools/utils/types"; import { getTopFrame } from "./builder/recipe.ts"; import { toOpaqueRef } from "./builder/types.ts"; import { type DocImpl, makeOpaqueRef } from "./doc.ts"; -import { type CellLink } from "./cell.ts"; +import { type LegacyCellLink } from "./sigil-types.ts"; import { type ReactivityLog } from "./scheduler.ts"; import { diffAndUpdate, setNestedValue } from "./data-updating.ts"; import { resolveLinkToValue } from "./link-resolution.ts"; @@ -97,7 +97,7 @@ export function createQueryResultProxy( get: (target, prop, receiver) => { if (typeof prop === "symbol") { if (prop === getCellLink) { - return { cell: valueCell, path: valuePath } satisfies CellLink; + return { cell: valueCell, path: valuePath } satisfies LegacyCellLink; } else if (prop === toOpaqueRef) { return () => makeOpaqueRef(valueCell, valuePath); } @@ -302,9 +302,9 @@ function isProxyForArrayValue(value: any): value is ProxyForArrayValue { * Get cell link or return values as is if not a cell value proxy. * * @param {any} value - The value to get the cell link or value from. - * @returns {CellLink | any} + * @returns {LegacyCellLink | any} */ -export function getCellLinkOrValue(value: any): CellLink { +export function getCellLinkOrValue(value: any): LegacyCellLink { if (isQueryResult(value)) return value[getCellLink]; else return value; } @@ -313,10 +313,10 @@ export function getCellLinkOrValue(value: any): CellLink { * Get cell link or throw if not a cell value proxy. * * @param {any} value - The value to get the cell link from. - * @returns {CellLink} + * @returns {LegacyCellLink} * @throws {Error} If the value is not a cell value proxy. */ -export function getCellLinkOrThrow(value: any): CellLink { +export function getCellLinkOrThrow(value: any): LegacyCellLink { if (isQueryResult(value)) return value[getCellLink]; else throw new Error("Value is not a cell proxy"); } @@ -347,7 +347,7 @@ export function isQueryResultForDereferencing( } export type QueryResultInternals = { - [getCellLink]: CellLink; + [getCellLink]: LegacyCellLink; }; export type QueryResult = T & QueryResultInternals; diff --git a/packages/runner/src/recipe-binding.ts b/packages/runner/src/recipe-binding.ts index 1dc23e6a0..44688d1d0 100644 --- a/packages/runner/src/recipe-binding.ts +++ b/packages/runner/src/recipe-binding.ts @@ -1,17 +1,17 @@ import { isRecord } from "@commontools/utils/types"; import { - type Alias, - isAlias, type Recipe, unsafe_materializeFactory, unsafe_originalRecipe, unsafe_parentRecipe, type UnsafeBinding, } from "./builder/types.ts"; +import { isLegacyAlias, isLink } from "./link-utils.ts"; import { type DocImpl, isDoc } from "./doc.ts"; -import { type Cell, type CellLink, isCell, isCellLink } from "./cell.ts"; +import { type Cell, isCell } from "./cell.ts"; +import { type LegacyCellLink } from "./sigil-types.ts"; import { type ReactivityLog } from "./scheduler.ts"; -import { followAliases } from "./link-resolution.ts"; +import { followWriteRedirects } from "./link-resolution.ts"; import { diffAndUpdate } from "./data-updating.ts"; /** @@ -32,8 +32,8 @@ export function sendValueToBinding( log?: ReactivityLog, ): void { const doc = isCell(docOrCell) ? docOrCell.getDoc() : docOrCell; - if (isAlias(binding)) { - const ref = followAliases(binding, doc, log); + if (isLegacyAlias(binding)) { + const ref = followWriteRedirects(binding, doc, log); diffAndUpdate(ref, value, log, { doc, binding }); } else if (Array.isArray(binding)) { if (Array.isArray(value)) { @@ -85,7 +85,7 @@ export function unwrapOneLevelAndBindtoDoc( ): T { const doc = isCell(docOrCell) ? docOrCell.getDoc() : docOrCell; function convert(binding: unknown): unknown { - if (isAlias(binding)) { + if (isLegacyAlias(binding)) { const alias = { ...binding.$alias }; if (typeof alias.cell === "number") { if (alias.cell === 1) { @@ -144,14 +144,15 @@ export function unsafe_createParentBindings( } } -// Traverses binding and returns all docs reacheable through aliases. -export function findAllAliasedCells( +// Traverses binding and returns all docs reacheable through legacy aliases. +// TODO(seefeld): Transition to all write redirects once recipes use those. +export function findAllLegacyAliasedCells( binding: unknown, doc: DocImpl, -): CellLink[] { - const docs: CellLink[] = []; +): LegacyCellLink[] { + const docs: LegacyCellLink[] = []; function find(binding: unknown, origDoc: DocImpl): void { - if (isAlias(binding)) { + if (isLegacyAlias(binding)) { // Numbered docs are yet to be unwrapped nested recipes. Ignore them. if (typeof binding.$alias.cell === "number") return; const doc = (binding.$alias.cell ?? origDoc) as DocImpl; @@ -161,13 +162,7 @@ export function findAllAliasedCells( find(doc.getAtPath(path), doc); } else if (Array.isArray(binding)) { for (const value of binding) find(value, origDoc); - } else if ( - typeof binding === "object" && - binding !== null && - !isCellLink(binding) && - !isDoc(binding) && - !isCell(binding) - ) { + } else if (isRecord(binding) && !isLink(binding)) { for (const value of Object.values(binding)) find(value, origDoc); } } diff --git a/packages/runner/src/runner.ts b/packages/runner/src/runner.ts index 827b144bd..fa8847dca 100644 --- a/packages/runner/src/runner.ts +++ b/packages/runner/src/runner.ts @@ -1,10 +1,9 @@ -import { isObject, isRecord } from "@commontools/utils/types"; +import { isObject, isRecord, type Mutable } from "@commontools/utils/types"; import { - type Alias, - isAlias, isModule, + isOpaqueRef, isRecipe, - isStreamAlias, + isStreamValue, type JSONSchema, type JSONValue, type Module, @@ -23,20 +22,25 @@ import { import { type DocImpl, isDoc } from "./doc.ts"; import { type Cell } from "./cell.ts"; import { type Action, type ReactivityLog } from "./scheduler.ts"; -import { containsOpaqueRef, deepCopy } from "./type-utils.ts"; import { diffAndUpdate } from "./data-updating.ts"; import { - findAllAliasedCells, + findAllLegacyAliasedCells, unsafe_noteParentOnRecipes, unwrapOneLevelAndBindtoDoc, } from "./recipe-binding.ts"; -import { followAliases, maybeGetCellLink } from "./link-resolution.ts"; +import { followWriteRedirects } from "./link-resolution.ts"; +import { + areLinksSame, + isLink, + isWriteRedirectLink, + parseLink, + parseToLegacyAlias, +} from "./link-utils.ts"; import { sendValueToBinding } from "./recipe-binding.ts"; import { type AddCancel, type Cancel, useCancelGroup } from "./cancel.ts"; import "./builtins/index.ts"; -import { type CellLink, isCell, isCellLink } from "./cell.ts"; -import { isQueryResultForDereferencing } from "./query-result-proxy.ts"; -import { getCellLinkOrThrow } from "./query-result-proxy.ts"; +import { isCell } from "./cell.ts"; +import { type LegacyCellLink } from "./sigil-types.ts"; import type { IRunner, IRuntime } from "./runtime.ts"; export class Runner implements IRunner { @@ -177,21 +181,8 @@ export class Runner implements IRunner { this.allCancels.add(cancel); // If the bindings are a cell, doc or doc link, convert them to an alias - if ( - isDoc(argument) || - isCellLink(argument) || - isCell(argument) || - isQueryResultForDereferencing(argument) - ) { - const ref = isCellLink(argument) - ? argument - : isCell(argument) - ? argument.getAsCellLink() - : isQueryResultForDereferencing(argument) - ? getCellLinkOrThrow(argument) - : ({ cell: argument, path: [] } satisfies CellLink); - - argument = { $alias: ref } as T; + if (isLink(argument)) { + argument = parseToLegacyAlias(argument) as T; } // Walk the recipe's schema and extract all default values @@ -201,8 +192,10 @@ export class Runner implements IRunner { const previousInternal = processCell.get()?.internal; const internal: JSONValue = Object.assign( {}, - deepCopy((defaults as unknown as { internal: JSONValue })?.internal), - deepCopy( + cellAwareDeepCopy( + (defaults as unknown as { internal: JSONValue })?.internal, + ), + cellAwareDeepCopy( isRecord(recipe.initial) && isRecord(recipe.initial.internal) ? recipe.initial.internal : {}, @@ -305,10 +298,12 @@ export class Runner implements IRunner { if (seen.has(value)) return; seen.add(value); - const link = maybeGetCellLink(value); + const link = parseLink(value, resultCell); - if (link && link.cell) { - const maybePromise = this.runtime.storage.syncCell(link.cell); + if (link) { + const maybePromise = this.runtime.storage.syncCell( + this.runtime.getCellFromLink(link), + ); if (maybePromise instanceof Promise) promises.add(maybePromise); } else if (isRecord(value)) { for (const key in value) syncAllMentionedCells(value[key]); @@ -338,8 +333,8 @@ export class Runner implements IRunner { for (const node of recipe.nodes) { const sourceDoc = sourceCell.getDoc(); - const inputs = findAllAliasedCells(node.inputs, sourceDoc); - const outputs = findAllAliasedCells(node.outputs, sourceDoc); + const inputs = findAllLegacyAliasedCells(node.inputs, sourceDoc); + const outputs = findAllLegacyAliasedCells(node.outputs, sourceDoc); // TODO(seefeld): This ignores schemas provided by modules, so it might // still fetch a lot. @@ -389,7 +384,7 @@ export class Runner implements IRunner { } private instantiateNode( - module: Module | Alias, + module: Module, inputBindings: JSONValue, outputBindings: JSONValue, processCell: DocImpl, @@ -453,7 +448,7 @@ export class Runner implements IRunner { default: throw new Error(`Unknown module type: ${module.type}`); } - } else if (isAlias(module)) { + } else if (isWriteRedirectLink(module)) { // TODO(seefeld): Implement, a dynamic node } else { throw new Error(`Unknown module: ${JSON.stringify(module)}`); @@ -473,10 +468,10 @@ export class Runner implements IRunner { processCell, ); - const reads = findAllAliasedCells(inputs, processCell); + const reads = findAllLegacyAliasedCells(inputs, processCell); const outputs = unwrapOneLevelAndBindtoDoc(outputBindings, processCell); - const writes = findAllAliasedCells(outputs, processCell); + const writes = findAllLegacyAliasedCells(outputs, processCell); let fn = ( typeof module.implementation === "string" @@ -489,20 +484,20 @@ export class Runner implements IRunner { } // Check if any of the read cells is a stream alias - let streamRef: CellLink | undefined = undefined; + let streamRef: LegacyCellLink | undefined = undefined; if (isRecord(inputs)) { for (const key in inputs) { let doc = processCell; let path: PropertyKey[] = [key]; let value = inputs[key]; - while (isAlias(value)) { - const ref = followAliases(value, processCell); + while (isWriteRedirectLink(value)) { + const ref = followWriteRedirects(value, processCell); doc = ref.cell; path = ref.path; value = doc.getAtPath(path); } - if (isStreamAlias(value)) { - streamRef = { cell: doc, path }; + if (isStreamValue(value)) { + streamRef = { cell: doc, path } satisfies LegacyCellLink; break; } } @@ -517,17 +512,20 @@ export class Runner implements IRunner { const eventInputs = { ...(inputs as Record) }; const cause = { ...(inputs as Record) }; for (const key in eventInputs) { - if ( - isAlias(eventInputs[key]) && - eventInputs[key].$alias.cell === stream.cell && - eventInputs[key].$alias.path.length === stream.path.length && - eventInputs[key].$alias.path.every( - (value: PropertyKey, index: number) => - value === stream.path[index], - ) - ) { - eventInputs[key] = event; - cause[key] = crypto.randomUUID(); + if (isWriteRedirectLink(eventInputs[key])) { + // Use format-agnostic comparison for aliases + const alias = eventInputs[key]; + + if ( + areLinksSame( + alias, + streamRef, + processCell.asCell(), + ) + ) { + eventInputs[key] = event; + cause[key] = crypto.randomUUID(); + } } } @@ -695,8 +693,14 @@ export class Runner implements IRunner { // note the parent recipe on the closure recipes. unsafe_noteParentOnRecipes(recipe, mappedInputBindings); - const inputCells = findAllAliasedCells(mappedInputBindings, processCell); - const outputCells = findAllAliasedCells(mappedOutputBindings, processCell); + const inputCells = findAllLegacyAliasedCells( + mappedInputBindings, + processCell, + ); + const outputCells = findAllLegacyAliasedCells( + mappedOutputBindings, + processCell, + ); const action = module.implementation( processCell.runtime!.documentMap.getDoc( @@ -732,10 +736,10 @@ export class Runner implements IRunner { const inputsCell = processCell.runtime!.documentMap.getDoc(inputs, { immutable: inputs, }, processCell.space); - const reads = findAllAliasedCells(inputs, processCell); + const reads = findAllLegacyAliasedCells(inputs, processCell); const outputs = unwrapOneLevelAndBindtoDoc(outputBindings, processCell); - const writes = findAllAliasedCells(outputs, processCell); + const writes = findAllLegacyAliasedCells(outputs, processCell); const action: Action = (log: ReactivityLog) => { const inputsProxy = inputsCell.getAsQueryResult([], log); @@ -786,6 +790,29 @@ export class Runner implements IRunner { } } +function containsOpaqueRef(value: unknown): boolean { + if (isOpaqueRef(value)) return true; + if (isLink(value)) return false; + if (isRecord(value)) { + return Object.values(value).some(containsOpaqueRef); + } + return false; +} + +export function cellAwareDeepCopy(value: T): Mutable { + if (isLink(value)) return value as Mutable; + if (isRecord(value)) { + return Array.isArray(value) + ? value.map(cellAwareDeepCopy) as unknown as Mutable + : Object.fromEntries( + Object.entries(value).map(( + [key, value], + ) => [key, cellAwareDeepCopy(value)]), + ) as unknown as Mutable; + // Literal value: + } else return value as Mutable; +} + /** * Extracts default values from a JSON schema object. * @param schema - The JSON schema to extract defaults from @@ -801,7 +828,9 @@ export function extractDefaultValues( ) { // Ignore the schema.default if it's not an object, since it's not a valid // default value for an object. - const obj = deepCopy(isRecord(schema.default) ? schema.default : {}); + const obj = cellAwareDeepCopy( + isRecord(schema.default) ? schema.default : {}, + ); for (const [propKey, propSchema] of Object.entries(schema.properties)) { const value = extractDefaultValues(propSchema); if (value !== undefined) { @@ -836,15 +865,7 @@ export function mergeObjects( // If we have a literal value, return it. Same for arrays, since we wouldn't // know how to merge them. Note that earlier objects take precedence, so if // an earlier was e.g. an object, we'll return that instead of the literal. - if ( - typeof obj !== "object" || - obj === null || - Array.isArray(obj) || - isAlias(obj) || - isCellLink(obj) || - isDoc(obj) || - isCell(obj) - ) { + if (!isObject(obj) || isLink(obj)) { return obj as T; } diff --git a/packages/runner/src/runtime.ts b/packages/runner/src/runtime.ts index d895e140a..050893b50 100644 --- a/packages/runner/src/runtime.ts +++ b/packages/runner/src/runtime.ts @@ -8,15 +8,13 @@ import type { import type { RecipeEnvironment } from "./builder/env.ts"; import { ContextualFlowControl } from "./cfc.ts"; import { setRecipeEnvironment } from "./builder/env.ts"; - import type { IStorageManager, IStorageProvider, MemorySpace, } from "./storage/interface.ts"; - -export type { IStorageManager, IStorageProvider, MemorySpace }; -import type { Cell, CellLink } from "./cell.ts"; +import { type Cell } from "./cell.ts"; +import { type JSONCellLink, type LegacyCellLink } from "./sigil-types.ts"; import type { DocImpl } from "./doc.ts"; import { isDoc } from "./doc.ts"; import { type EntityId, getEntityId } from "./doc-map.ts"; @@ -25,6 +23,9 @@ import type { Action, EventHandler, ReactivityLog } from "./scheduler.ts"; import type { Harness } from "./harness/harness.ts"; import { Engine } from "./harness/index.ts"; import { ConsoleMethod } from "./harness/console.ts"; +import { isCellLink, type NormalizedLink } from "./link-utils.ts"; + +export type { IStorageManager, IStorageProvider, MemorySpace }; export type ErrorWithContext = Error & { action: Action; @@ -99,12 +100,12 @@ export interface IRuntime { log?: ReactivityLog, ): Cell>; getCellFromLink( - cellLink: CellLink, + cellLink: LegacyCellLink | NormalizedLink, schema?: JSONSchema, log?: ReactivityLog, ): Cell; getCellFromLink( - cellLink: CellLink, + cellLink: LegacyCellLink | NormalizedLink, schema: S, log?: ReactivityLog, ): Cell>; @@ -158,8 +159,8 @@ export interface IScheduler { unschedule(action: Action): void; onConsole(fn: ConsoleHandler): void; onError(fn: ErrorHandler): void; - queueEvent(eventRef: CellLink, event: any): void; - addEventHandler(handler: EventHandler, ref: CellLink): Cancel; + queueEvent(eventRef: LegacyCellLink, event: any): void; + addEventHandler(handler: EventHandler, ref: LegacyCellLink): Cancel; runningPromise: Promise | undefined; } @@ -203,7 +204,13 @@ export interface IDocumentMap { getDocByEntityId( space: MemorySpace, entityId: EntityId | string, - createIfNotFound?: boolean, + createIfNotFound?: true, + sourceIfCreated?: DocImpl, + ): DocImpl; + getDocByEntityId( + space: MemorySpace, + entityId: EntityId | string, + createIfNotFound: false, sourceIfCreated?: DocImpl, ): DocImpl | undefined; registerDoc(entityId: EntityId, doc: DocImpl, space: MemorySpace): void; @@ -420,38 +427,52 @@ export class Runtime implements IRuntime { } getCellFromLink( - cellLink: CellLink, + cellLink: LegacyCellLink | JSONCellLink | NormalizedLink, schema?: JSONSchema, log?: ReactivityLog, ): Cell; getCellFromLink( - cellLink: CellLink, + cellLink: LegacyCellLink | JSONCellLink | NormalizedLink, schema: S, log?: ReactivityLog, ): Cell>; getCellFromLink( - cellLink: CellLink, + cellLink: LegacyCellLink | NormalizedLink, schema?: JSONSchema, log?: ReactivityLog, ): Cell { let doc; - if (isDoc(cellLink.cell)) { - doc = cellLink.cell; - } else if (cellLink.space) { + if (isCellLink(cellLink)) { + if (isDoc(cellLink.cell)) { + doc = cellLink.cell; + } else if (cellLink.space) { + doc = this.documentMap.getDocByEntityId( + cellLink.space as MemorySpace, + getEntityId(cellLink.cell)!, + true, + )!; + if (!doc) { + throw new Error(`Can't find ${cellLink.space}/${cellLink.cell}!`); + } + } else { + throw new Error("Cell link has no space"); + } + } else { doc = this.documentMap.getDocByEntityId( cellLink.space as MemorySpace, - getEntityId(cellLink.cell)!, + getEntityId((cellLink as NormalizedLink).id)!, true, )!; - if (!doc) { - throw new Error(`Can't find ${cellLink.space}/${cellLink.cell}!`); - } - } else { - throw new Error("Cell link has no space"); } + // If we aren't passed a schema, use the one in the cellLink - return doc.asCell(cellLink.path, log, schema ?? cellLink.schema); + return doc.asCell( + cellLink.path, + log, + schema ?? cellLink.schema, + schema ? undefined : cellLink.rootSchema, + ); } getImmutableCell( diff --git a/packages/runner/src/scheduler.ts b/packages/runner/src/scheduler.ts index b3cb5d603..345f9243e 100644 --- a/packages/runner/src/scheduler.ts +++ b/packages/runner/src/scheduler.ts @@ -2,7 +2,7 @@ import { getTopFrame } from "./builder/recipe.ts"; import { TYPE } from "./builder/types.ts"; import type { DocImpl } from "./doc.ts"; import type { Cancel } from "./cancel.ts"; -import { type CellLink } from "./cell.ts"; +import { type LegacyCellLink } from "./sigil-types.ts"; import { getCellLinkOrThrow, isQueryResultForDereferencing, @@ -30,8 +30,8 @@ export type EventHandler = (event: any) => any; * dependencies and to topologically sort pending actions before executing them. */ export type ReactivityLog = { - reads: CellLink[]; - writes: CellLink[]; + reads: LegacyCellLink[]; + writes: LegacyCellLink[]; }; const MAX_ITERATIONS_PER_RUN = 100; @@ -39,7 +39,7 @@ const MAX_ITERATIONS_PER_RUN = 100; export class Scheduler implements IScheduler { private pending = new Set(); private eventQueue: (() => void)[] = []; - private eventHandlers: [CellLink, EventHandler][] = []; + private eventHandlers: [LegacyCellLink, EventHandler][] = []; private dirty = new Set>(); private dependencies = new WeakMap(); private cancels = new WeakMap(); @@ -179,7 +179,7 @@ export class Scheduler implements IScheduler { }); } - queueEvent(eventRef: CellLink, event: any): void { + queueEvent(eventRef: LegacyCellLink, event: any): void { for (const [ref, handler] of this.eventHandlers) { if ( ref.cell === eventRef.cell && @@ -192,7 +192,7 @@ export class Scheduler implements IScheduler { } } - addEventHandler(handler: EventHandler, ref: CellLink): Cancel { + addEventHandler(handler: EventHandler, ref: LegacyCellLink): Cancel { this.eventHandlers.push([ref, handler]); return () => { const index = this.eventHandlers.findIndex(([r, h]) => @@ -216,7 +216,10 @@ export class Scheduler implements IScheduler { this.scheduled = true; } - private setDependencies(action: Action, log: ReactivityLog): CellLink[] { + private setDependencies( + action: Action, + log: ReactivityLog, + ): LegacyCellLink[] { const reads = compactifyPaths(log.reads); const writes = compactifyPaths(log.writes); this.dependencies.set(action, { reads, writes }); @@ -423,7 +426,7 @@ function topologicalSort( } // Remove longer paths already covered by shorter paths -export function compactifyPaths(entries: CellLink[]): CellLink[] { +export function compactifyPaths(entries: LegacyCellLink[]): LegacyCellLink[] { // First group by doc via a Map const docToPaths = new Map, PropertyKey[][]>(); for (const { cell: doc, path } of entries) { @@ -434,7 +437,7 @@ export function compactifyPaths(entries: CellLink[]): CellLink[] { // For each cell, sort the paths by length, then only return those that don't // have a prefix earlier in the list - const result: CellLink[] = []; + const result: LegacyCellLink[] = []; for (const [doc, paths] of docToPaths.entries()) { paths.sort((a, b) => a.length - b.length); for (let i = 0; i < paths.length; i++) { diff --git a/packages/runner/src/schema.ts b/packages/runner/src/schema.ts index 50eb08d9a..80a6fc84d 100644 --- a/packages/runner/src/schema.ts +++ b/packages/runner/src/schema.ts @@ -1,14 +1,12 @@ import { isObject, isRecord, type Mutable } from "@commontools/utils/types"; import { ContextualFlowControl } from "./cfc.ts"; -import { - isAlias, - type JSONSchema, - type JSONValue, -} from "./builder/types.ts"; +import { type JSONSchema, type JSONValue } from "./builder/types.ts"; +import { isAnyCellLink, isWriteRedirectLink, parseLink } from "./link-utils.ts"; import { type DocImpl } from "./doc.ts"; -import { type CellLink, createCell, isCell, isCellLink } from "./cell.ts"; +import { createCell, isCell } from "./cell.ts"; +import { type LegacyCellLink } from "./sigil-types.ts"; import { type ReactivityLog } from "./scheduler.ts"; -import { resolveLinks, resolveLinkToAlias } from "./link-resolution.ts"; +import { resolveLinks, resolveLinkToWriteRedirect } from "./link-resolution.ts"; /** * Schemas are mostly a subset of JSONSchema. @@ -281,7 +279,7 @@ export function validateAndTransform( // Follow aliases, etc. to last element on path + just aliases on that last one // When we generate cells below, we want them to be based off this value, as that // is what a setter would change when they update a value or reference. - const resolvedRef = resolveLinkToAlias( + const resolvedRef = resolveLinkToWriteRedirect( doc, path, log, @@ -333,20 +331,21 @@ export function validateAndTransform( // the top of the current doc is already a reference. for (let i = -1; i < path.length; i++) { const value = doc.getAtPath(path.slice(0, i + 1)); - if (isAlias(value)) { + if (isWriteRedirectLink(value)) { throw new Error( - "Unexpected alias in path, should have been handled by resolvePath", + "Unexpected write redirect in path, should have been handled by resolvePath", ); } - if (isCellLink(value)) { + if (isAnyCellLink(value)) { + const link = parseLink(value, doc.asCell()); log?.reads.push({ cell: doc, path: path.slice(0, i + 1) }); const extraPath = [...path.slice(i + 1)]; - const newPath = [...value.path, ...extraPath]; + const newPath = [...link.path, ...extraPath]; const cfc = doc.runtime.cfc; let newSchema; - if (value.schema !== undefined) { + if (link.schema !== undefined) { newSchema = cfc.getSchemaAtPath( - value.schema, + link.schema, extraPath.map((key) => key.toString()), rootSchema, ); @@ -357,7 +356,7 @@ export function validateAndTransform( newSchema = cfc.getSchemaAtPath(resolvedSchema, []); } return createCell( - value.cell, + doc.runtime.documentMap.getDocByEntityId(link.space, link.id, true), newPath, log, newSchema, @@ -509,7 +508,7 @@ export function validateAndTransform( // Merge all the object extractions let merged: Record = {}; - const extraReads: CellLink[] = []; + const extraReads: LegacyCellLink[] = []; for (const { result, extraLog } of candidates) { if (isCell(result)) { merged = result; diff --git a/packages/runner/src/sigil-types.ts b/packages/runner/src/sigil-types.ts new file mode 100644 index 000000000..43fa47140 --- /dev/null +++ b/packages/runner/src/sigil-types.ts @@ -0,0 +1,82 @@ +import type { JSONSchema } from "@commontools/api"; +import type { MemorySpace } from "@commontools/memory/interface"; +import type { ShadowRef } from "./builder/types.ts"; +import type { DocImpl } from "./doc.ts"; +import type { URI } from "@commontools/memory/interface"; + +export type { URI } from "@commontools/memory/interface"; + +/** + * Generic sigil value type for future extensions + */ +export type SigilValue = { "/": T }; + +/** + * Link sigil value v1 + */ + +export const LINK_V1_TAG = "link@1" as const; + +export type LinkV1 = { + [LINK_V1_TAG]: { + id?: URI; + path?: string[]; + space?: MemorySpace; + schema?: JSONSchema; + rootSchema?: JSONSchema; + overwrite?: "redirect" | "this"; // default is "this" + }; +}; + +export type WriteRedirectV1 = LinkV1 & { + [LINK_V1_TAG]: { overwrite: "redirect" }; +}; +/** + * Sigil link type + */ + +export type SigilLink = SigilValue; +/** + * Sigil alias type - uses LinkV1 with overwrite field + */ + +export type SigilWriteRedirectLink = SigilValue; + +/**************** + * Legacy types * + ****************/ + +/** + * Cell link. + * + * A cell link is a doc and a path within that doc. + */ +export type LegacyCellLink = { + space?: MemorySpace; + cell: DocImpl; + path: PropertyKey[]; + schema?: JSONSchema; + rootSchema?: JSONSchema; +}; + +/** + * Legacy alias. + * + * A legacy alias is a cell and a path within that cell. + */ +export type LegacyAlias = { + $alias: { + cell?: DocImpl | ShadowRef | number; + path: PropertyKey[]; + schema?: JSONSchema; + rootSchema?: JSONSchema; + }; +}; + +/** + * JSON cell link format used in storage + */ +export type JSONCellLink = { + cell: { "/": string }; + path: (string | number)[]; +}; diff --git a/packages/runner/src/storage.ts b/packages/runner/src/storage.ts index ada72daa8..b3c495c13 100644 --- a/packages/runner/src/storage.ts +++ b/packages/runner/src/storage.ts @@ -10,13 +10,14 @@ import { } from "@commontools/memory/interface"; import { SchemaNone } from "@commontools/memory/schema"; import { type AddCancel, type Cancel, useCancelGroup } from "./cancel.ts"; -import { Cell, type CellLink, isCell, isCellLink, isStream } from "./cell.ts"; +import { Cell, isCell, isStream } from "./cell.ts"; import { type DocImpl, isDoc } from "./doc.ts"; -import { type EntityId } from "./doc-map.ts"; import { getCellLinkOrThrow, isQueryResultForDereferencing, } from "./query-result-proxy.ts"; +import { type EntityId } from "./doc-map.ts"; +import { isLink, parseLink } from "./link-utils.ts"; import type { IStorageManager, IStorageProvider, @@ -265,31 +266,28 @@ export class Storage implements IStorage { // Traverse the value and for each doc reference, make sure it's persisted. // This is done recursively. - const traverse = ( - value: Readonly, - path: PropertyKey[], - ): any => { - // If it's a doc, make it a doc link - if (isDoc(value)) value = { cell: value, path: [] } satisfies CellLink; - - // If it's a query result proxy, make it a doc link - if (isQueryResultForDereferencing(value)) { - value = getCellLinkOrThrow(value); - } - - // If it's a doc link, convert it to a doc link with an id - if (isCellLink(value)) { - dependencies.add(this._ensureIsSynced(value.cell)); - return { ...value, cell: value.cell.toJSON() /* = the id */ }; + const traverse = (value: Readonly): any => { + // If it's a link, add it as dependency and convert it to a sigil link + if (isLink(value)) { + const link = parseLink(value, doc.asCell()); + const cell = this.runtime.getCellFromLink(link); + dependencies.add(this._ensureIsSynced(cell)); + + // Don't convert to sigil link here, as we plan to remove this whole + // transformation soon. So return value instead of creating a sigil + // link. Roundtripping through JSON converts all Cells and Docs to a + // serializable format. + if (isQueryResultForDereferencing(value)) { + value = getCellLinkOrThrow(value); + } + return JSON.parse(JSON.stringify(value)); } else if (isRecord(value)) { if (Array.isArray(value)) { - return value.map((value, index) => traverse(value, [...path, index])); + return value.map(traverse); } else { return Object.fromEntries( - Object.entries(value).map(([key, value]: [PropertyKey, any]) => [ - key, - traverse(value, [...path, key]), - ]), + Object.entries(value as Record) + .map(([key, value]) => [key, traverse(value)]), ); } } else return value; @@ -300,7 +298,7 @@ export class Storage implements IStorage { // Convert all doc references to ids and remember as dependent docs const value: StorageValue = { - value: traverse(doc.get(), []), + value: traverse(doc.get()), source: doc.sourceCell?.entityId, ...(labels !== undefined) ? { labels: labels } : {}, }; @@ -341,9 +339,20 @@ export class Storage implements IStorage { const traverse = (value: any): any => { if (typeof value !== "object" || value === null) { return value; + } else if (isLink(value)) { + const link = parseLink(value, doc.asCell()); + const cell = this.runtime.getCellFromLink(link); + + // If the doc is not yet loaded, load it. As it's referenced in + // something that came from storage, the id is known in storage and so + // we have to wait for it to load. Hence true as second parameter. + dependencies.add(this._ensureIsSynced(cell)); + + // We don't convert here as we plan to remove this whole transformation + // soon. So return value instead of creating a sigil link. + return value; } else if ("cell" in value && "path" in value) { - // If we see a doc link with just an id, then we replace it with - // the actual doc: + // If we see a doc link an id, then we sync the mentioned doc. if ( isRecord(value.cell) && "/" in value.cell && @@ -353,9 +362,6 @@ export class Storage implements IStorage { const valueSchema = (label !== undefined) ? this.runtime.cfc.schemaWithLub(value.schema ?? {}, label) : value.schema; - // If the doc is not yet loaded, load it. As it's referenced in - // something that came from storage, the id is known in storage and so - // we have to wait for it to load. Hence true as second parameter. const dependency = this._ensureIsSyncedById( doc.space, value.cell, @@ -365,11 +371,10 @@ export class Storage implements IStorage { : undefined, ); dependencies.add(dependency); - return { ...value, cell: dependency }; } else { console.warn("unexpected doc link", value); - return value; } + return value; } else if (Array.isArray(value)) { return value.map(traverse); } else { diff --git a/packages/runner/src/storage/cache.ts b/packages/runner/src/storage/cache.ts index 2b4ff5c1f..4fd987391 100644 --- a/packages/runner/src/storage/cache.ts +++ b/packages/runner/src/storage/cache.ts @@ -1,7 +1,11 @@ import { fromString, refer } from "merkle-reference"; import { isBrowser } from "@commontools/utils/env"; import { isObject } from "@commontools/utils/types"; -import { type JSONSchema } from "../builder/types.ts"; +import { + type JSONSchema, + type JSONValue, + type SchemaContext, +} from "../builder/types.ts"; import { ContextualFlowControl } from "../cfc.ts"; import { deepEqual } from "../path-utils.ts"; import { MapSet } from "../traverse.ts"; @@ -15,15 +19,12 @@ import type { Entity, Fact, FactAddress, - JSONValue, MemorySpace, Protocol, ProviderCommand, - ProviderSession, QueryError, Result, Revision, - SchemaContext, SchemaPathSelector, SchemaQueryArgs, Signer, diff --git a/packages/runner/src/traverse.ts b/packages/runner/src/traverse.ts index eb4067b21..b7a6cb347 100644 --- a/packages/runner/src/traverse.ts +++ b/packages/runner/src/traverse.ts @@ -4,7 +4,6 @@ import { type Immutable, isNumber, isObject, - isRecord, isString, } from "../../utils/src/types.ts"; import { ContextualFlowControl } from "./cfc.ts"; @@ -14,8 +13,15 @@ import type { JSONValue, SchemaContext, } from "./builder/types.ts"; -import { isAlias } from "./builder/types.ts"; import { deepEqual } from "./path-utils.ts"; +import { + isLegacyAlias, + isSigilLink, + type NormalizedLink, + parseLink, +} from "./link-utils.ts"; +import { fromURI } from "./uri-utils.ts"; +import { type JSONCellLink } from "./sigil-types.ts"; export type SchemaPathSelector = { path: readonly string[]; @@ -82,7 +88,6 @@ export type PointerCycleTracker = CycleTracker< Immutable >; -type JSONCellLink = { cell: { "/": string }; path: string[] }; export type CellTarget = { path: string[]; cellTarget: string | undefined }; export interface ObjectStorageManager { @@ -448,7 +453,7 @@ function narrowSchema( } /** - * Extract the path and cellTarget from an Alias or JSONCellLink + * Extract the path and cellTarget from an Alias, JSONCellLink, or sigil value * * @param value - The JSON object that might contain pointer information * @returns A CellTarget object containing: @@ -456,26 +461,16 @@ function narrowSchema( * - cellTarget: The target cell identifier as a string, or undefined if it refers to the current document */ export function getPointerInfo(value: Immutable): CellTarget { - if (isAlias(value)) { - if (isObject(value.$alias.cell) && "/" in value.$alias.cell) { - return { - path: value.$alias.path.map((p) => p.toString()), - cellTarget: value.$alias.cell["/"] as string, - }; - } - return { - path: value.$alias.path.map((p) => p.toString()), - cellTarget: undefined, - }; - } else if (isJSONCellLink(value)) { - //console.error("cell: ", obj.cell, "; path: ", obj.path); - return { path: value.path, cellTarget: value.cell["/"] as string }; - } - return { path: [], cellTarget: undefined }; + const link = parseLink(value, {} as NormalizedLink); + if (!link) return { path: [], cellTarget: undefined }; + return { + path: link.path ?? [], + cellTarget: link.id ? fromURI(link.id) : undefined, + }; } export function isPointer(value: unknown): boolean { - return (isAlias(value) || isJSONCellLink(value)); + return (isSigilLink(value) || isJSONCellLink(value) || isLegacyAlias(value)); } /** diff --git a/packages/runner/src/type-utils.ts b/packages/runner/src/type-utils.ts deleted file mode 100644 index 2554efd11..000000000 --- a/packages/runner/src/type-utils.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { isRecord, type Mutable } from "@commontools/utils/types"; -import { isOpaqueRef } from "./builder/types.ts"; -import { isDoc } from "./doc.ts"; -import { type CellLink, isCell, isCellLink } from "./cell.ts"; -import { - getCellLinkOrThrow, - isQueryResultForDereferencing, -} from "./query-result-proxy.ts"; - -export function maybeUnwrapProxy(value: unknown): unknown { - return isQueryResultForDereferencing(value) - ? getCellLinkOrThrow(value) - : value; -} - -export function arrayEqual(a: PropertyKey[], b: PropertyKey[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; - return true; -} - -export function isEqualCellLink(a: CellLink, b: CellLink): boolean { - return isCellLink(a) && isCellLink(b) && a.cell === b.cell && - arrayEqual(a.path, b.path); -} - -export function containsOpaqueRef(value: unknown): boolean { - if (isOpaqueRef(value)) return true; - if (isCell(value) || isCellLink(value) || isDoc(value)) return false; - if (isRecord(value)) { - return Object.values(value).some(containsOpaqueRef); - } - return false; -} - -export function deepCopy(value: T): Mutable { - if (isQueryResultForDereferencing(value)) { - return deepCopy(getCellLinkOrThrow(value)) as unknown as Mutable; - } - if (isDoc(value) || isCell(value)) return value as Mutable; - if (isRecord(value)) { - return Array.isArray(value) - ? value.map(deepCopy) as unknown as Mutable - : Object.fromEntries( - Object.entries(value).map(([key, value]) => [key, deepCopy(value)]), - ) as unknown as Mutable; - // Literal value: - } else return value as Mutable; -} diff --git a/packages/runner/src/uri-utils.ts b/packages/runner/src/uri-utils.ts new file mode 100644 index 000000000..5318041f4 --- /dev/null +++ b/packages/runner/src/uri-utils.ts @@ -0,0 +1,44 @@ +import { isRecord } from "@commontools/utils/types"; +import type { URI } from "./sigil-types.ts"; + +/** + * Convert an entity ID to URI format with "of:" prefix + */ +export function toURI(value: unknown): URI { + if (isRecord(value)) { + // Converts EntityId to JSON + const parsed = JSON.parse(JSON.stringify(value)) as { "/": string }; + + // Handle EntityId object + if (typeof parsed["/"] === "string") return `of:${parsed["/"]}`; + } else if (typeof value === "string") { + // Already has prefix with colon + if (value.includes(":")) { + // TODO(seefeld): Remove this once we want to support any URI, ideally + // once there are no bare ids anymore + if (!value.startsWith("of:")) { + throw new Error(`Invalid URI: ${value}`); + } + return value as URI; + } + + // Add "of:" prefix + return `of:${value}`; + } + + throw new Error(`Cannot convert value to URI: ${JSON.stringify(value)}`); +} + +/** + * Extract the hash from a URI by removing the "of:" prefix + */ +export function fromURI(uri: URI | string): string { + if (!uri.includes(":")) { + return uri; + } else if (uri.startsWith("of:")) { + return uri.slice(3); + } else { + // TODO(seefeld): Remove this once we want to support any URI + throw new Error(`Invalid URI: ${uri}`); + } +} diff --git a/packages/runner/test/cell.test.ts b/packages/runner/test/cell.test.ts index 1805da61f..191aa31b9 100644 --- a/packages/runner/test/cell.test.ts +++ b/packages/runner/test/cell.test.ts @@ -1,20 +1,24 @@ import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; -import { type DocImpl, isDoc } from "../src/doc.ts"; -import { isCell, isCellLink } from "../src/cell.ts"; +import { isCell } from "../src/cell.ts"; +import { LINK_V1_TAG } from "../src/sigil-types.ts"; import { isQueryResult } from "../src/query-result-proxy.ts"; import { type ReactivityLog } from "../src/scheduler.ts"; import { ID, JSONSchema } from "../src/builder/types.ts"; import { popFrame, pushFrame } from "../src/builder/recipe.ts"; import { Runtime } from "../src/runtime.ts"; import { addCommonIDfromObjectID } from "../src/data-updating.ts"; +import { isCellLink } from "../src/link-utils.ts"; import { Identity } from "@commontools/identity"; import { StorageManager } from "@commontools/runner/storage/cache.deno"; -import { expectCellLinksEqual, normalizeCellLink } from "./test-helpers.ts"; +import { areLinksSame } from "../src/link-utils.ts"; const signer = await Identity.fromPassphrase("test operator"); const space = signer.did(); +const signer2 = await Identity.fromPassphrase("test operator 2"); +const space2 = signer2.did(); + describe("Cell", () => { let runtime: Runtime; let storageManager: ReturnType; @@ -211,7 +215,7 @@ describe("Cell utility functions", () => { "should identify a cell reference", ); c.set({ x: 10 }); - const ref = c.key("x").getAsCellLink(); + const ref = c.key("x").getAsLegacyCellLink(); expect(isCellLink(ref)).toBe(true); expect(isCellLink({})).toBe(false); }); @@ -309,7 +313,7 @@ describe("createProxy", () => { "should handle cell references", ); c.set({ x: 42 }); - const ref = c.key("x").getAsCellLink(); + const ref = c.key("x").getAsLegacyCellLink(); const proxy = c.getAsQueryResult(); proxy.y = ref; expect(proxy.y).toBe(42); @@ -321,7 +325,7 @@ describe("createProxy", () => { "should handle infinite loops in cell references", ); c.set({ x: 42 }); - const ref = c.key("x").getAsCellLink(); + const ref = c.key("x").getAsLegacyCellLink(); const proxy = c.getAsQueryResult(); proxy.x = ref; expect(() => proxy.x).toThrow(); @@ -519,10 +523,11 @@ describe("createProxy", () => { const proxy = c.getAsQueryResult([], log); proxy.length = 2; expect(c.get()).toEqual([1, 2]); - expectCellLinksEqual(log.writes).toEqual([ - c.key("length").getAsCellLink(), - c.key(2).getAsCellLink(), - ]); + expect(areLinksSame(log.writes[0], c.key("length").getAsLegacyCellLink())) + .toBe(true); + expect(areLinksSame(log.writes[1], c.key(2).getAsLegacyCellLink())).toBe( + true, + ); proxy.length = 4; expect(c.get()).toEqual([1, 2, undefined, undefined]); expect(log.writes.length).toBe(5); @@ -649,7 +654,7 @@ describe("asCell", () => { }); it("should call sink only when the cell changes on the subpath", async () => { - const c = runtime.getCell<{ a: { b: number, c: number }, d: number }>( + const c = runtime.getCell<{ a: { b: number; c: number }; d: number }>( space, "should call sink only when the cell changes on the subpath", ); @@ -1163,8 +1168,8 @@ describe("asCell with schema", () => { "should handle all types of references in underlying cell: inner", ); innerCell.set({ value: 42 }); - const cellRef = innerCell.getAsCellLink(); - const aliasRef = { $alias: innerCell.getAsCellLink() }; + const cellRef = innerCell.getAsLegacyCellLink(); + const aliasRef = { $alias: innerCell.getAsLegacyCellLink() }; // Create a cell that uses all reference types const c = runtime.getCell<{ @@ -1223,22 +1228,22 @@ describe("asCell with schema", () => { "should handle nested references: inner", ); innerCell.set({ value: 42 }); - - const ref1 = innerCell.getAsCellLink(); - + + const ref1 = innerCell.getAsLegacyCellLink(); + const ref2Cell = runtime.getCell<{ ref: any }>( space, "should handle nested references: ref2", ); ref2Cell.set({ ref: ref1 }); - const ref2 = ref2Cell.key("ref").getAsCellLink(); - + const ref2 = ref2Cell.key("ref").getAsLegacyCellLink(); + const ref3Cell = runtime.getCell<{ ref: any }>( space, "should handle nested references: ref3", ); ref3Cell.setRaw({ ref: ref2 }); - const ref3 = ref3Cell.key("ref").getAsCellLink(); + const ref3 = ref3Cell.key("ref").getAsLegacyCellLink(); // Create a cell that uses the nested reference const c = runtime.getCell<{ @@ -1275,15 +1280,15 @@ describe("asCell with schema", () => { expect(value.context.nested.get().value).toBe(42); // Check that 4 unique documents were read (by entity ID) - const readEntityIds = new Set(log.reads.map(r => r.cell.entityId)); + const readEntityIds = new Set(log.reads.map((r) => r.cell.entityId)); expect(readEntityIds.size).toBe(4); - + // Verify each cell was read using equals() - const readCells = log.reads.map(r => r.cell.asCell()); - expect(readCells.some(cell => cell.equals(c))).toBe(true); - expect(readCells.some(cell => cell.equals(ref3Cell))).toBe(true); - expect(readCells.some(cell => cell.equals(ref2Cell))).toBe(true); - expect(readCells.some(cell => cell.equals(innerCell))).toBe(true); + const readCells = log.reads.map((r) => r.cell.asCell()); + expect(readCells.some((cell) => cell.equals(c))).toBe(true); + expect(readCells.some((cell) => cell.equals(ref3Cell))).toBe(true); + expect(readCells.some((cell) => cell.equals(ref2Cell))).toBe(true); + expect(readCells.some((cell) => cell.equals(innerCell))).toBe(true); // Changes to the original cell should propagate through the chain innerCell.send({ value: 100 }); @@ -1608,8 +1613,8 @@ describe("asCell with schema", () => { // Let's make sure we got a different doc with the different context expect(testDoc.getRaw()[0].cell).not.toBe(docFromContext1); - expect(testDoc.getRaw()[0].cell.entityId.toString()).not.toBe( - docFromContext1.entityId.toString(), + expect(JSON.stringify(testDoc.getRaw()[0].cell.entityId)).not.toBe( + JSON.stringify(docFromContext1.entityId), ); expect(testCell.get()).toEqual(initialData); @@ -1633,7 +1638,6 @@ describe("asCell with schema", () => { arrayCell.push(d); arrayCell.push(dCell); arrayCell.push(d.getAsQueryResult()); - arrayCell.push(d.getAsCellLink()); // helper to normalize CellLinks because different push operations // may result in CellLinks with different extra properties (ex: space) @@ -1643,13 +1647,12 @@ describe("asCell with schema", () => { }); const rawItems = c.getRaw().items; - const expectedCellLink = normalizeCellLink(d.getAsCellLink()); + const expectedCellLink = normalizeCellLink(d.getAsLegacyCellLink()); expect(rawItems.map(normalizeCellLink)).toEqual([ expectedCellLink, expectedCellLink, expectedCellLink, - expectedCellLink, ]); }); @@ -1680,6 +1683,373 @@ describe("asCell with schema", () => { }); }); +describe("getAsLink method", () => { + let runtime: Runtime; + let storageManager: ReturnType; + + beforeEach(() => { + storageManager = StorageManager.emulate({ as: signer }); + + runtime = new Runtime({ + blobbyServerUrl: import.meta.url, + storageManager, + }); + }); + + afterEach(async () => { + await runtime?.dispose(); + await storageManager?.close(); + }); + + it("should return new sigil format", () => { + const c = runtime.documentMap.getDoc( + { value: 42 }, + "getAsLink-test", + space, + ); + const cell = c.asCell(); + + // Get the new sigil format + const link = cell.getAsLink(); + + // Verify structure + expect(link["/"]).toBeDefined(); + expect(link["/"][LINK_V1_TAG]).toBeDefined(); + expect(link["/"][LINK_V1_TAG].id).toBeDefined(); + expect(link["/"][LINK_V1_TAG].path).toBeDefined(); + + // Verify id has of: prefix + expect(link["/"][LINK_V1_TAG].id).toMatch(/^of:/); + + // Verify path is empty array + expect(link["/"][LINK_V1_TAG].path).toEqual([]); + + // Verify space is included if present + expect(link["/"][LINK_V1_TAG].space).toBe(space); + }); + + it("should return correct path for nested cells", () => { + const c = runtime.documentMap.getDoc( + { nested: { value: 42 } }, + "getAsLink-nested-test", + space, + ); + const nestedCell = c.asCell(["nested", "value"]); + + const link = nestedCell.getAsLink(); + + expect(link["/"][LINK_V1_TAG].path).toEqual(["nested", "value"]); + }); + + it("should return different formats for getAsLink vs toJSON", () => { + const c = runtime.documentMap.getDoc( + { value: 42 }, + "getAsLink-json-test", + space, + ); + const cell = c.asCell(); + + const link = cell.getAsLink(); + const json = cell.toJSON(); + + // Debug: log actual values + console.log("getAsLink result:", JSON.stringify(link, null, 2)); + console.log("toJSON result:", JSON.stringify(json, null, 2)); + + // getAsLink returns new sigil format + expect(link).toHaveProperty("/"); + console.log("getAsLink result /:", JSON.stringify(link["/"], null, 2)); + expect(link["/"][LINK_V1_TAG]).toBeDefined(); + + // toJSON returns old format for backward compatibility + expect(json).toHaveProperty("cell"); + expect(json).toHaveProperty("path"); + expect((json as any).cell).toHaveProperty("/"); + }); + + it("should create relative links with base parameter - same document", () => { + const c = runtime.documentMap.getDoc( + { value: 42, other: "test" }, + "getAsLink-base-test", + space, + ); + const cell = c.asCell(["value"]); + const baseCell = c.asCell(); + + // Link relative to base cell (same document) + const link = cell.getAsLink({ base: baseCell }); + + // Should omit id and space since they're the same + expect(link["/"][LINK_V1_TAG].id).toBeUndefined(); + expect(link["/"][LINK_V1_TAG].space).toBeUndefined(); + expect(link["/"][LINK_V1_TAG].path).toEqual(["value"]); + }); + + it("should create relative links with base parameter - different document", () => { + const c1 = runtime.documentMap.getDoc( + { value: 42 }, + "getAsLink-base-test-1", + space, + ); + const c2 = runtime.documentMap.getDoc( + { other: "test" }, + "getAsLink-base-test-2", + space, + ); + const cell = c1.asCell(["value"]); + const baseCell = c2.asCell(); + + // Link relative to base cell (different document, same space) + const link = cell.getAsLink({ base: baseCell }); + + // Should include id but not space since space is the same + expect(link["/"][LINK_V1_TAG].id).toBeDefined(); + expect(link["/"][LINK_V1_TAG].id).toMatch(/^of:/); + expect(link["/"][LINK_V1_TAG].space).toBeUndefined(); + expect(link["/"][LINK_V1_TAG].path).toEqual(["value"]); + }); + + it("should create relative links with base parameter - different space", () => { + const c1 = runtime.documentMap.getDoc( + { value: 42 }, + "getAsLink-base-test-1", + space, + ); + const c2 = runtime.documentMap.getDoc( + { other: "test" }, + "getAsLink-base-test-2", + space2, + ); + const cell = c1.asCell(["value"]); + const baseCell = c2.asCell(); + + // Link relative to base cell (different space) + const link = cell.getAsLink({ base: baseCell }); + + // Should include both id and space since they're different + expect(link["/"][LINK_V1_TAG].id).toBeDefined(); + expect(link["/"][LINK_V1_TAG].id).toMatch(/^of:/); + expect(link["/"][LINK_V1_TAG].space).toBe(space); + expect(link["/"][LINK_V1_TAG].path).toEqual(["value"]); + }); + + it("should include schema when includeSchema is true", () => { + const c = runtime.documentMap.getDoc( + { value: 42 }, + "getAsLink-schema-test", + space, + ); + const schema = { type: "number", minimum: 0 } as const; + const cell = c.asCell(["value"], undefined, schema); + + // Link with schema included + const link = cell.getAsLink({ includeSchema: true }); + + expect(link["/"][LINK_V1_TAG].schema).toEqual(schema); + expect(link["/"][LINK_V1_TAG].id).toBeDefined(); + expect(link["/"][LINK_V1_TAG].path).toEqual(["value"]); + }); + + it("should not include schema when includeSchema is false", () => { + const c = runtime.documentMap.getDoc( + { value: 42 }, + "getAsLink-no-schema-test", + space, + ); + const schema = { type: "number", minimum: 0 } as const; + const cell = c.asCell(["value"], undefined, schema); + + // Link without schema + const link = cell.getAsLink({ includeSchema: false }); + + expect(link["/"][LINK_V1_TAG].schema).toBeUndefined(); + }); + + it("should not include schema when includeSchema is undefined", () => { + const c = runtime.documentMap.getDoc( + { value: 42 }, + "getAsLink-default-schema-test", + space, + ); + const schema = { type: "number", minimum: 0 } as const; + const cell = c.asCell(["value"], undefined, schema); + + // Link with default options (no schema) + const link = cell.getAsLink(); + + expect(link["/"][LINK_V1_TAG].schema).toBeUndefined(); + }); + + it("should handle both base and includeSchema options together", () => { + const c1 = runtime.documentMap.getDoc( + { value: 42 }, + "getAsLink-combined-test-1", + space, + ); + const c2 = runtime.documentMap.getDoc( + { other: "test" }, + "getAsLink-combined-test-2", + space, + ); + const schema = { type: "number", minimum: 0 } as const; + const cell = c1.asCell(["value"], undefined, schema); + const baseCell = c2.asCell(); + + // Link with both base and schema options + const link = cell.getAsLink({ base: baseCell, includeSchema: true }); + + // Should include id (different docs) but not space (same space) + expect(link["/"][LINK_V1_TAG].id).toBeDefined(); + expect(link["/"][LINK_V1_TAG].space).toBeUndefined(); + expect(link["/"][LINK_V1_TAG].path).toEqual(["value"]); + expect(link["/"][LINK_V1_TAG].schema).toEqual(schema); + }); + + it("should handle cell without schema when includeSchema is true", () => { + const c = runtime.documentMap.getDoc( + { value: 42 }, + "getAsLink-no-cell-schema-test", + space, + ); + const cell = c.asCell(["value"]); // No schema provided + + // Link with includeSchema but cell has no schema + const link = cell.getAsLink({ includeSchema: true }); + + expect(link["/"][LINK_V1_TAG].schema).toBeUndefined(); + }); +}); + +describe("getAsWriteRedirectLink method", () => { + let runtime: Runtime; + let storageManager: ReturnType; + + beforeEach(() => { + storageManager = StorageManager.emulate({ as: signer }); + + runtime = new Runtime({ + blobbyServerUrl: import.meta.url, + storageManager, + }); + }); + + afterEach(async () => { + await runtime?.dispose(); + await storageManager?.close(); + }); + + it("should return new sigil alias format", () => { + const c = runtime.documentMap.getDoc( + { value: 42 }, + "getAsWriteRedirectLink-test", + space, + ); + const cell = c.asCell(); + + // Get the new sigil alias format + const alias = cell.getAsWriteRedirectLink(); + + // Verify structure + expect(alias["/"]).toBeDefined(); + expect(alias["/"][LINK_V1_TAG]).toBeDefined(); + expect(alias["/"][LINK_V1_TAG].id).toBeDefined(); + expect(alias["/"][LINK_V1_TAG].path).toBeDefined(); + expect(alias["/"][LINK_V1_TAG].overwrite).toBe("redirect"); + + // Verify id has of: prefix + expect(alias["/"][LINK_V1_TAG].id).toMatch(/^of:/); + + // Verify path is empty array + expect(alias["/"][LINK_V1_TAG].path).toEqual([]); + + // Verify space is included if present + expect(alias["/"][LINK_V1_TAG].space).toBe(space); + }); + + it("should return correct path for nested cells", () => { + const c = runtime.documentMap.getDoc( + { nested: { value: 42 } }, + "getAsWriteRedirectLink-nested-test", + space, + ); + const nestedCell = c.asCell(["nested", "value"]); + + const alias = nestedCell.getAsWriteRedirectLink(); + + expect(alias["/"][LINK_V1_TAG].path).toEqual(["nested", "value"]); + }); + + it("should omit space when baseSpace matches", () => { + const c = runtime.documentMap.getDoc( + { value: 42 }, + "getAsWriteRedirectLink-baseSpace-test", + space, + ); + const cell = c.asCell(); + + // Alias with same baseSpace should omit space + const alias = cell.getAsWriteRedirectLink({ baseSpace: space }); + + expect(alias["/"][LINK_V1_TAG].id).toBeDefined(); + expect(alias["/"][LINK_V1_TAG].space).toBeUndefined(); + expect(alias["/"][LINK_V1_TAG].path).toEqual([]); + }); + + it("should include space when baseSpace differs", () => { + const c = runtime.documentMap.getDoc( + { value: 42 }, + "getAsWriteRedirectLink-different-baseSpace-test", + space2, + ); + const cell = c.asCell(); + + // Alias with different baseSpace should include space + const alias = cell.getAsWriteRedirectLink({ baseSpace: space }); + + expect(alias["/"][LINK_V1_TAG].id).toBeDefined(); + expect(alias["/"][LINK_V1_TAG].space).toBe(space2); + expect(alias["/"][LINK_V1_TAG].path).toEqual([]); + }); + + it("should include schema when includeSchema is true", () => { + const c = runtime.documentMap.getDoc( + { value: 42 }, + "getAsWriteRedirectLink-schema-test", + space, + ); + const schema = { type: "number", minimum: 0 } as const; + const cell = c.asCell(["value"], undefined, schema); + + // Alias with includeSchema option + const alias = cell.getAsWriteRedirectLink({ includeSchema: true }); + + expect(alias["/"][LINK_V1_TAG].schema).toEqual(schema); + }); + + it("should handle base cell for relative aliases", () => { + const c1 = runtime.documentMap.getDoc( + { value: 42 }, + "getAsWriteRedirectLink-base-test-1", + space, + ); + const c2 = runtime.documentMap.getDoc( + { other: "test" }, + "getAsWriteRedirectLink-base-test-2", + space, + ); + const cell = c1.asCell(["value"]); + const baseCell = c2.asCell(); + + // Alias relative to base cell (different document, same space) + const alias = cell.getAsWriteRedirectLink({ base: baseCell }); + + // Should include id (different docs) but not space (same space) + expect(alias["/"][LINK_V1_TAG].id).toBeDefined(); + expect(alias["/"][LINK_V1_TAG].space).toBeUndefined(); + expect(alias["/"][LINK_V1_TAG].path).toEqual(["value"]); + }); +}); + describe("JSON.stringify bug", () => { let storageManager: ReturnType; let runtime: Runtime; @@ -1708,14 +2078,23 @@ describe("JSON.stringify bug", () => { space, "json-test2", ); - d.setRaw({ internal: { "__#2": normalizeCellLink(c.key("result").getAsCellLink()) } }); + const cLink = c.key("result").getAsLink({ base: d }); + d.setRaw({ + internal: { + "__#2": cLink, + }, + }); const e = runtime.getCell<{ internal: { a: any } }>( space, "json-test3", ); + const dLink = d.key("internal").key("__#2").key("data") + .getAsWriteRedirectLink( + { base: e }, + ); e.setRaw({ internal: { - a: { $alias: normalizeCellLink(d.key("internal").key("__#2").key("data").getAsCellLink()) }, + a: dLink, }, }); const proxy = e.getAsQueryResult(); @@ -1724,16 +2103,10 @@ describe("JSON.stringify bug", () => { expect(JSON.stringify(c.get())).toEqual('{"result":{"data":1}}'); expect(JSON.stringify(d.getRaw())).toEqual( - `{"internal":{"__#2":{"cell":${ - JSON.stringify(c.entityId) - },"path":["result"]}}}`, + `{"internal":{"__#2":${JSON.stringify(cLink)}}}`, ); expect(JSON.stringify(e.getRaw())).toEqual( - `{"internal":{"a":{"$alias":{"cell":${ - JSON.stringify( - d.entityId, - ) - },"path":["internal","__#2","data"]}}}}`, + `{"internal":{"a":${JSON.stringify(dLink)}}}`, ); }); }); diff --git a/packages/runner/test/data-updating.test.ts b/packages/runner/test/data-updating.test.ts index cc0920689..a02658643 100644 --- a/packages/runner/test/data-updating.test.ts +++ b/packages/runner/test/data-updating.test.ts @@ -8,13 +8,18 @@ import { normalizeAndDiff, setNestedValue, } from "../src/data-updating.ts"; -import { isEqualCellLink } from "../src/type-utils.ts"; import { Runtime } from "../src/runtime.ts"; -import { CellLink, isCellLink } from "../src/cell.ts"; +import { + areLinksSame, + isAnyCellLink, + isCellLink, + isLegacyCellLink, +} from "../src/link-utils.ts"; +import type { LegacyCellLink } from "../src/sigil-types.ts"; +import { arrayEqual } from "../src/path-utils.ts"; import { type ReactivityLog } from "../src/scheduler.ts"; import { Identity } from "@commontools/identity"; import { StorageManager } from "@commontools/runner/storage/cache.deno"; -import { expectCellLinksEqual } from "./test-helpers.ts"; const signer = await Identity.fromPassphrase("test operator"); const space = signer.did(); @@ -51,7 +56,9 @@ describe("data-updating", () => { }); it("should delete no longer used fields when setting a nested value", () => { - const testCell = runtime.getCell<{ a: number; b: { c: number; d?: number } }>( + const testCell = runtime.getCell< + { a: number; b: { c: number; d?: number } } + >( space, "should delete no longer used fields 1", ); @@ -68,7 +75,10 @@ describe("data-updating", () => { ); testCell.set({ a: 1, b: { c: 2 } }); const log: ReactivityLog = { reads: [], writes: [] }; - const success = setNestedValue(testCell.getDoc(), [], { a: 1, b: { c: 2 } }, log); + const success = setNestedValue(testCell.getDoc(), [], { + a: 1, + b: { c: 2 }, + }, log); expect(success).toBe(true); // No changes is still a success expect(testCell.get()).toEqual({ a: 1, b: { c: 2 } }); expect(log.writes).toEqual([]); @@ -81,7 +91,10 @@ describe("data-updating", () => { ); testCell.set({ a: 1, b: { c: 2 } }); const log: ReactivityLog = { reads: [], writes: [] }; - const success = setNestedValue(testCell.getDoc(), [], { a: 1, b: { c: 3 } }, log); + const success = setNestedValue(testCell.getDoc(), [], { + a: 1, + b: { c: 3 }, + }, log); expect(success).toBe(true); expect(testCell.get()).toEqual({ a: 1, b: { c: 3 } }); expect(log.writes.length).toEqual(1); @@ -96,7 +109,10 @@ describe("data-updating", () => { testCell.set({ a: 1, b: { c: 2 } }); testCell.getDoc().freeze("test"); const log: ReactivityLog = { reads: [], writes: [] }; - const success = setNestedValue(testCell.getDoc(), [], { a: 1, b: { c: 3 } }, log); + const success = setNestedValue(testCell.getDoc(), [], { + a: 1, + b: { c: 3 }, + }, log); expect(success).toBe(false); }); @@ -143,7 +159,7 @@ describe("data-updating", () => { "normalizeAndDiff simple value changes", ); testCell.set({ value: 42 }); - const current = testCell.key("value").getAsCellLink(); + const current = testCell.key("value").getAsLegacyCellLink(); const changes = normalizeAndDiff(current, 100); expect(changes.length).toBe(1); @@ -157,29 +173,27 @@ describe("data-updating", () => { "normalizeAndDiff object property changes", ); testCell.set({ user: { name: "John", age: 30 } }); - const current = testCell.key("user").getAsCellLink(); + const current = testCell.key("user").getAsLegacyCellLink(); const changes = normalizeAndDiff(current, { name: "Jane", age: 30 }); expect(changes.length).toBe(1); - expectCellLinksEqual(changes[0].location).toEqual( - testCell.key("user").key("name").getAsCellLink() - ); + expect(areLinksSame(changes[0].location, testCell.key("user").key("name"))).toBe(true); expect(changes[0].value).toBe("Jane"); }); it("should detect added object properties", () => { - const testCell = runtime.getCell<{ user: { name: string; age?: number } }>( + const testCell = runtime.getCell< + { user: { name: string; age?: number } } + >( space, "normalizeAndDiff added object properties", ); testCell.set({ user: { name: "John" } }); - const current = testCell.key("user").getAsCellLink(); + const current = testCell.key("user").getAsLegacyCellLink(); const changes = normalizeAndDiff(current, { name: "John", age: 30 }); expect(changes.length).toBe(1); - expectCellLinksEqual(changes[0].location).toEqual( - testCell.key("user").key("age").getAsCellLink() - ); + expect(areLinksSame(changes[0].location, testCell.key("user").key("age"))).toBe(true); expect(changes[0].value).toBe(30); }); @@ -189,13 +203,11 @@ describe("data-updating", () => { "normalizeAndDiff removed object properties", ); testCell.set({ user: { name: "John", age: 30 } }); - const current = testCell.key("user").getAsCellLink(); + const current = testCell.key("user").getAsLegacyCellLink(); const changes = normalizeAndDiff(current, { name: "John" }); expect(changes.length).toBe(1); - expectCellLinksEqual(changes[0].location).toEqual( - testCell.key("user").key("age").getAsCellLink() - ); + expect(areLinksSame(changes[0].location, testCell.key("user").key("age"))).toBe(true); expect(changes[0].value).toBe(undefined); }); @@ -205,13 +217,11 @@ describe("data-updating", () => { "normalizeAndDiff array length changes", ); testCell.set({ items: [1, 2, 3] }); - const current = testCell.key("items").getAsCellLink(); + const current = testCell.key("items").getAsLegacyCellLink(); const changes = normalizeAndDiff(current, [1, 2]); expect(changes.length).toBe(1); - expectCellLinksEqual(changes[0].location).toEqual( - testCell.key("items").key("length").getAsCellLink() - ); + expect(areLinksSame(changes[0].location, testCell.key("items").key("length"))).toBe(true); expect(changes[0].value).toBe(2); }); @@ -221,13 +231,11 @@ describe("data-updating", () => { "normalizeAndDiff array element changes", ); testCell.set({ items: [1, 2, 3] }); - const current = testCell.key("items").getAsCellLink(); + const current = testCell.key("items").getAsLegacyCellLink(); const changes = normalizeAndDiff(current, [1, 5, 3]); expect(changes.length).toBe(1); - expectCellLinksEqual(changes[0].location).toEqual( - testCell.key("items").key(1).getAsCellLink() - ); + expect(areLinksSame(changes[0].location, testCell.key("items").key(1))).toBe(true); expect(changes[0].value).toBe(5); }); @@ -243,14 +251,12 @@ describe("data-updating", () => { value: 42, alias: { $alias: { path: ["value"] } }, }); - const current = testCell.key("alias").getAsCellLink(); + const current = testCell.key("alias").getAsLegacyCellLink(); const changes = normalizeAndDiff(current, 100); // Should follow alias to value and change it there expect(changes.length).toBe(1); - expectCellLinksEqual(changes[0].location).toEqual( - testCell.key("value").getAsCellLink() - ); + expect(areLinksSame(changes[0].location, testCell.key("value"))).toBe(true); expect(changes[0].value).toBe(100); }); @@ -268,14 +274,12 @@ describe("data-updating", () => { value2: 200, alias: { $alias: { path: ["value"] } }, }); - const current = testCell.key("alias").getAsCellLink(); + const current = testCell.key("alias").getAsLegacyCellLink(); const changes = normalizeAndDiff(current, 100); // Should follow alias to value and change it there expect(changes.length).toBe(1); - expectCellLinksEqual(changes[0].location).toEqual( - testCell.key("value").getAsCellLink() - ); + expect(areLinksSame(changes[0].location, testCell.key("value"))).toBe(true); expect(changes[0].value).toBe(100); applyChangeSet(changes); @@ -287,17 +291,13 @@ describe("data-updating", () => { applyChangeSet(changes2); expect(changes2.length).toBe(1); - expectCellLinksEqual(changes2[0].location).toEqual( - testCell.key("alias").getAsCellLink() - ); + expect(areLinksSame(changes2[0].location, testCell.key("alias"))).toBe(true); expect(changes2[0].value).toEqual({ $alias: { path: ["value2"] } }); const changes3 = normalizeAndDiff(current, 300); expect(changes3.length).toBe(1); - expectCellLinksEqual(changes3[0].location).toEqual( - testCell.key("value2").getAsCellLink() - ); + expect(areLinksSame(changes3[0].location, testCell.key("value2"))).toBe(true); expect(changes3[0].value).toBe(300); }); @@ -329,7 +329,7 @@ describe("data-updating", () => { }, }, }); - const current = testCell.key("user").key("profile").getAsCellLink(); + const current = testCell.key("user").key("profile").getAsLegacyCellLink(); const changes = normalizeAndDiff(current, { details: { address: { @@ -340,9 +340,9 @@ describe("data-updating", () => { }); expect(changes.length).toBe(1); - expectCellLinksEqual(changes[0].location).toEqual( - testCell.key("user").key("profile").key("details").key("address").key("city").getAsCellLink() - ); + expect(areLinksSame(changes[0].location, testCell.key("user").key("profile").key("details").key("address").key( + "city", + ))).toBe(true); expect(changes[0].value).toBe("Boston"); }); @@ -352,7 +352,7 @@ describe("data-updating", () => { "should handle ID-based entity objects", ); testCell.set({ items: [] }); - const current = testCell.key("items").key(0).getAsCellLink(); + const current = testCell.key("items").key(0).getAsLegacyCellLink(); const newValue = { [ID]: "item1", name: "First Item" }; const changes = normalizeAndDiff( @@ -378,7 +378,7 @@ describe("data-updating", () => { "should update the same document with ID-based entity objects", ); testCell.set({ items: [] }); - const current = testCell.key("items").key(0).getAsCellLink(); + const current = testCell.key("items").key(0).getAsLegacyCellLink(); const newValue = { [ID]: "item1", name: "First Item" }; diffAndUpdate( @@ -397,18 +397,22 @@ describe("data-updating", () => { ], }; diffAndUpdate( - testCell.getAsCellLink(), + testCell.getAsLegacyCellLink(), newValue2, undefined, "should update the same document with ID-based entity objects", ); - - expect(isCellLink(testCell.getRaw().items[0])).toBe(true); - expect(isCellLink(testCell.getRaw().items[1])).toBe(true); + + expect(isAnyCellLink(testCell.getRaw().items[0])).toBe(true); + expect(isAnyCellLink(testCell.getRaw().items[1])).toBe(true); expect(testCell.getRaw().items[0].cell).not.toBe(newDoc); - expect(testCell.getRaw().items[0].cell.get().name).toEqual("Inserted before"); + expect(testCell.getRaw().items[0].cell.get().name).toEqual( + "Inserted before", + ); expect(testCell.getRaw().items[1].cell).toBe(newDoc); - expect(testCell.getRaw().items[1].cell.get().name).toEqual("Second Value"); + expect(testCell.getRaw().items[1].cell.get().name).toEqual( + "Second Value", + ); }); it("should update the same document with numeric ID-based entity objects", () => { @@ -417,7 +421,7 @@ describe("data-updating", () => { "should update the same document with ID-based entity objects", ); testCell.set({ items: [] }); - const current = testCell.key("items").key(0).getAsCellLink(); + const current = testCell.key("items").key(0).getAsLegacyCellLink(); const newValue = { [ID]: 1, name: "First Item" }; diffAndUpdate( @@ -436,16 +440,20 @@ describe("data-updating", () => { ], }; diffAndUpdate( - testCell.getAsCellLink(), + testCell.getAsLegacyCellLink(), newValue2, undefined, "should update the same document with ID-based entity objects", ); expect(testCell.getRaw().items[0].cell).not.toBe(newDoc); - expect(testCell.getRaw().items[0].cell.get().name).toEqual("Inserted before"); + expect(testCell.getRaw().items[0].cell.get().name).toEqual( + "Inserted before", + ); expect(testCell.getRaw().items[1].cell).toBe(newDoc); - expect(testCell.getRaw().items[1].cell.get().name).toEqual("Second Value"); + expect(testCell.getRaw().items[1].cell.get().name).toEqual( + "Second Value", + ); }); it("should handle ID_FIELD redirects and reuse existing documents", () => { @@ -459,7 +467,7 @@ describe("data-updating", () => { const data = { id: "item1", name: "First Item" }; addCommonIDfromObjectID(data); diffAndUpdate( - testCell.key("items").key(0).getAsCellLink(), + testCell.key("items").key(0).getAsLegacyCellLink(), data, undefined, "test ID_FIELD redirects", @@ -477,7 +485,7 @@ describe("data-updating", () => { addCommonIDfromObjectID(newValue); diffAndUpdate( - testCell.getAsCellLink(), + testCell.getAsLegacyCellLink(), newValue, undefined, "test ID_FIELD redirects", @@ -487,7 +495,9 @@ describe("data-updating", () => { expect(isCellLink(testCell.getRaw().items[0])).toBe(true); expect(isCellLink(testCell.getRaw().items[1])).toBe(true); expect(testCell.getRaw().items[1].cell).toBe(initialDoc); - expect(testCell.getRaw().items[1].cell.get().name).toEqual("Updated Item"); + expect(testCell.getRaw().items[1].cell.get().name).toEqual( + "Updated Item", + ); expect(testCell.getRaw().items[0].cell.get().name).toEqual("New Item"); }); @@ -497,7 +507,7 @@ describe("data-updating", () => { "it should treat different properties as different ID namespaces", ); testCell.set(undefined); - const current = testCell.getAsCellLink(); + const current = testCell.getAsLegacyCellLink(); const newValue = { a: { [ID]: "item1", name: "First Item" }, @@ -523,7 +533,7 @@ describe("data-updating", () => { "normalizeAndDiff no changes", ); testCell.set({ value: 42 }); - const current = testCell.key("value").getAsCellLink(); + const current = testCell.key("value").getAsLegacyCellLink(); const changes = normalizeAndDiff(current, 42); expect(changes.length).toBe(0); @@ -541,12 +551,12 @@ describe("data-updating", () => { ); cellB.set({ value: { name: "Original" } }); - const current = cellB.key("value").getAsCellLink(); + const current = cellB.key("value").getAsLegacyCellLink(); const changes = normalizeAndDiff(current, cellA.getDoc()); expect(changes.length).toBe(1); expect(changes[0].location).toEqual(current); - expectCellLinksEqual(changes[0].value).toEqual(cellA.getAsCellLink()); + expect(areLinksSame(changes[0].value, cellA)).toBe(true); }); it("should handle doc and cell references that don't change", () => { @@ -561,12 +571,12 @@ describe("data-updating", () => { ); cellB.set({ value: { name: "Original" } }); - const current = cellB.key("value").getAsCellLink(); + const current = cellB.key("value").getAsLegacyCellLink(); const changes = normalizeAndDiff(current, cellA.getDoc()); expect(changes.length).toBe(1); expect(changes[0].location).toEqual(current); - expectCellLinksEqual(changes[0].value).toEqual(cellA.getAsCellLink()); + expect(areLinksSame(changes[0].value, cellA)).toBe(true); applyChangeSet(changes); @@ -584,37 +594,41 @@ describe("data-updating", () => { }); it("should reuse items", () => { + function isEqualCellLink(a: LegacyCellLink, b: LegacyCellLink): boolean { + return isLegacyCellLink(a) && isLegacyCellLink(b) && + a.cell === b.cell && + arrayEqual(a.path, b.path); + } + const itemCell = runtime.getCell<{ id: string; name: string }>( space, "addCommonIDfromObjectID reuse items", ); itemCell.set({ id: "item1", name: "Original Item" }); - + const testCell = runtime.getCell<{ items: any[] }>( space, "addCommonIDfromObjectID arrays", ); - testCell.setRaw({ items: [itemCell.getAsCellLink()] }); + testCell.setRaw({ items: [itemCell.getAsLegacyCellLink()] }); const data = { items: [{ id: "item1", name: "New Item" }, itemCell], }; addCommonIDfromObjectID(data); diffAndUpdate( - testCell.getAsCellLink(), + testCell.getAsLegacyCellLink(), data, undefined, "addCommonIDfromObjectID reuse items", ); const result = testCell.getRaw(); - expect(isCellLink(result.items[0])).toBe(true); - expect(isCellLink(result.items[1])).toBe(true); + expect(isAnyCellLink(result.items[0])).toBe(true); + expect(isAnyCellLink(result.items[1])).toBe(true); expect(isEqualCellLink(result.items[0] as any, result.items[1] as any)) - .toBe( - true, - ); + .toBe(true); expect(result.items[1].cell.get().name).toBe("New Item"); }); }); -}); +}); \ No newline at end of file diff --git a/packages/runner/test/doc-map.test.ts b/packages/runner/test/doc-map.test.ts index 8b34d30a0..cad93f0a0 100644 --- a/packages/runner/test/doc-map.test.ts +++ b/packages/runner/test/doc-map.test.ts @@ -57,10 +57,10 @@ describe("cell-map", () => { const cell = runtime.getCell(space, "test-cell"); cell.set({}); const id = getEntityId(cell); - + expect(getEntityId(cell)).toEqual(id); expect(getEntityId(cell.getAsQueryResult())).toEqual(id); - expect(getEntityId(cell.getAsCellLink())).toEqual(id); + expect(getEntityId(cell.getAsLegacyCellLink())).toEqual(id); }); it("should return a different entity ID for reference with paths", () => { @@ -74,13 +74,13 @@ describe("cell-map", () => { expect(getEntityId(c.getAsQueryResult())).toEqual(id); expect(getEntityId(c.getAsQueryResult(["foo"]))).not.toEqual(id); expect(getEntityId(c.key("foo"))).not.toEqual(id); - expect(getEntityId(c.key("foo").getAsCellLink())).not.toEqual(id); + expect(getEntityId(c.key("foo").getAsLegacyCellLink())).not.toEqual(id); expect(getEntityId(c.getAsQueryResult(["foo"]))).toEqual( getEntityId(c.key("foo")), ); expect(getEntityId(c.getAsQueryResult(["foo"]))).toEqual( - getEntityId(c.key("foo").getAsCellLink()), + getEntityId(c.key("foo").getAsLegacyCellLink()), ); }); }); @@ -99,7 +99,7 @@ describe("cell-map", () => { // Verify we got the same cell expect(retrievedCell.entityId).toEqual(c.entityId); expect(retrievedCell.get()).toEqual({ value: 42 }); - + // Also verify the cells are equal expect(retrievedCell.equals(c)).toBe(true); }); @@ -117,10 +117,10 @@ describe("cell-map", () => { it("should serialize the entity ID", () => { const c = runtime.getCell<{ value: number }>(space, "test-json"); c.set({ value: 42 }); - + const expected = JSON.stringify({ cell: c.entityId, - path: [] + path: [], }); expect(JSON.stringify(c)).toEqual(expected); }); diff --git a/packages/runner/test/json-utils.test.ts b/packages/runner/test/json-utils.test.ts index 2117d18de..d3e0057e8 100644 --- a/packages/runner/test/json-utils.test.ts +++ b/packages/runner/test/json-utils.test.ts @@ -10,13 +10,12 @@ const signer = await Identity.fromPassphrase("test operator"); const space = signer.did(); describe("createJsonSchema", () => { - let storageManager: ReturnType; let runtime: Runtime; + let storageManager: ReturnType; beforeEach(() => { storageManager = StorageManager.emulate({ as: signer }); - // Create runtime with the shared storage provider - // We need to bypass the URL-based configuration for this test + runtime = new Runtime({ blobbyServerUrl: import.meta.url, storageManager, @@ -50,16 +49,28 @@ describe("createJsonSchema", () => { name: "item2", value: 42, }]); - expect(mixedArraySchema).toEqual({ - type: "array", - items: { - type: "object", - properties: { - name: { type: "string" }, - value: { type: "integer" }, + expect(mixedArraySchema).toEqual( + { + type: "array", + items: { + anyOf: [ + { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + { + type: "object", + properties: { + name: { type: "string" }, + value: { type: "integer" }, + }, + }, + ], }, - }, - }); + } satisfies JSONSchema, + ); }); it("should create schema for objects", () => { @@ -162,7 +173,7 @@ describe("createJsonSchema", () => { { type: "string", format: "email" }, ); - const schema = createJsonSchema(cellWithSchema); + const schema = createJsonSchema(cellWithSchema, false, runtime); expect(schema).toEqual({ type: "string", format: "email" }); }); @@ -176,7 +187,7 @@ describe("createJsonSchema", () => { }, ); - const schema = createJsonSchema(cellWithoutSchema); + const schema = createJsonSchema(cellWithoutSchema, false, runtime); expect(schema).toEqual({ type: "object", properties: { @@ -188,9 +199,12 @@ describe("createJsonSchema", () => { }); it("should handle array cell without schema", () => { - const arrayCell = runtime.getImmutableCell(space, [1, 2, 3, 4]); + const arrayCell = runtime.getImmutableCell( + space, + [1, 2, 3, 4], + ); - const schema = createJsonSchema(arrayCell); + const schema = createJsonSchema(arrayCell, false, runtime); expect(schema).toEqual({ type: "array", @@ -201,7 +215,10 @@ describe("createJsonSchema", () => { }); it("should handle nested cells with and without schema", () => { - const userCell = runtime.getImmutableCell(space, { id: 1, name: "Alice" }); + const userCell = runtime.getImmutableCell( + space, + { id: 1, name: "Alice" }, + ); const prefsSchema = { type: "object", @@ -222,7 +239,7 @@ describe("createJsonSchema", () => { preferences: prefsCell, }; - const schema = createJsonSchema(nestedObject); + const schema = createJsonSchema(nestedObject, false, runtime); expect(schema).toEqual({ type: "object", properties: { diff --git a/packages/runner/test/link-resolution.test.ts b/packages/runner/test/link-resolution.test.ts index 5d6ca57ac..6d2679bff 100644 --- a/packages/runner/test/link-resolution.test.ts +++ b/packages/runner/test/link-resolution.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; -import { followAliases } from "../src/link-resolution.ts"; +import { followWriteRedirects } from "../src/link-resolution.ts"; import { Runtime } from "../src/runtime.ts"; import { Identity } from "@commontools/identity"; import { StorageManager } from "@commontools/runner/storage/cache.deno"; -import { expectCellLinksEqual } from "./test-helpers.ts"; +import { areLinksSame } from "../src/link-utils.ts"; const signer = await Identity.fromPassphrase("test operator"); const space = signer.did(); @@ -28,7 +28,7 @@ describe("link-resolution", () => { await storageManager?.close(); }); - describe("followAliases", () => { + describe("followWriteRedirects", () => { it("should follow a simple alias", () => { const testCell = runtime.getCell<{ value: number }>( space, @@ -36,7 +36,7 @@ describe("link-resolution", () => { ); testCell.set({ value: 42 }); const binding = { $alias: { path: ["value"] } }; - const result = followAliases(binding, testCell); + const result = followWriteRedirects(binding, testCell); expect(result.cell.getAtPath(result.path)).toBe(42); }); @@ -51,11 +51,11 @@ describe("link-resolution", () => { "should follow nested aliases 2", ); outerCell.setRaw({ - outer: { $alias: innerCell.key("inner").getAsCellLink() }, + outer: { $alias: innerCell.key("inner").getAsLegacyCellLink() }, }); const binding = { $alias: { path: ["outer"] } }; - const result = followAliases(binding, outerCell); - expectCellLinksEqual(result).toEqual(innerCell.key("inner").getAsCellLink()); + const result = followWriteRedirects(binding, outerCell); + expect(areLinksSame(result, innerCell.key("inner"))).toBe(true); expect(result.cell.getAtPath(result.path)).toBe(10); }); @@ -70,10 +70,16 @@ describe("link-resolution", () => { "should throw an error on circular aliases 2", ); cellB.set({}); - cellA.setRaw({ alias: { $alias: cellB.key("alias").getAsCellLink() } }); - cellB.setRaw({ alias: { $alias: cellA.key("alias").getAsCellLink() } }); + cellA.setRaw({ + alias: { $alias: cellB.key("alias").getAsLegacyCellLink() }, + }); + cellB.setRaw({ + alias: { $alias: cellA.key("alias").getAsLegacyCellLink() }, + }); const binding = { $alias: { path: ["alias"] } }; - expect(() => followAliases(binding, cellA)).toThrow("cycle detected"); + expect(() => followWriteRedirects(binding, cellA)).toThrow( + "cycle detected", + ); }); it("should allow aliases in aliased paths", () => { @@ -85,8 +91,10 @@ describe("link-resolution", () => { a: { a: { $alias: { path: ["a", "b"] } }, b: { c: 1 } }, }); const binding = { $alias: { path: ["a", "a", "c"] } }; - const result = followAliases(binding, testCell); - expectCellLinksEqual(result).toEqual(testCell.key("a").key("b").key("c").getAsCellLink()); + const result = followWriteRedirects(binding, testCell); + expect(areLinksSame(result, testCell.key("a").key("b").key("c"))).toBe( + true, + ); expect(result.cell.getAtPath(result.path)).toBe(1); }); }); diff --git a/packages/runner/test/link-utils.test.ts b/packages/runner/test/link-utils.test.ts new file mode 100644 index 000000000..efb5decb3 --- /dev/null +++ b/packages/runner/test/link-utils.test.ts @@ -0,0 +1,587 @@ +import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; +import { + areLinksSame, + createSigilLinkFromParsedLink, + isLegacyAlias, + isLink, + isSigilValue, + isWriteRedirectLink, + type NormalizedLink, + parseLink, + parseLinkOrThrow, + parseToLegacyCellLink, +} from "../src/link-utils.ts"; +import { LINK_V1_TAG } from "../src/sigil-types.ts"; +import { Runtime } from "../src/runtime.ts"; +import { Identity } from "@commontools/identity"; +import { StorageManager } from "@commontools/runner/storage/cache.deno"; + +const signer = await Identity.fromPassphrase("test operator"); +const space = signer.did(); + +describe("link-utils", () => { + let storageManager: ReturnType; + let runtime: Runtime; + + beforeEach(() => { + storageManager = StorageManager.emulate({ as: signer }); + runtime = new Runtime({ + blobbyServerUrl: import.meta.url, + storageManager, + }); + }); + + afterEach(async () => { + await runtime?.dispose(); + await storageManager?.close(); + }); + + describe("isSigilValue", () => { + it("should identify valid sigil values", () => { + const validSigil = { "/": { someKey: "someValue" } }; + expect(isSigilValue(validSigil)).toBe(true); + }); + + it("should identify sigil values with empty record", () => { + const emptySigil = { "/": {} }; + expect(isSigilValue(emptySigil)).toBe(true); + }); + + it("should identify sigil values with nested objects", () => { + const nestedSigil = { "/": { nested: { deep: "value" } } }; + expect(isSigilValue(nestedSigil)).toBe(true); + }); + + it("should not identify objects with / and other properties", () => { + const invalidSigil1 = { "/": { key: "value" }, otherProp: "value" }; + expect(isSigilValue(invalidSigil1)).toBe(false); + + const invalidSigil2 = { "/": { key: "value" }, extra: 123 }; + expect(isSigilValue(invalidSigil2)).toBe(false); + + const invalidSigil3 = { + "/": { key: "value" }, + nested: { prop: "value" }, + }; + expect(isSigilValue(invalidSigil3)).toBe(false); + }); + + it("should not identify objects without / property", () => { + const noSlash = { otherProp: "value" }; + expect(isSigilValue(noSlash)).toBe(false); + + const emptyObject = {}; + expect(isSigilValue(emptyObject)).toBe(false); + }); + + it("should not identify objects where / is not a record", () => { + const stringSlash = { "/": "not a record" }; + expect(isSigilValue(stringSlash)).toBe(false); + + const numberSlash = { "/": 123 }; + expect(isSigilValue(numberSlash)).toBe(false); + + const arraySlash = { "/": ["not", "a", "record"] }; + expect(isSigilValue(arraySlash)).toBe(false); + + const nullSlash = { "/": null }; + expect(isSigilValue(nullSlash)).toBe(false); + + const undefinedSlash = { "/": undefined }; + expect(isSigilValue(undefinedSlash)).toBe(false); + }); + + it("should not identify non-objects", () => { + expect(isSigilValue("string")).toBe(false); + expect(isSigilValue(123)).toBe(false); + expect(isSigilValue(true)).toBe(false); + expect(isSigilValue(null)).toBe(false); + expect(isSigilValue(undefined)).toBe(false); + expect(isSigilValue([])).toBe(false); + }); + + it("should handle edge cases with multiple properties", () => { + const multipleProps = { + "/": { key: "value" }, + prop1: "value1", + prop2: "value2", + }; + expect(isSigilValue(multipleProps)).toBe(false); + + const onlySlashButMultiple = { "/": { key: "value" }, "/extra": "value" }; + expect(isSigilValue(onlySlashButMultiple)).toBe(false); + }); + }); + + describe("isLink", () => { + it("should identify query results as links", () => { + const cell = runtime.getCell(space, "test"); + // Has to be an object, otherwise asQueryResult() returns a literal + cell.set({ value: 42 }); + const queryResult = cell.getAsQueryResult(); + expect(isLink(queryResult)).toBe(true); + }); + + it("should identify cell links as links", () => { + const cell = runtime.getCell(space, "test"); + const cellLink = cell.getAsLegacyCellLink(); + expect(isLink(cellLink)).toBe(true); + }); + + it("should identify cells as links", () => { + const cell = runtime.getCell(space, "test"); + expect(isLink(cell)).toBe(true); + }); + + it("should identify docs as links", () => { + const cell = runtime.getCell(space, "test"); + const doc = cell.getDoc(); + expect(isLink(doc)).toBe(true); + }); + + it("should identify EntityId format as links", () => { + expect(isLink({ "/": "of:test" })).toBe(true); + }); + + it("should not identify non-links as links", () => { + expect(isLink("string")).toBe(false); + expect(isLink(123)).toBe(false); + expect(isLink({ notLink: "value" })).toBe(false); + expect(isLink(null)).toBe(false); + expect(isLink(undefined)).toBe(false); + }); + }); + + describe("isWriteRedirectLink", () => { + it("should identify legacy aliases as write redirect links", () => { + const legacyAlias = { $alias: { path: ["test"] } }; + expect(isWriteRedirectLink(legacyAlias)).toBe(true); + }); + + it("should identify sigil links with overwrite redirect as write redirect links", () => { + const sigilLink = { + "/": { + [LINK_V1_TAG]: { id: "test", overwrite: "redirect" }, + }, + }; + expect(isWriteRedirectLink(sigilLink)).toBe(true); + }); + + it("should not identify regular sigil links as write redirect links", () => { + const sigilLink = { + "/": { + [LINK_V1_TAG]: { id: "test" }, + }, + }; + expect(isWriteRedirectLink(sigilLink)).toBe(false); + }); + + it("should not identify non-links as write redirect links", () => { + expect(isWriteRedirectLink("string")).toBe(false); + expect(isWriteRedirectLink({ notLink: "value" })).toBe(false); + }); + }); + + describe("isLegacyAlias", () => { + it("should identify legacy aliases", () => { + const legacyAlias = { $alias: { path: ["test"] } }; + expect(isLegacyAlias(legacyAlias)).toBe(true); + }); + + it("should identify legacy aliases with cell", () => { + const cell = runtime.getCell(space, "test"); + const legacyAlias = { $alias: { cell: cell.getDoc(), path: ["test"] } }; + expect(isLegacyAlias(legacyAlias)).toBe(true); + }); + + it("should not identify non-legacy aliases", () => { + expect(isLegacyAlias({ notAlias: "value" })).toBe(false); + expect(isLegacyAlias({ $alias: "not object" })).toBe(false); + expect(isLegacyAlias({ $alias: { notPath: "value" } })).toBe(false); + }); + }); + + describe("parseLink", () => { + it("should parse cells to normalized links", () => { + const cell = runtime.getCell(space, "test"); + cell.set({ value: 42 }); + const result = parseLink(cell); + + expect(result).toEqual({ + id: expect.stringContaining("of:"), + path: [], + space: space, + schema: undefined, + rootSchema: undefined, + }); + }); + + it("should parse cells with paths to normalized links", () => { + const cell = runtime.getCell(space, "test"); + cell.set({ nested: { value: 42 } }); + const nestedCell = cell.key("nested"); + const result = parseLink(nestedCell); + + expect(result).toEqual({ + id: expect.stringContaining("of:"), + path: ["nested"], + space: space, + schema: undefined, + rootSchema: undefined, + }); + }); + + it("should parse docs to normalized links", () => { + const cell = runtime.getCell(space, "test"); + const doc = cell.getDoc(); + const result = parseLink(doc); + + expect(result).toEqual({ + id: expect.stringContaining("of:"), + path: [], + space: space, + }); + }); + + it("should parse sigil links to normalized links", () => { + const sigilLink = { + "/": { + [LINK_V1_TAG]: { + id: "of:test", + path: ["nested", "value"], + space: space, + schema: { type: "number" }, + rootSchema: { type: "object" }, + }, + }, + }; + const result = parseLink(sigilLink); + + expect(result).toEqual({ + id: "of:test", + path: ["nested", "value"], + space: space, + schema: { type: "number" }, + rootSchema: { type: "object" }, + }); + }); + + it("should parse sigil links to normalized links", () => { + const sigilLink = { + "/": { + [LINK_V1_TAG]: { + id: "of:test", + path: ["nested", "value"], + space: space, + schema: { type: "number" }, + rootSchema: { type: "object" }, + overwrite: "this", + }, + }, + }; + const result = parseLink(sigilLink); + + expect(result).toEqual({ + id: "of:test", + path: ["nested", "value"], + space: space, + schema: { type: "number" }, + rootSchema: { type: "object" }, + }); + }); + + it("should parse sigil links with overwrite this to normalized links", () => { + const sigilLink = { + "/": { + [LINK_V1_TAG]: { + id: "of:test", + path: ["nested", "value"], + space: space, + schema: { type: "number" }, + rootSchema: { type: "object" }, + overwrite: "redirect", + }, + }, + }; + const result = parseLink(sigilLink); + + expect(result).toEqual({ + id: "of:test", + path: ["nested", "value"], + space: space, + schema: { type: "number" }, + rootSchema: { type: "object" }, + overwrite: "redirect", + }); + }); + + it("should parse sigil links with relative references", () => { + const baseCell = runtime.getCell(space, "base"); + const sigilLink = { + "/": { + [LINK_V1_TAG]: { + path: ["nested", "value"], + space: space, + }, + }, + }; + const result = parseLink(sigilLink, baseCell); + + expect(result).toEqual({ + id: expect.stringContaining("of:"), + path: ["nested", "value"], + space: space, + schema: undefined, + rootSchema: undefined, + }); + }); + + it("should parse cell links to normalized links", () => { + const cell = runtime.getCell(space, "test"); + const cellLink = cell.getAsLegacyCellLink(); + const result = parseLink(cellLink); + + expect(result).toEqual({ + id: expect.stringContaining("of:"), + path: [], + space: space, + schema: undefined, + rootSchema: undefined, + }); + }); + + it("should parse JSON cell links to normalized links", () => { + const jsonLink = { + cell: { "/": "of:test" }, + path: ["nested", "value"], + }; + const baseCell = runtime.getCell(space, "base"); + const result = parseLink(jsonLink, baseCell); + + expect(result).toEqual({ + id: "of:test", + path: ["nested", "value"], + space: space, + }); + }); + + it("should parse legacy aliases to normalized links", () => { + const cell = runtime.getCell(space, "test"); + const legacyAlias = { + $alias: { + cell: cell.getDoc(), + path: ["nested", "value"], + schema: { type: "number" }, + rootSchema: { type: "object" }, + }, + }; + const result = parseLink(legacyAlias); + + expect(result).toEqual({ + id: expect.stringContaining("of:"), + path: ["nested", "value"], + space: space, + schema: { type: "number" }, + rootSchema: { type: "object" }, + }); + }); + + it("should handle legacy aliases without cell using base", () => { + const baseCell = runtime.getCell(space, "base"); + const legacyAlias = { + $alias: { + path: ["nested", "value"], + }, + }; + const result = parseLink(legacyAlias, baseCell); + + expect(result).toEqual({ + id: expect.stringContaining("of:"), + path: ["nested", "value"], + space: space, + schema: undefined, + rootSchema: undefined, + }); + }); + + it("should return undefined for non-link values", () => { + expect(parseLink("string")).toBeUndefined(); + expect(parseLink(123)).toBeUndefined(); + expect(parseLink({ notLink: "value" })).toBeUndefined(); + }); + }); + + describe("parseLinkOrThrow", () => { + it("should return parsed link for valid links", () => { + const cell = runtime.getCell(space, "test"); + const result = parseLinkOrThrow(cell); + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + }); + + it("should throw error for non-link values", () => { + expect(() => parseLinkOrThrow("string")).toThrow( + "Cannot parse value as link", + ); + expect(() => parseLinkOrThrow(123)).toThrow("Cannot parse value as link"); + }); + }); + + describe("parseToLegacyCellLink", () => { + it("should parse cells to legacy cell links", () => { + const cell = runtime.getCell(space, "test"); + const result = parseToLegacyCellLink(cell, cell); + + expect(result).toBeDefined(); + expect(result?.cell).toBeDefined(); + expect(result?.path).toEqual([]); + }); + + it("should parse docs to legacy cell links", () => { + const cell = runtime.getCell(space, "test"); + const doc = cell.getDoc(); + const result = parseToLegacyCellLink(doc); + + expect(result).toBeDefined(); + expect(result?.cell).toBeDefined(); + expect(result?.path).toEqual([]); + }); + + it("should parse legacy aliases to legacy cell links", () => { + const cell = runtime.getCell(space, "test"); + const legacyAlias = { + $alias: { + cell: cell.getDoc(), + path: ["nested", "value"], + }, + }; + const result = parseToLegacyCellLink(legacyAlias); + + expect(result).toBeDefined(); + expect(result?.cell).toBeDefined(); + expect(result?.path).toEqual(["nested", "value"]); + }); + + it("should return undefined for non-link values", () => { + expect(parseToLegacyCellLink("string")).toBeUndefined(); + expect(parseToLegacyCellLink(123)).toBeUndefined(); + }); + + it("should throw error for links without base cell when needed", () => { + const jsonLink = { + cell: { "/": "of:test" }, + path: ["nested", "value"], + }; + expect(() => parseToLegacyCellLink(jsonLink)).toThrow( + "No id or base cell provided", + ); + }); + }); + + describe("areLinksSame", () => { + it("should return true for identical objects", () => { + const cell = runtime.getCell(space, "test"); + expect(areLinksSame(cell, cell)).toBe(true); + }); + + it("should return true for equivalent links", () => { + const cell = runtime.getCell(space, "test"); + const cellLink1 = cell.getAsLegacyCellLink(); + const cellLink2 = cell.getAsLegacyCellLink(); + expect(areLinksSame(cellLink1, cellLink2)).toBe(true); + }); + + it("should return true for different link formats pointing to same location", () => { + const cell = runtime.getCell(space, "test"); + const cellLink = cell.getAsLegacyCellLink(); + const sigilLink = cell.getAsLink(); + expect(areLinksSame(cellLink, sigilLink)).toBe(true); + }); + + it("should return false for different links", () => { + const cell1 = runtime.getCell(space, "test1"); + const cell2 = runtime.getCell(space, "test2"); + expect(areLinksSame(cell1, cell2)).toBe(false); + }); + + it("should return false for link vs non-link", () => { + const cell = runtime.getCell(space, "test"); + expect(areLinksSame(cell, "string")).toBe(false); + }); + + it("should handle null/undefined values", () => { + expect(areLinksSame(null, null)).toBe(true); + expect(areLinksSame(undefined, undefined)).toBe(true); + expect(areLinksSame(null, undefined)).toBe(false); + + const cell = runtime.getCell(space, "test"); + expect(areLinksSame(cell, null)).toBe(false); + expect(areLinksSame(null, cell)).toBe(false); + }); + }); + + describe("createSigilLinkFromParsedLink", () => { + it("should create sigil link from normalized link", () => { + const normalizedLink: NormalizedLink = { + id: "of:test", + path: ["nested", "value"], + space: space, + schema: { type: "number" }, + rootSchema: { type: "object" }, + }; + + const result = createSigilLinkFromParsedLink(normalizedLink); + + expect(result).toEqual({ + "/": { + [LINK_V1_TAG]: { + id: "of:test", + path: ["nested", "value"], + space: space, + schema: { type: "number" }, + rootSchema: { type: "object" }, + }, + }, + }); + }); + + it("should omit space when same as base", () => { + const baseCell = runtime.getCell(space, "base"); + const normalizedLink: NormalizedLink = { + id: "of:test", + path: ["nested", "value"], + space: space, + }; + + const result = createSigilLinkFromParsedLink(normalizedLink, baseCell); + + expect(result["/"][LINK_V1_TAG].space).toBeUndefined(); + }); + + it("should omit id when same as base", () => { + const baseCell = runtime.getCell(space, "base"); + const baseId = baseCell.getDoc().entityId; + const normalizedLink: NormalizedLink = { + id: `of:${baseId}`, + path: ["nested", "value"], + }; + + const result = createSigilLinkFromParsedLink(normalizedLink, baseCell); + + expect(result["/"][LINK_V1_TAG].id).toBe(`of:${baseId}`); + }); + + it("should include overwrite field when present", () => { + const normalizedLink: NormalizedLink = { + id: "of:test", + path: ["nested", "value"], + overwrite: "redirect", + }; + + const result = createSigilLinkFromParsedLink(normalizedLink); + + expect(result["/"][LINK_V1_TAG].overwrite).toBe("redirect"); + }); + }); +}); diff --git a/packages/runner/test/push-conflict.test.ts b/packages/runner/test/push-conflict.test.ts index 51e6fd46a..adcc3512c 100644 --- a/packages/runner/test/push-conflict.test.ts +++ b/packages/runner/test/push-conflict.test.ts @@ -3,7 +3,7 @@ import { expect } from "@std/expect"; import { ID } from "../src/builder/types.ts"; import { Identity } from "@commontools/identity"; import { type IStorage, Runtime } from "../src/runtime.ts"; -import { isCellLink } from "../src/cell.ts"; +import { isAnyCellLink } from "../src/link-utils.ts"; import * as Memory from "@commontools/memory"; import * as Consumer from "@commontools/memory/consumer"; import { Provider } from "../src/storage/cache.ts"; @@ -110,7 +110,7 @@ describe("Push conflict", () => { "name", ); name.set(undefined); - + const list = runtime.getCell( signer.did(), "list 2", @@ -175,7 +175,7 @@ describe("Push conflict", () => { "name 2", ); name.set(undefined); - + const list = runtime.getCell( signer.did(), "list 3", @@ -221,7 +221,7 @@ describe("Push conflict", () => { // This is locally ahead of the db, and retry wasn't called yet. expect(name.get()).toEqual("bar"); expect(list.get()).toEqual([{ n: 4 }]); - expect(isCellLink(list.getRaw()?.[0])).toBe(true); + expect(isAnyCellLink(list.getRaw()?.[0])).toBe(true); const entry = list.getRaw()[0].cell?.asCell(); expect(retryCalled).toEqual(0); @@ -239,6 +239,6 @@ describe("Push conflict", () => { expect(!!listDoc.retry?.length).toBe(false); // Check that the ID is still there - expect(entry.equals(list.getRaw()[3].cell.asCell())).toBe(true); + expect(JSON.stringify(entry)).toEqual(JSON.stringify(list.getRaw()[3])); }); }); diff --git a/packages/runner/test/query.test.ts b/packages/runner/test/query.test.ts index 5603ae7ec..cfeae77b2 100644 --- a/packages/runner/test/query.test.ts +++ b/packages/runner/test/query.test.ts @@ -17,7 +17,6 @@ import { Runtime } from "../src/runtime.ts"; import { StorageManager } from "@commontools/runner/storage/cache.deno"; import { Identity } from "@commontools/identity"; import { ClientObjectManager } from "../src/storage/query.ts"; -import { entityIdToJSON } from "./test-helpers.ts"; const signer = await Identity.fromPassphrase("test operator"); const space = signer.did(); @@ -60,7 +59,7 @@ describe("Query", () => { `query test cell 1`, ); testCell1.set(docValue1); - const entityId1 = testCell1.entityId!; + const entityId1 = JSON.parse(JSON.stringify(testCell1.entityId!)); const assert1 = { the: "application/json", of: `of:${entityId1["/"]}` as Entity, @@ -70,7 +69,7 @@ describe("Query", () => { }; const docValue2 = { name: { - cell: entityIdToJSON(entityId1), + cell: entityId1, path: ["employees", "0", "fullName"], }, }; @@ -151,7 +150,7 @@ describe("Query", () => { `query test cell 1`, ); testCell1.set({ employees: [{ name: { first: "Bob" } }] }); - const entityId1 = testCell1.entityId!; + const entityId1 = JSON.parse(JSON.stringify(testCell1.entityId!)); const assert1 = { the: "application/json", of: `of:${entityId1["/"]}` as Entity, @@ -167,7 +166,7 @@ describe("Query", () => { ); testCell2.setRaw({ name: { - cell: entityIdToJSON(entityId1), + cell: entityId1, path: ["employees", "0", "name"], }, }); @@ -249,14 +248,14 @@ describe("Query", () => { path: ["name"], }, }); - const entityId1 = testCell1.entityId!; + const entityId1 = JSON.parse(JSON.stringify(testCell1.entityId!)); const assert1 = { the: "application/json", of: `of:${entityId1["/"]}` as Entity, is: { value: { name: { - cell: entityIdToJSON(entityId1), + cell: entityId1, path: ["name"], }, }, @@ -308,7 +307,7 @@ describe("Query", () => { `query test cell 1`, ); testCell1.set(docValue1); - const entityId1 = testCell1.entityId!; + const entityId1 = JSON.parse(JSON.stringify(testCell1.entityId!)); const assert1 = { the: "application/json", of: `of:${entityId1["/"]}` as Entity, @@ -318,7 +317,12 @@ describe("Query", () => { }; const docValue2 = { - employees: [{ address: { cell: entityIdToJSON(entityId1), path: ["home"] } }], + employees: [{ + address: { + cell: entityId1, + path: ["home"], + }, + }], }; const testCell2 = runtime.getCell( space, diff --git a/packages/runner/test/recipe-binding.test.ts b/packages/runner/test/recipe-binding.test.ts index 6530a2995..12bf24c94 100644 --- a/packages/runner/test/recipe-binding.test.ts +++ b/packages/runner/test/recipe-binding.test.ts @@ -7,7 +7,7 @@ import { import { Runtime } from "../src/runtime.ts"; import { Identity } from "@commontools/identity"; import { StorageManager } from "@commontools/runner/storage/cache.deno"; -import { expectCellLinksEqual } from "./test-helpers.ts"; +import { areLinksSame } from "../src/link-utils.ts"; const signer = await Identity.fromPassphrase("test operator"); const space = signer.did(); @@ -127,11 +127,16 @@ describe("recipe-binding", () => { }; const result = unwrapOneLevelAndBindtoDoc(binding, testCell); - expectCellLinksEqual(result).toEqual({ - x: { $alias: testCell.key("a").getAsCellLink() }, - y: { $alias: testCell.key("b").key("c").getAsCellLink() }, - z: 3, - }); + expect( + areLinksSame(result.x, { + $alias: testCell.key("a").getAsLegacyCellLink(), + }), + ).toBe(true); + expect( + areLinksSame(result.y, { + $alias: testCell.key("b").key("c").getAsLegacyCellLink(), + }), + ).toBe(true); }); }); }); diff --git a/packages/runner/test/recipes.test.ts b/packages/runner/test/recipes.test.ts index d9ac8f01d..14cb3181a 100644 --- a/packages/runner/test/recipes.test.ts +++ b/packages/runner/test/recipes.test.ts @@ -4,7 +4,7 @@ import { type Cell, type JSONSchema } from "../src/builder/types.ts"; import { createBuilder } from "../src/builder/factory.ts"; import { Runtime } from "../src/runtime.ts"; import { type ErrorWithContext } from "../src/scheduler.ts"; -import { isCell, isCellLink } from "../src/cell.ts"; +import { isCell } from "../src/cell.ts"; import { resolveLinks } from "../src/link-resolution.ts"; import { Identity } from "@commontools/identity"; import { StorageManager } from "@commontools/runner/storage/cache.deno"; @@ -60,7 +60,9 @@ describe("Recipe Runner", () => { space, "should run a simple recipe", ); - const result = await runtime.runSynced(resultCell, simpleRecipe, { value: 5 }); + const result = await runtime.runSynced(resultCell, simpleRecipe, { + value: 5, + }); await runtime.idle(); @@ -90,7 +92,9 @@ describe("Recipe Runner", () => { space, "should handle nested recipes", ); - const result = await runtime.runSynced(resultCell, outerRecipe, { value: 4 }); + const result = await runtime.runSynced(resultCell, outerRecipe, { + value: 4, + }); await runtime.idle(); @@ -112,7 +116,11 @@ describe("Recipe Runner", () => { space, "should handle recipes with defaults", ); - const result1 = await runtime.runSynced(resultCell1, recipeWithDefaults, {}); + const result1 = await runtime.runSynced( + resultCell1, + recipeWithDefaults, + {}, + ); await runtime.idle(); @@ -122,7 +130,9 @@ describe("Recipe Runner", () => { space, "should handle recipes with defaults (2)", ); - const result2 = await runtime.runSynced(resultCell2, recipeWithDefaults, { a: 20 }); + const result2 = await runtime.runSynced(resultCell2, recipeWithDefaults, { + a: 20, + }); await runtime.idle(); @@ -200,7 +210,9 @@ describe("Recipe Runner", () => { space, "should handle map nodes with undefined input", ); - const result = await runtime.runSynced(resultCell, doubleArray, { values: undefined }); + const result = await runtime.runSynced(resultCell, doubleArray, { + values: undefined, + }); await runtime.idle(); @@ -225,7 +237,9 @@ describe("Recipe Runner", () => { ); const resultCell = runtime.getCell(space, "should execute handlers"); - const result = await runtime.runSynced(resultCell, incRecipe, { counter: { value: 0 } }); + const result = await runtime.runSynced(resultCell, incRecipe, { + counter: { value: 0 }, + }); await runtime.idle(); @@ -261,7 +275,9 @@ describe("Recipe Runner", () => { space, "should execute handlers that use bind and this", ); - const result = await runtime.runSynced(resultCell, incRecipe, { counter: { value: 0 } }); + const result = await runtime.runSynced(resultCell, incRecipe, { + counter: { value: 0 }, + }); await runtime.idle(); @@ -293,7 +309,9 @@ describe("Recipe Runner", () => { space, "should execute handlers that use bind and this (no types)", ); - const result = await runtime.runSynced(resultCell, incRecipe, { counter: { value: 0 } }); + const result = await runtime.runSynced(resultCell, incRecipe, { + counter: { value: 0 }, + }); await runtime.idle(); @@ -348,7 +366,10 @@ describe("Recipe Runner", () => { space, "should execute recipes returned by handlers", ); - const result = await runtime.runSynced(resultCell, incRecipe, { counter, nested }); + const result = await runtime.runSynced(resultCell, incRecipe, { + counter, + nested, + }); await runtime.idle(); @@ -416,7 +437,10 @@ describe("Recipe Runner", () => { space, "should handle recipes returned by lifted functions", ); - const result = await runtime.runSynced(resultCell, multiplyRecipe, { x, y }); + const result = await runtime.runSynced(resultCell, multiplyRecipe, { + x, + y, + }); await runtime.idle(); @@ -466,7 +490,9 @@ describe("Recipe Runner", () => { space, "should support referenced modules", ); - const result = await runtime.runSynced(resultCell, simpleRecipe, { value: 5 }); + const result = await runtime.runSynced(resultCell, simpleRecipe, { + value: 5, + }); await runtime.idle(); @@ -581,7 +607,9 @@ describe("Recipe Runner", () => { space, "should handle nested cell references in schema", ); - const result = await runtime.runSynced(resultCell, sumRecipe, { data: { items: [item1, item2] } }); + const result = await runtime.runSynced(resultCell, sumRecipe, { + data: { items: [item1, item2] }, + }); await runtime.idle(); @@ -675,7 +703,9 @@ describe("Recipe Runner", () => { space, "should execute handlers with schemas", ); - const result = await runtime.runSynced(resultCell, incRecipe, { counter: 0 }); + const result = await runtime.runSynced(resultCell, incRecipe, { + counter: 0, + }); await runtime.idle(); @@ -788,7 +818,10 @@ describe("Recipe Runner", () => { space, "failed lifted handlers should be ignored", ); - const charm = await runtime.runSynced(charmCell, divRecipe, { divisor: 10, dividend }); + const charm = await runtime.runSynced(charmCell, divRecipe, { + divisor: 10, + dividend, + }); await runtime.idle(); @@ -811,7 +844,9 @@ describe("Recipe Runner", () => { // Make sure it recovers: dividend.send(2); await runtime.idle(); - expect((charm.getRaw() as any).result.$alias.cell).toBe(charm.getSourceCell()?.getDoc()); + expect((charm.getRaw() as any).result.$alias.cell).toBe( + charm.getSourceCell()?.getDoc(), + ); expect(charm.getAsQueryResult()).toMatchObject({ result: 5 }); }); @@ -975,7 +1010,7 @@ describe("Recipe Runner", () => { type: "object", properties: { value: { type: "number", asCell: true } }, required: ["value"], - } + }, ); const result = runtime.run(wrapperRecipe, { value: input }, resultCell); @@ -987,7 +1022,7 @@ describe("Recipe Runner", () => { expect(wrapperCell.get()).toBe(5); // Follow all the links until we get to the doc holding the value - const ref = resolveLinks(wrapperCell.getAsCellLink()); + const ref = resolveLinks(wrapperCell.getAsLegacyCellLink()); expect(ref.path).toEqual([]); // = This is stored in its own document // And let's make sure the value is correct diff --git a/packages/runner/test/runner.test.ts b/packages/runner/test/runner.test.ts index d8e48acd6..70c2f9573 100644 --- a/packages/runner/test/runner.test.ts +++ b/packages/runner/test/runner.test.ts @@ -279,7 +279,7 @@ describe("runRecipe", () => { space, "should allow passing a cell as a binding: input cell", ); - inputCell.setRaw({ input: 10, output: 0 }); + inputCell.set({ input: 10, output: 0 }); const resultCell = runtime.getCell( space, "should allow passing a cell as a binding", @@ -320,7 +320,7 @@ describe("runRecipe", () => { space, "should allow stopping a recipe: input cell", ); - inputCell.setRaw({ input: 10, output: 0 }); + inputCell.set({ input: 10, output: 0 }); const resultCell = runtime.getCell(space, "should allow stopping a recipe"); const result = await runtime.runSynced(resultCell, recipe, inputCell); @@ -711,16 +711,16 @@ describe("runner utils", () => { "should treat cell aliases and references as values 1", ); const obj1 = { a: { $alias: { path: [] } } }; - const obj2 = { a: 2, b: { c: testCell.getAsCellLink() } }; + const obj2 = { a: 2, b: { c: testCell.getAsLegacyCellLink() } }; const obj3 = { - a: { $alias: testCell.key("a").getAsCellLink() }, + a: { $alias: testCell.key("a").getAsLegacyCellLink() }, b: { c: 4 }, }; const result = mergeObjects(obj1, obj2, obj3); expect(result).toEqual({ a: { $alias: { path: [] } }, - b: { c: testCell.getAsCellLink() }, + b: { c: testCell.getAsLegacyCellLink() }, }); }); }); diff --git a/packages/runner/test/scheduler.test.ts b/packages/runner/test/scheduler.test.ts index 937d14bb5..6504d26d5 100644 --- a/packages/runner/test/scheduler.test.ts +++ b/packages/runner/test/scheduler.test.ts @@ -87,10 +87,10 @@ describe("scheduler", () => { }; runtime.scheduler.schedule(adder, { reads: [ - a.getAsCellLink(), - b.getAsCellLink(), + a.getAsLegacyCellLink(), + b.getAsLegacyCellLink(), ], - writes: [c.getAsCellLink()], + writes: [c.getAsLegacyCellLink()], }); expect(runCount).toBe(0); expect(c.get()).toBe(0); @@ -155,10 +155,10 @@ describe("scheduler", () => { }; const cancel = runtime.scheduler.schedule(adder, { reads: [ - a.getAsCellLink(), - b.getAsCellLink(), + a.getAsLegacyCellLink(), + b.getAsLegacyCellLink(), ], - writes: [c.getAsCellLink()], + writes: [c.getAsLegacyCellLink()], }); expect(runCount).toBe(0); expect(c.get()).toBe(0); @@ -371,10 +371,13 @@ describe("event handling", () => { eventResultCell.send(event); }; - runtime.scheduler.addEventHandler(eventHandler, eventCell.getAsCellLink()); + runtime.scheduler.addEventHandler( + eventHandler, + eventCell.getAsLegacyCellLink(), + ); - runtime.scheduler.queueEvent(eventCell.getAsCellLink(), 1); - runtime.scheduler.queueEvent(eventCell.getAsCellLink(), 2); + runtime.scheduler.queueEvent(eventCell.getAsLegacyCellLink(), 1); + runtime.scheduler.queueEvent(eventCell.getAsLegacyCellLink(), 2); await runtime.idle(); @@ -398,10 +401,10 @@ describe("event handling", () => { const removeHandler = runtime.scheduler.addEventHandler( eventHandler, - eventCell.getAsCellLink(), + eventCell.getAsLegacyCellLink(), ); - runtime.scheduler.queueEvent(eventCell.getAsCellLink(), 1); + runtime.scheduler.queueEvent(eventCell.getAsLegacyCellLink(), 1); await runtime.idle(); expect(eventCount).toBe(1); @@ -409,7 +412,7 @@ describe("event handling", () => { removeHandler(); - runtime.scheduler.queueEvent(eventCell.getAsCellLink(), 2); + runtime.scheduler.queueEvent(eventCell.getAsLegacyCellLink(), 2); await runtime.idle(); expect(eventCount).toBe(1); @@ -430,11 +433,11 @@ describe("event handling", () => { runtime.scheduler.addEventHandler( eventHandler, - parentCell.key("child").key("value").getAsCellLink(), + parentCell.key("child").key("value").getAsLegacyCellLink(), ); runtime.scheduler.queueEvent( - parentCell.key("child").key("value").getAsCellLink(), + parentCell.key("child").key("value").getAsLegacyCellLink(), 42, ); await runtime.idle(); @@ -454,11 +457,14 @@ describe("event handling", () => { events.push(event); }; - runtime.scheduler.addEventHandler(eventHandler, eventCell.getAsCellLink()); + runtime.scheduler.addEventHandler( + eventHandler, + eventCell.getAsLegacyCellLink(), + ); - runtime.scheduler.queueEvent(eventCell.getAsCellLink(), 1); - runtime.scheduler.queueEvent(eventCell.getAsCellLink(), 2); - runtime.scheduler.queueEvent(eventCell.getAsCellLink(), 3); + runtime.scheduler.queueEvent(eventCell.getAsLegacyCellLink(), 1); + runtime.scheduler.queueEvent(eventCell.getAsLegacyCellLink(), 2); + runtime.scheduler.queueEvent(eventCell.getAsLegacyCellLink(), 3); await runtime.idle(); @@ -491,11 +497,14 @@ describe("event handling", () => { }; await runtime.scheduler.run(action); - runtime.scheduler.addEventHandler(eventHandler, eventCell.getAsCellLink()); + runtime.scheduler.addEventHandler( + eventHandler, + eventCell.getAsLegacyCellLink(), + ); expect(actionCount).toBe(1); - runtime.scheduler.queueEvent(eventCell.getAsCellLink(), 1); + runtime.scheduler.queueEvent(eventCell.getAsLegacyCellLink(), 1); await runtime.idle(); expect(eventCount).toBe(1); @@ -503,7 +512,7 @@ describe("event handling", () => { expect(actionCount).toBe(2); - runtime.scheduler.queueEvent(eventCell.getAsCellLink(), 2); + runtime.scheduler.queueEvent(eventCell.getAsLegacyCellLink(), 2); await runtime.idle(); expect(eventCount).toBe(2); @@ -532,8 +541,8 @@ describe("compactifyPaths", () => { await storageManager?.close(); }); - // helper to normalize CellLinks because compactifyPaths() does not preserve - // the extra properties added by cell.getAsCellLink() + // helper to normalize LegacyCellLinks because compactifyPaths() does not preserve + // the extra properties added by cell.getAsLegacyCellLink() const normalizeCellLink = (link: any) => ({ cell: link.cell, path: link.path, @@ -546,14 +555,14 @@ describe("compactifyPaths", () => { ); testCell.set({}); const paths = [ - testCell.key("a").key("b").getAsCellLink(), - testCell.key("a").getAsCellLink(), - testCell.key("c").getAsCellLink(), + testCell.key("a").key("b").getAsLegacyCellLink(), + testCell.key("a").getAsLegacyCellLink(), + testCell.key("c").getAsLegacyCellLink(), ]; const result = compactifyPaths(paths); const expected = [ - testCell.key("a").getAsCellLink(), - testCell.key("c").getAsCellLink(), + testCell.key("a").getAsLegacyCellLink(), + testCell.key("c").getAsLegacyCellLink(), ]; expect(result.map(normalizeCellLink)).toEqual( expected.map(normalizeCellLink), @@ -567,11 +576,11 @@ describe("compactifyPaths", () => { ); testCell.set({}); const paths = [ - testCell.key("a").key("b").getAsCellLink(), - testCell.key("a").key("b").getAsCellLink(), + testCell.key("a").key("b").getAsLegacyCellLink(), + testCell.key("a").key("b").getAsLegacyCellLink(), ]; const result = compactifyPaths(paths); - const expected = [testCell.key("a").key("b").getAsCellLink()]; + const expected = [testCell.key("a").key("b").getAsLegacyCellLink()]; expect(result.map(normalizeCellLink)).toEqual( expected.map(normalizeCellLink), ); @@ -589,8 +598,8 @@ describe("compactifyPaths", () => { ); cellB.set({}); const paths = [ - cellA.key("a").key("b").getAsCellLink(), - cellB.key("a").key("b").getAsCellLink(), + cellA.key("a").key("b").getAsLegacyCellLink(), + cellB.key("a").key("b").getAsLegacyCellLink(), ]; const result = compactifyPaths(paths); expect(result.map(normalizeCellLink)).toEqual(paths.map(normalizeCellLink)); @@ -603,11 +612,11 @@ describe("compactifyPaths", () => { ); cellA.set({}); - const expectedResult = cellA.getAsCellLink(); + const expectedResult = cellA.getAsLegacyCellLink(); const paths = [ - cellA.key("a").key("b").getAsCellLink(), - cellA.key("c").getAsCellLink(), - cellA.key("d").getAsCellLink(), + cellA.key("a").key("b").getAsLegacyCellLink(), + cellA.key("c").getAsLegacyCellLink(), + cellA.key("d").getAsLegacyCellLink(), expectedResult, ]; const result = compactifyPaths(paths); diff --git a/packages/runner/test/schema-lineage.test.ts b/packages/runner/test/schema-lineage.test.ts index 6f97658e2..015303f12 100644 --- a/packages/runner/test/schema-lineage.test.ts +++ b/packages/runner/test/schema-lineage.test.ts @@ -58,7 +58,7 @@ describe("Schema Lineage", () => { ); sourceCell.setRaw({ $alias: { - ...targetCell.getAsCellLink(), + ...targetCell.getAsLegacyCellLink(), schema, rootSchema: schema, }, @@ -66,7 +66,8 @@ describe("Schema Lineage", () => { // Access the cell without providing a schema // (Type script type is just to avoid compiler errors) - const cell: Cell<{ count: number; label: string }> = sourceCell.asSchema(); + const cell: Cell<{ count: number; label: string }> = sourceCell + .asSchema(); // The cell should have picked up the schema from the alias expect(cell.schema).toBeDefined(); @@ -110,7 +111,7 @@ describe("Schema Lineage", () => { ); sourceCell.setRaw({ $alias: { - ...targetCell.getAsCellLink(), + ...targetCell.getAsLegacyCellLink(), schema: aliasSchema, rootSchema: aliasSchema, }, @@ -149,7 +150,7 @@ describe("Schema Lineage", () => { ); countCell.setRaw({ $alias: { - ...valueCell.key("count").getAsCellLink(), + ...valueCell.key("count").getAsLegacyCellLink(), schema: numberSchema, rootSchema: numberSchema, }, @@ -161,7 +162,7 @@ describe("Schema Lineage", () => { "final-alias", ); finalCell.setRaw({ - $alias: countCell.getAsCellLink(), + $alias: countCell.getAsLegacyCellLink(), }); // Access the cell without providing a schema @@ -207,15 +208,17 @@ describe("Schema Lineage", () => { ); itemsCell.setRaw({ $alias: { - ...nestedCell.key("items").getAsCellLink(), + ...nestedCell.key("items").getAsLegacyCellLink(), schema: arraySchema, }, }); // Access the items with a schema that specifies array items should be cells - const itemsCellWithSchema = itemsCell.asSchema({ - asCell: true, - } as const satisfies JSONSchema); + const itemsCellWithSchema = itemsCell.asSchema( + { + asCell: true, + } as const satisfies JSONSchema, + ); const value = itemsCellWithSchema.get() as any; expect(isCell(value)).toBe(true); @@ -289,17 +292,19 @@ describe("Schema propagation end-to-end example", () => { result, ); - const c = result.key(UI).asSchema({ - type: "object", - properties: { - type: { type: "string" }, - name: { type: "string" }, - props: { - type: "object", - additionalProperties: { asCell: true }, + const c = result.key(UI).asSchema( + { + type: "object", + properties: { + type: { type: "string" }, + name: { type: "string" }, + props: { + type: "object", + additionalProperties: { asCell: true }, + }, }, - }, - } as const satisfies JSONSchema); + } as const satisfies JSONSchema, + ); const cValue = c.get() as any; expect(isCell(cValue.props.value)).toBe(true); diff --git a/packages/runner/test/schema.test.ts b/packages/runner/test/schema.test.ts index dd464cd2e..a07a17ea1 100644 --- a/packages/runner/test/schema.test.ts +++ b/packages/runner/test/schema.test.ts @@ -1,11 +1,13 @@ import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; -import { type Cell, CellLink, isCell, isStream } from "../src/cell.ts"; +import { type Cell, isCell, isStream } from "../src/cell.ts"; +import { LegacyCellLink } from "../src/sigil-types.ts"; import type { JSONSchema } from "../src/builder/types.ts"; import { Runtime } from "../src/runtime.ts"; import { Identity } from "@commontools/identity"; import { StorageManager } from "@commontools/runner/storage/cache.deno"; -import { entityIdToJSON, toPlainObject } from "./test-helpers.ts"; +import { toURI } from "../src/uri-utils.ts"; +import { parseLink } from "../src/link-utils.ts"; const signer = await Identity.fromPassphrase("test operator"); const space = signer.did(); @@ -54,23 +56,23 @@ describe("Schema Support", () => { // This is what the system (or someone manually) would create to remap // data to match the desired schema const mappingCell = runtime.getCell<{ - id: CellLink; - changes: CellLink[]; - kind: CellLink; - tag: CellLink; + id: LegacyCellLink; + changes: LegacyCellLink[]; + kind: LegacyCellLink; + tag: LegacyCellLink; }>( space, "allows mapping of fields via interim cells 2", ); mappingCell.setRaw({ // as-is - id: c.key("id").getAsCellLink(), + id: c.key("id").getAsLegacyCellLink(), // turn single value to set - changes: [c.key("metadata").key("createdAt").getAsCellLink()], + changes: [c.key("metadata").key("createdAt").getAsLegacyCellLink()], // rename field and uplift from nested element - kind: c.key("metadata").key("type").getAsCellLink(), + kind: c.key("metadata").key("type").getAsLegacyCellLink(), // turn set into a single value - tag: c.key("tags").key(0).getAsCellLink(), + tag: c.key("tags").key(0).getAsLegacyCellLink(), }); // This schema is how the recipient specifies what they want @@ -115,17 +117,17 @@ describe("Schema Support", () => { "should support nested sinks 1", ); innerCell.set({ label: "first" }); - + const c = runtime.getCell<{ value: string; - current: CellLink; + current: LegacyCellLink; }>( space, "should support nested sinks 2", ); c.setRaw({ value: "root", - current: innerCell.getAsCellLink(), + current: innerCell.getAsLegacyCellLink(), }); const cWithSchema = c.asSchema(schema); @@ -235,14 +237,14 @@ describe("Schema Support", () => { ); initial.set({ foo: { label: "first" } }); const initialEntityId = initial.entityId!; - + const linkCell = runtime.getCell( space, "should support nested sinks via asCell with aliases 2", ); - linkCell.setRaw(initial.getAsCellLink()); + linkCell.setRaw(initial.getAsLegacyCellLink()); const linkEntityId = linkCell.entityId!; - + const docCell = runtime.getCell<{ value: string; current: any; @@ -252,7 +254,7 @@ describe("Schema Support", () => { ); docCell.setRaw({ value: "root", - current: { $alias: linkCell.key("foo").getAsCellLink() }, + current: { $alias: linkCell.key("foo").getAsLegacyCellLink() }, }); const docEntityId = docCell.entityId!; const root = docCell.asSchema(schema); @@ -282,8 +284,8 @@ describe("Schema Support", () => { // Make sure the schema is correct and it is still anchored at the root expect(current.schema).toEqual({ type: "string" }); - expect(toPlainObject(current.getAsCellLink())).toEqual({ - cell: entityIdToJSON(docCell.entityId!), + expect(parseLink(current.getAsLink({ includeSchema: true }))).toEqual({ + id: toURI(docCell.entityId!), path: ["current", "label"], space, schema: current.schema, @@ -307,8 +309,8 @@ describe("Schema Support", () => { expect(isCell(first)).toBe(true); expect(first.get()).toEqual({ label: "first" }); const { asCell: _ignore, ...omitSchema } = schema.properties.current; - expect(toPlainObject(first.getAsCellLink())).toEqual({ - cell: entityIdToJSON(initialEntityId), + expect(parseLink(first.getAsLink({ includeSchema: true }))).toEqual({ + id: toURI(initialEntityId), path: ["foo"], space, schema: omitSchema, @@ -316,15 +318,15 @@ describe("Schema Support", () => { }); expect(log.reads.length).toEqual(4); expect( - log.reads.map((r: CellLink) => ({ - cell: entityIdToJSON(r.cell.entityId!), + log.reads.map((r: LegacyCellLink) => ({ + cell: toURI(r.cell.entityId!), path: r.path, })), ).toEqual([ - { cell: entityIdToJSON(docCell.entityId!), path: ["current"] }, - { cell: entityIdToJSON(linkEntityId), path: [] }, - { cell: entityIdToJSON(initialEntityId), path: ["foo"] }, - { cell: entityIdToJSON(initialEntityId), path: ["foo", "label"] }, + { cell: toURI(docCell.entityId!), path: ["current"] }, + { cell: toURI(linkEntityId), path: [] }, + { cell: toURI(initialEntityId), path: ["foo"] }, + { cell: toURI(initialEntityId), path: ["foo", "label"] }, ]); // Then update it @@ -340,7 +342,7 @@ describe("Schema Support", () => { "should support nested sinks via asCell with aliases 4", ); second.set({ foo: { label: "second" } }); - linkCell.setRaw(second.getAsCellLink()); + linkCell.setRaw(second.getAsLegacyCellLink()); await runtime.idle(); @@ -382,7 +384,7 @@ describe("Schema Support", () => { ); third.set({ label: "third" }); docCell.key("current").setRaw({ - $alias: third.getAsCellLink(), + $alias: third.getAsLegacyCellLink(), }); await runtime.idle(); @@ -667,9 +669,9 @@ describe("Schema Support", () => { }); // Set up circular references using cell links - c.key("parent").setRaw(c.getAsCellLink()); - c.key("children").key(0).key("parent").setRaw(c.getAsCellLink()); - c.key("children").key(1).key("parent").setRaw(c.getAsCellLink()); + c.key("parent").setRaw(c.getAsLegacyCellLink()); + c.key("children").key(0).key("parent").setRaw(c.getAsLegacyCellLink()); + c.key("children").key(1).key("parent").setRaw(c.getAsLegacyCellLink()); const schema = { type: "object", @@ -723,9 +725,11 @@ describe("Schema Support", () => { }); // Set up circular references using cell links - c.key("nested").key("items").key(0).key("value").setRaw(c.getAsCellLink()); + c.key("nested").key("items").key(0).key("value").setRaw( + c.getAsLegacyCellLink(), + ); c.key("nested").key("items").key(1).key("value").setRaw( - c.key("nested").getAsCellLink() + c.key("nested").getAsLegacyCellLink(), ); const schema = { @@ -793,7 +797,7 @@ describe("Schema Support", () => { }); // Set up circular references using cell links - c.key("children").key(1).key("value").setRaw(c.getAsCellLink()); + c.key("children").key(1).key("value").setRaw(c.getAsLegacyCellLink()); const schema = { type: "object", @@ -1254,7 +1258,7 @@ describe("Schema Support", () => { ); childrenArrayCell.set([ { type: "text", value: "hello" }, - innerTextCell.getAsCellLink(), + innerTextCell.getAsLegacyCellLink(), ]); const withLinks = runtime.getCell<{ @@ -1275,11 +1279,11 @@ describe("Schema Support", () => { type: "vnode", name: "div", props: { - style: styleCell.getAsCellLink(), + style: styleCell.getAsLegacyCellLink(), }, children: [ { type: "text", value: "single" }, - childrenArrayCell.getAsCellLink(), + childrenArrayCell.getAsLegacyCellLink(), "or just text", ], }); diff --git a/packages/runner/test/storage.test.ts b/packages/runner/test/storage.test.ts index fb4620a84..d22f0bd3f 100644 --- a/packages/runner/test/storage.test.ts +++ b/packages/runner/test/storage.test.ts @@ -65,7 +65,7 @@ describe("Storage", () => { const testValue = { data: "test", - ref: refDoc.getAsCellLink(), + ref: refDoc.getAsLegacyCellLink(), }; testDoc.send(testValue); diff --git a/packages/runner/test/test-helpers.ts b/packages/runner/test/test-helpers.ts deleted file mode 100644 index e8d6e9e8c..000000000 --- a/packages/runner/test/test-helpers.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { assertEquals } from "@std/assert"; -import { AssertionError } from "@std/assert"; -import { isAlias } from "../src/builder/types.ts"; -import { isCellLink } from "../src/cell.ts"; -import type { EntityId } from "../src/doc-map.ts"; - -/** - * Normalizes CellLinks by keeping only cell and path properties - * Also normalizes path elements so numeric strings and numbers are equivalent - */ -export function normalizeCellLink(link: any): any { - // Normalize path elements: convert numeric strings to numbers for comparison - const normalizedPath = link.path.map((element: any) => { - // If it's a string that represents a valid array index, convert to number - if (typeof element === 'string' && /^\d+$/.test(element)) { - return parseInt(element, 10); - } - return element; - }); - - return { cell: link.cell, path: normalizedPath }; -} - -/** - * Deep normalizes an object, handling CellLinks and aliases - * Strips out extra properties like space, schema, rootSchema from CellLinks - */ -function deepNormalizeCellLinks(obj: any): any { - if (!obj || typeof obj !== 'object') { - return obj; - } - - // Handle bare CellLinks - if (isCellLink(obj)) { - return normalizeCellLink(obj); - } - - // Handle arrays - if (Array.isArray(obj)) { - return obj.map(deepNormalizeCellLinks); - } - - // Handle objects - const result: any = {}; - for (const [key, value] of Object.entries(obj)) { - if (isAlias(value)) { - // Normalize CellLinks within aliases - result[key] = { - $alias: normalizeCellLink(value.$alias) - }; - } else { - result[key] = deepNormalizeCellLinks(value); - } - } - return result; -} - -/** - * Custom expect-style matcher for CellLink equality - * Usage: expectCellLinksEqual(actual).toEqual(expected) - * Works with bare CellLinks, CellLinks in aliases, and nested structures - */ -export function expectCellLinksEqual(actual: unknown) { - return { - toEqual(expected: unknown, msg?: string) { - const normalizedActual = deepNormalizeCellLinks(actual); - const normalizedExpected = deepNormalizeCellLinks(expected); - - try { - assertEquals(normalizedActual, normalizedExpected, msg); - } catch (error) { - if (error instanceof AssertionError) { - error.message = `CellLinks are not equal (after normalization).\n${error.message}`; - throw error; - } - throw error; - } - } - }; -} - -/** - * Helper to convert objects with toJSON methods to plain objects - * Useful for test expectations that need to compare serialized forms - */ -export function toPlainObject(obj: T): any { - return JSON.parse(JSON.stringify(obj)); -} - -/** - * Helper to convert EntityId to JSON representation - * Handles EntityId objects that may or may not have toJSON method - */ -export function entityIdToJSON(entityId: EntityId): { "/": string } { - if (entityId.toJSON) { - return entityId.toJSON(); - } - // Fallback: construct the object manually - return { "/": entityId["/"].toString() }; -} \ No newline at end of file diff --git a/packages/runner/test/type-utils.test.ts b/packages/runner/test/type-utils.test.ts index 0ea73fee1..dd33c930a 100644 --- a/packages/runner/test/type-utils.test.ts +++ b/packages/runner/test/type-utils.test.ts @@ -1,11 +1,8 @@ import { describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; -import { - isAlias, - isModule, - isRecipe, - type Opaque, -} from "../src/builder/types.ts"; +import { isModule, isRecipe, type Opaque } from "../src/builder/types.ts"; +import { isWriteRedirectLink } from "../src/link-utils.ts"; +import { LINK_V1_TAG } from "../src/sigil-types.ts"; describe("value type", () => { it("can destructure a value without TS errors", () => { @@ -35,9 +32,19 @@ describe("value type", () => { }); describe("utility functions", () => { - it("isAlias correctly identifies aliases", () => { - expect(isAlias({ $alias: { path: ["path", "to", "value"] } })).toBe(true); - expect(isAlias({ notAlias: "something" })).toBe(false); + it("isWriteRedirectLink correctly identifies write redirects", () => { + expect(isWriteRedirectLink({ $alias: { path: ["path", "to", "value"] } })) + .toBe(true); + expect( + isWriteRedirectLink({ + "/": { + [LINK_V1_TAG]: { id: "path/to/value", overwrite: "redirect" }, + }, + }), + ).toBe( + true, + ); + expect(isWriteRedirectLink({ notAlias: "something" })).toBe(false); }); it("isModule correctly identifies modules", () => { @@ -58,4 +65,4 @@ describe("utility functions", () => { ).toBe(true); expect(isRecipe({ notRecipe: "something" })).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/packages/toolshed/routes/integrations/google-oauth/google-oauth.handlers.ts b/packages/toolshed/routes/integrations/google-oauth/google-oauth.handlers.ts index 5e278c7f3..2698fbd4d 100644 --- a/packages/toolshed/routes/integrations/google-oauth/google-oauth.handlers.ts +++ b/packages/toolshed/routes/integrations/google-oauth/google-oauth.handlers.ts @@ -25,7 +25,7 @@ import { tokenToAuthData, } from "./google-oauth.utils.ts"; import { setBGCharm } from "@commontools/background-charm"; -import { type CellLink } from "@commontools/runner"; +import { parseLink } from "@commontools/runner"; import { runtime } from "@/index.ts"; import { Tokens } from "@cmd-johnson/oauth2-client"; @@ -201,7 +201,7 @@ export const callback: AppRouteHandler = async (c) => { // Add this charm to the Gmail integration charms cell try { // Get the charm ID and space from the decodedState (which is the auth cell ID) - const authCellLink = JSON.parse(decodedState.authCellId) as CellLink; + const authCellLink = parseLink(JSON.parse(decodedState.authCellId))!; const space = authCellLink.space; const integrationCharmId = decodedState?.integrationCharmId; diff --git a/packages/toolshed/routes/integrations/google-oauth/google-oauth.routes.ts b/packages/toolshed/routes/integrations/google-oauth/google-oauth.routes.ts index 3b33ccb30..f26a0836f 100644 --- a/packages/toolshed/routes/integrations/google-oauth/google-oauth.routes.ts +++ b/packages/toolshed/routes/integrations/google-oauth/google-oauth.routes.ts @@ -21,7 +21,8 @@ export const login = createRoute({ }) .openapi({ example: { - authCellId: "auth-cell-123", + authCellId: + '{"/" : {"link-v0.1" : {"id" : "of:bafe...", "space" : "did:key:bafe...", "path" : ["path", "to", "value"]}}}', integrationCharmId: "integration-charm-123", }, }), @@ -129,7 +130,8 @@ export const refresh = createRoute({ }) .openapi({ example: { - authCellId: "auth-cell-123", + authCellId: + '{"/" : {"link-v0.1" : {"id" : "of:bafe...", "space" : "did:key:bafe...", "path" : ["path", "to", "value"]}}}', }, }), }, @@ -200,7 +202,8 @@ export const logout = createRoute({ }) .openapi({ example: { - authCellId: "auth-cell-123", + authCellId: + '{"/" : {"link-v0.1" : {"id" : "of:bafe...", "space" : "did:key:bafe...", "path" : ["path", "to", "value"]}}}', }, }), }, diff --git a/packages/toolshed/routes/integrations/google-oauth/google-oauth.utils.ts b/packages/toolshed/routes/integrations/google-oauth/google-oauth.utils.ts index 8f1f76b2a..a53aec4b7 100644 --- a/packages/toolshed/routes/integrations/google-oauth/google-oauth.utils.ts +++ b/packages/toolshed/routes/integrations/google-oauth/google-oauth.utils.ts @@ -1,6 +1,5 @@ import { OAuth2Client, Tokens } from "@cmd-johnson/oauth2-client"; import env from "@/env.ts"; -import { type CellLink } from "@commontools/runner"; import { runtime } from "@/index.ts"; import { Context } from "@hono/hono"; import { AuthSchema, Mutable, Schema } from "@commontools/runner"; @@ -144,18 +143,16 @@ export async function fetchUserInfo(accessToken: string): Promise { } // Helper function to get auth cell -export async function getAuthCell(docLink: CellLink | string) { +export async function getAuthCell(docLink: string) { try { // Parse string to docLink if needed - const parsedDocLink = typeof docLink === "string" - ? JSON.parse(docLink) - : docLink; + const parsedDocLink = JSON.parse(docLink); + + let authCell = runtime.getCellFromLink(parsedDocLink); // We already should have the schema on the parsedDocLink (from our state), // but if it's missing, we can add it here. - parsedDocLink.schema = parsedDocLink.schema ?? AuthSchema; - - const authCell = runtime.getCellFromLink(parsedDocLink); + if (!authCell.schema) authCell = authCell.asSchema(AuthSchema); // make sure the cell is live! await runtime.storage.syncCell(authCell, true); @@ -171,7 +168,7 @@ export async function getAuthCell(docLink: CellLink | string) { export async function persistTokens( oauthToken: OAuth2Tokens, userInfo: UserInfo, - authCellDocLink: string | CellLink, + authCellDocLink: string, ) { try { const authCell = await getAuthCell(authCellDocLink); @@ -202,7 +199,7 @@ export async function persistTokens( // Get tokens from the auth cell export async function getTokensFromAuthCell( - authCellDocLink: string | CellLink, + authCellDocLink: string, ) { try { const authCell = await getAuthCell(authCellDocLink); @@ -279,7 +276,7 @@ export function createRefreshErrorResponse( } // Clears authentication data from the auth cell -export async function clearAuthData(authCellDocLink: string | CellLink) { +export async function clearAuthData(authCellDocLink: string) { try { const authCell = await getAuthCell(authCellDocLink); diff --git a/packages/ui/src/v1/components/common-google-oauth.ts b/packages/ui/src/v1/components/common-google-oauth.ts index b5570a622..7c84b7371 100644 --- a/packages/ui/src/v1/components/common-google-oauth.ts +++ b/packages/ui/src/v1/components/common-google-oauth.ts @@ -26,7 +26,7 @@ export class CommonGoogleOauthElement extends LitElement { scopes: { type: Array }, }; - declare auth: Cell | undefined; + declare auth: Cell; declare authStatus: string; declare isLoading: boolean; declare authResult: Record | null; @@ -52,7 +52,7 @@ export class CommonGoogleOauthElement extends LitElement { this.authStatus = "Initiating OAuth flow..."; this.authResult = null; - const authCellId = JSON.stringify(this.auth?.getAsCellLink()); + const authCellId = JSON.stringify(this.auth.getAsLink()); // `ct://${spaceDid}/${cellId}` @@ -134,7 +134,7 @@ export class CommonGoogleOauthElement extends LitElement { } async handleLogout() { - await this.auth?.set({ + await this.auth.set({ token: "", tokenType: "", scope: [], @@ -154,20 +154,20 @@ export class CommonGoogleOauthElement extends LitElement { return html`
- ${this.auth?.get()?.user?.email && this.auth?.get()?.token + ${this.auth.get()?.user?.email && this.auth.get()?.token ? html` - User profile picture ` : ""}
- ${this.auth?.get()?.token + ${this.auth.get()?.token ? html`