diff --git a/typescript/packages/common-cli/charm_test.ts b/typescript/packages/common-cli/charm_test.ts index 617c95a81..ac3226764 100644 --- a/typescript/packages/common-cli/charm_test.ts +++ b/typescript/packages/common-cli/charm_test.ts @@ -1,15 +1,14 @@ /** - * @file This file is Ellyse's exploration into the interactions between - * charms, cells, and documents, and how they relate to common memory. + * @file This file is Ellyse's exploration into the interactions between + * charms, cells, and documents, and how they relate to common memory. * * I'm starting from the bottom (common memory) up and purposely calling * APIs that would normally call into common memory. - * */ -import { CharmManager, Charm } from "../common-charm/src/charm.ts"; +import { Charm, CharmManager } from "../common-charm/src/charm.ts"; import { Cell } from "../common-runner/src/cell.ts"; import { DocImpl, getDoc } from "../common-runner/src/doc.ts"; -import { EntityId } from "../common-runner/src/cell-map.ts"; +import { EntityId } from "../common-runner/src/doc-map.ts"; import { storage } from "../common-charm/src/storage.ts"; import { getSpace, Space } from "../common-runner/src/space.ts"; @@ -17,19 +16,26 @@ const replica = "ellyse7"; const TOOLSHED_API_URL = "https://toolshed.saga-castor.ts.net/"; // simple log function -const log: (s: T, prefix?: string) => void = (s, prefix?) => - console.log("-------------\n" + (prefix ? prefix : "") + ":\n" + JSON.stringify(s, null, 2)); +const log: (s: T, prefix?: string) => void = (s, prefix?) => + console.log( + "-------------\n" + (prefix ? prefix : "") + ":\n" + + JSON.stringify(s, null, 2), + ); -function createCell(space: Space): Cell { +function createCell(space: Space): Cell { const myCharm: Charm = { NAME: "mycharm", UI: "someui", "somekey": "some value", }; - // make this a DocImpl because we need to return a Cell since + // make this a DocImpl because we need to return a Cell since // that's what CharmManger.add() needs later on - const myDoc: DocImpl = getDoc(myCharm, crypto.randomUUID(), space); + const myDoc: DocImpl = getDoc( + myCharm, + crypto.randomUUID(), + space, + ); return myDoc.asCell(); } @@ -38,15 +44,15 @@ async function main() { const charmManager = new CharmManager(replica); log(charmManager, "charmManager"); - // let's try to create a cell + // let's try to create a cell const space: Space = getSpace(replica); const cell: Cell = createCell(space); log(cell.get(), "cell value from Cell.get()"); - - // this feels like magic and wrong, - // but we crash in the next CharmManager.add() if this isn't set + + // this feels like magic and wrong, + // but we crash in the next CharmManager.add() if this isn't set storage.setRemoteStorage( - new URL(TOOLSHED_API_URL) + new URL(TOOLSHED_API_URL), ); // let's add the cell to the charmManager diff --git a/typescript/packages/common-cli/memory_test.ts b/typescript/packages/common-cli/memory_test.ts index e277d9b39..b827fb2a4 100644 --- a/typescript/packages/common-cli/memory_test.ts +++ b/typescript/packages/common-cli/memory_test.ts @@ -1,7 +1,7 @@ import { MemorySpace } from "@commontools/memory"; import { RemoteStorageProvider } from "../common-charm/src/storage/remote.ts"; import { StorageProvider } from "../common-charm/src/storage/base.ts"; -import { EntityId } from "../common-runner/src/cell-map.ts"; +import { EntityId } from "../common-runner/src/doc-map.ts"; // some config stuff, hardcoded, ofcourse const replica = "ellyse5"; @@ -79,10 +79,11 @@ async function main() { // now lets try to store a batch of values console.log("storing all entities"); const result = await storageProvider.send(people_batch); - if (result.ok) + if (result.ok) { console.log("sent entities successfully"); - else + } else { console.log("got error: " + JSON.stringify(result.error, null, 2)); + } } main(); diff --git a/typescript/packages/common-runner/src/builtins/fetch-data.ts b/typescript/packages/common-runner/src/builtins/fetch-data.ts index 276ccdac7..6c98dddc3 100644 --- a/typescript/packages/common-runner/src/builtins/fetch-data.ts +++ b/typescript/packages/common-runner/src/builtins/fetch-data.ts @@ -9,10 +9,10 @@ import { refer } from "merkle-reference"; * * Returns the fetched result as `result`. `pending` is true while a request is pending. * - * @param url - A cell containing the URL to fetch data from. + * @param url - A doc containing the URL to fetch data from. * @param mode - The mode to use for fetching data. Either `text` or `json` * default to `json` results. - * @returns { pending: boolean, result: any, error: any } - As individual cells, representing `pending` state, final `result`, and any `error`. + * @returns { pending: boolean, result: any, error: any } - As individual docs, representing `pending` state, final `result`, and any `error`. */ export function fetchData( inputsCell: DocImpl<{ diff --git a/typescript/packages/common-runner/src/builtins/llm.ts b/typescript/packages/common-runner/src/builtins/llm.ts index 2aab6e99f..58dec5af9 100644 --- a/typescript/packages/common-runner/src/builtins/llm.ts +++ b/typescript/packages/common-runner/src/builtins/llm.ts @@ -12,18 +12,18 @@ import { type ReactivityLog } from "../scheduler.ts"; * Returns the complete result as `result` and the incremental result as * `partial`. `pending` is true while a request is pending. * - * @param prompt - A cell to store the prompt message - if you only have a single message + * @param prompt - A doc to store the prompt message - if you only have a single message * @param messages - list of messages to send to the LLM. - alternating user and assistant messages. * - if you end with an assistant message, the LLM will continue from there. * - if both prompt and messages are empty, no LLM call will be made, * result and partial will be undefined. - * @param model - A cell to store the model to use. - * @param system - A cell to store the system message. - * @param stop - A cell to store (optional) stop sequence. - * @param max_tokens - A cell to store the maximum number of tokens to generate. + * @param model - A doc to store the model to use. + * @param system - A doc to store the system message. + * @param stop - A doc to store (optional) stop sequence. + * @param max_tokens - A doc to store the maximum number of tokens to generate. * * @returns { pending: boolean, result?: string, partial?: string } - As individual - * cells, representing `pending` state, final `result` and incrementally + * docs, representing `pending` state, final `result` and incrementally * updating `partial` result. */ export function llm( @@ -114,7 +114,7 @@ export function llm( // Return if the same request is being made again, either concurrently (same // as previousCallHash) or when rehydrated from storage (same as the - // contents of the requestHash cell). + // contents of the requestHash doc). if (hash === previousCallHash || hash === requestHash.get()) return; previousCallHash = hash; diff --git a/typescript/packages/common-runner/src/builtins/map.ts b/typescript/packages/common-runner/src/builtins/map.ts index c57f61949..1aef628bd 100644 --- a/typescript/packages/common-runner/src/builtins/map.ts +++ b/typescript/packages/common-runner/src/builtins/map.ts @@ -13,19 +13,19 @@ import { type AddCancel } from "../cancel.ts"; * The goal is to keep the output array current without recomputing too much. * * Approach: - * 1. Create a cell to store the result. - * 2. Create a handler to update the result cell when the input cell changes. - * 3. Create a handler to update the result cell when the op cell changes. - * 4. For each value in the input cell, create a handler to update the result - * cell when the value changes. + * 1. Create a doc to store the result. + * 2. Create a handler to update the result doc when the input doc changes. + * 3. Create a handler to update the result doc when the op doc changes. + * 4. For each value in the input doc, create a handler to update the result + * doc when the value changes. * * TODO: Optimization depends on javascript objects and not lookslike objects. * We should make sure updates to arrays don't unnecessarily re-ify objects * and/or change the comparision here. * - * @param list - A cell containing an array of values to map over. + * @param list - A doc containing an array of values to map over. * @param op - A recipe to apply to each value. - * @returns A cell containing the mapped values. + * @returns A doc containing the mapped values. */ export function map( inputsCell: DocImpl<{ @@ -72,7 +72,7 @@ export function map( throw new Error("map currently only supports arrays"); } - // // Hack to get to underlying array that lists cell references, etc. + // Hack to get to underlying array that lists doc links, etc. const listRef = getDocLinkOrThrow(list); // Same for op, but here it's so that the proxy doesn't follow the aliases @@ -106,7 +106,7 @@ export function map( // TODO(seefeld): Have `run` return cancel, once we make resultCell required addCancel(cancels.get(resultCell)); - // Send the result value to the result cell + // Send the result value to the result doc result.setAtPath([initializedUpTo], { cell: resultCell, path: [] }, log); initializedUpTo++; diff --git a/typescript/packages/common-runner/src/builtins/stream-data.ts b/typescript/packages/common-runner/src/builtins/stream-data.ts index 6c7b440d8..e90dae0b0 100644 --- a/typescript/packages/common-runner/src/builtins/stream-data.ts +++ b/typescript/packages/common-runner/src/builtins/stream-data.ts @@ -11,8 +11,8 @@ import { type ReactivityLog } from "../scheduler.ts"; * * Returns the streamed result as `result`. `pending` is true while a request is pending. * - * @param url - A cell containing the URL to stream data from. - * @returns { pending: boolean, result: any, error: any } - As individual cells, representing `pending` state, streamed `result`, and any `error`. + * @param url - A doc containing the URL to stream data from. + * @returns { pending: boolean, result: any, error: any } - As individual docs, representing `pending` state, streamed `result`, and any `error`. */ export function streamData( inputsCell: DocImpl<{ @@ -53,7 +53,7 @@ export function streamData( result.sourceCell = parentDoc; error.sourceCell = parentDoc; - // Since we'll only write into the cells above, we only have to call this once + // Since we'll only write into the docs above, we only have to call this once // here, instead of in the action. sendResult({ pending, result, error }); diff --git a/typescript/packages/common-runner/src/cell.ts b/typescript/packages/common-runner/src/cell.ts index 90508b77f..72217dedc 100644 --- a/typescript/packages/common-runner/src/cell.ts +++ b/typescript/packages/common-runner/src/cell.ts @@ -13,9 +13,9 @@ import { getDocLinkOrValue, type QueryResult, } from "./query-result-proxy.ts"; -import { followLinks, prepareForSaving, resolvePath } from "./utils.ts"; +import { prepareForSaving, resolveLinkToValue, resolvePath } from "./utils.ts"; import { queueEvent, type ReactivityLog, subscribe } from "./scheduler.ts"; -import { type EntityId, getDocByEntityId, getEntityId } from "./cell-map.ts"; +import { type EntityId, getDocByEntityId, getEntityId } from "./doc-map.ts"; import { type Cancel, isCancel, useCancelGroup } from "./cancel.ts"; import { validateAndTransform } from "./schema.ts"; import { type Schema } from "@commontools/builder"; @@ -203,7 +203,7 @@ export function createCell( // 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 = followLinks(resolvePath(doc, path)); + const ref = resolveLinkToValue(doc, path); if (isStreamAlias(ref.cell.getAtPath(ref.path))) { return createStreamCell(ref.cell, ref.path) as unknown as Cell; } else return createRegularCell(doc, path, log, schema, rootSchema); diff --git a/typescript/packages/common-runner/src/doc-map.ts b/typescript/packages/common-runner/src/doc-map.ts new file mode 100644 index 000000000..61134646c --- /dev/null +++ b/typescript/packages/common-runner/src/doc-map.ts @@ -0,0 +1,190 @@ +import { isOpaqueRef } from "@commontools/builder"; +import { type DocImpl, type DocLink, getDoc, isDoc, isDocLink } from "./doc.ts"; +import { + getDocLinkOrThrow, + isQueryResultForDereferencing, +} from "./query-result-proxy.ts"; +import { isCell } from "./cell.ts"; +import { refer } from "merkle-reference"; +import { type Space } from "./space.ts"; + +export type EntityId = { + "/": string | Uint8Array; + toJSON?: () => { "/": string }; +}; + +/** + * Generates an entity ID. + * + * @param source - The source object. + * @param cause - Optional causal source. Otherwise a random n is used. + */ +export const createRef = ( + source: any = {}, + cause: any = crypto.randomUUID(), +): EntityId => { + const seen = new Set(); + + // Unwrap query result proxies, replace docs with their ids and remove + // functions and undefined values, since `merkle-reference` doesn't support + // them. + function traverse(obj: any): any { + // Avoid cycles + if (seen.has(obj)) return null; + seen.add(obj); + + // Don't traverse into ids. + if (typeof obj === "object" && obj !== null && "/" in obj) return obj; + + // If there is a .toJSON method, replace obj with it, then descend. + if ( + typeof obj === "object" && obj !== null && + typeof obj.toJSON === "function" + ) { + obj = obj.toJSON() ?? obj; + } + + if (isOpaqueRef(obj)) return obj.export().value ?? crypto.randomUUID(); + + if (isQueryResultForDereferencing(obj)) { + // It'll traverse this and call .toJSON on the doc in the reference. + obj = getDocLinkOrThrow(obj); + } + + // If referencing other docs, return their ids (or random as fallback). + if (isDoc(obj) || isCell(obj)) return obj.entityId ?? crypto.randomUUID(); + else if (Array.isArray(obj)) return obj.map(traverse); + else if (typeof obj === "object" && obj !== null) { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [key, traverse(value)]), + ); + } else if (typeof obj === "function") return obj.toString(); + else if (obj === undefined) return null; + else return obj; + } + + return refer(traverse({ ...source, causal: cause })); +}; + +/** + * Extracts an entity ID from a cell or cell representation. Creates a stable + * derivative entity ID for path references. + * + * @param value - The value to extract the entity ID from. + * @returns The entity ID, or undefined if the value is not a cell or doc. + */ +export const getEntityId = (value: any): { "/": string } | undefined => { + if (typeof value === "string") { + return value.startsWith("{") ? JSON.parse(value) : { "/": value }; + } + if (typeof value === "object" && value !== null && "/" in value) { + return JSON.parse(JSON.stringify(value)); + } + + let ref: DocLink | undefined = undefined; + + if (isQueryResultForDereferencing(value)) ref = getDocLinkOrThrow(value); + else if (isDocLink(value)) ref = value; + else if (isCell(value)) ref = value.getAsDocLink(); + else if (isDoc(value)) ref = { cell: value, path: [] }; + + if (!ref?.cell.entityId) return undefined; + + if (ref.path.length > 0) { + return JSON.parse( + JSON.stringify(createRef({ path: ref.path }, ref.cell.entityId)), + ); + } else return JSON.parse(JSON.stringify(ref.cell.entityId)); +}; + +/** + * A map that holds weak references to its values per space. + */ +class SpaceAwareCleanableMap { + private maps = new Map>(); + + set(space: Space, key: string, value: T) { + let map = this.maps.get(space); + if (!map) { + map = new CleanableMap(); + this.maps.set(space, map); + } + map.set(key, value); + } + + get(space: Space, key: string): T | undefined { + return this.maps.get(space)?.get(key); + } +} + +const entityIdToDocMap = new SpaceAwareCleanableMap>(); + +/** + * A map that holds weak references to its values. Triggers a cleanup of the map + * when any item was garbage collected, so that the weak references themselves + * can be garbage collected. + */ +class CleanableMap { + private map = new Map>(); + private cleanupScheduled = false; + + set(key: string, value: T) { + this.map.set(key, new WeakRef(value)); + } + + get(key: string): T | undefined { + const ref = this.map.get(key); + if (ref) { + const value = ref.deref(); + if (value === undefined) { + this.scheduleCleanup(); + } + return value; + } + return undefined; + } + + private scheduleCleanup() { + if (!this.cleanupScheduled) { + this.cleanupScheduled = true; + queueMicrotask(() => { + this.cleanup(); + this.cleanupScheduled = false; + }); + } + } + + private cleanup() { + for (const [key, ref] of this.map) { + if (ref.deref() === undefined) { + this.map.delete(key); + } + } + } +} + +export function getDocByEntityId( + space: Space, + entityId: EntityId | string, + createIfNotFound = true, +): DocImpl | undefined { + const id = typeof entityId === "string" ? entityId : JSON.stringify(entityId); + let doc = entityIdToDocMap.get(space, id); + if (doc) return doc; + if (!createIfNotFound) return undefined; + + doc = getDoc(); + if (typeof entityId === "string") entityId = JSON.parse(entityId) as EntityId; + doc.entityId = entityId; + doc.space = space; + setDocByEntityId(space, entityId, doc); + return doc; +} + +export const setDocByEntityId = ( + space: Space, + entityId: EntityId, + doc: DocImpl, +) => { + entityIdToDocMap.set(space, JSON.stringify(entityId), doc); +}; diff --git a/typescript/packages/common-runner/src/doc.ts b/typescript/packages/common-runner/src/doc.ts index 6ead89db3..34f27266e 100644 --- a/typescript/packages/common-runner/src/doc.ts +++ b/typescript/packages/common-runner/src/doc.ts @@ -20,7 +20,7 @@ import { type EntityId, getDocByEntityId, setDocByEntityId, -} from "./cell-map.ts"; +} from "./doc-map.ts"; import { type ReactivityLog } from "./scheduler.ts"; import { type Cancel } from "./cancel.ts"; import { arrayEqual } from "./utils.ts"; diff --git a/typescript/packages/common-runner/src/index.ts b/typescript/packages/common-runner/src/index.ts index c6bf23678..dbc3f0a85 100644 --- a/typescript/packages/common-runner/src/index.ts +++ b/typescript/packages/common-runner/src/index.ts @@ -30,7 +30,7 @@ export { type EntityId, getDocByEntityId, getEntityId, -} from "./cell-map.ts"; +} from "./doc-map.ts"; export { addRecipe, allRecipesByName, diff --git a/typescript/packages/common-runner/src/query-result-proxy.ts b/typescript/packages/common-runner/src/query-result-proxy.ts index fa156a6fa..2ee8e1b75 100644 --- a/typescript/packages/common-runner/src/query-result-proxy.ts +++ b/typescript/packages/common-runner/src/query-result-proxy.ts @@ -10,9 +10,8 @@ import { } from "./doc.ts"; import { queueEvent, type ReactivityLog } from "./scheduler.ts"; import { - followAliases, - followCellReferences, normalizeToDocLinks, + resolveLinkToValue, setNestedValue, } from "./utils.ts"; @@ -62,64 +61,17 @@ export function createQueryResultProxy( valuePath: PropertyKey[], log?: ReactivityLog, ): T { + // Resolve path and follow links to actual value. + ({ cell: valueCell, path: valuePath } = resolveLinkToValue( + valueCell, + valuePath, + log, + )); + log?.reads.push({ cell: valueCell, path: valuePath }); + const target = valueCell.getAtPath(valuePath) as any; - // Follow path, following aliases and cells, so might end up on different cell - let target = valueCell.get() as any; - const keys = [...valuePath]; - valuePath = []; - while (keys.length) { - const key = keys.shift()!; - if (isQueryResultForDereferencing(target)) { - const ref = target[getDocLink]; - valueCell = ref.cell; - valuePath = [...ref.path]; - log?.reads.push({ cell: valueCell, path: valuePath }); - target = ref.cell.getAtPath(ref.path); - } else if (isAlias(target)) { - const ref = followAliases(target, valueCell, log); - valueCell = ref.cell; - valuePath = [...ref.path]; - log?.reads.push({ cell: valueCell, path: valuePath }); - target = ref.cell.getAtPath(ref.path); - } else if (isDoc(target)) { - valueCell = target; - valuePath = []; - log?.reads.push({ cell: valueCell, path: valuePath }); - target = target.get(); - } else if (isDocLink(target)) { - const ref = followCellReferences(target, log); - valueCell = ref.cell; - valuePath = [...ref.path]; - log?.reads.push({ cell: valueCell, path: valuePath }); - target = ref.cell.getAtPath(ref.path); - } - valuePath.push(key); - if (typeof target === "object" && target !== null) { - target = target[key as keyof typeof target]; - } else { - target = undefined; - } - } - - if (valuePath.length > 30) { - console.warn("Query result with long path [2]", JSON.stringify(valuePath)); - } - - // Now target is the end of the path. It might still be a cell, alias or cell - // reference, so we follow these as well. - if (isQueryResult(target)) { - const ref = target[getDocLink]; - return createQueryResultProxy(ref.cell, ref.path, log); - } else if (isDoc(target)) { - return createQueryResultProxy(target, [], log); - } else if (isAlias(target)) { - const ref = followAliases(target, valueCell, log); - return createQueryResultProxy(ref.cell, ref.path, log); - } else if (isDocLink(target)) { - const ref = followCellReferences(target, log); - return createQueryResultProxy(ref.cell, ref.path, log); - } else if (typeof target !== "object" || target === null) return target; + if (typeof target !== "object" || target === null) return target; return new Proxy(target as object, { get: (target, prop, receiver) => { @@ -319,9 +271,9 @@ function isProxyForArrayValue(value: any): value is ProxyForArrayValue { } /** - * Get cell reference or return values as is if not a cell value proxy. + * Get doc link or return values as is if not a cell value proxy. * - * @param {any} value - The value to get the cell reference or value from. + * @param {any} value - The value to get the doc link or value from. * @returns {DocLink | any} */ export function getDocLinkOrValue(value: any): DocLink { @@ -330,9 +282,9 @@ export function getDocLinkOrValue(value: any): DocLink { } /** - * Get cell reference or throw if not a cell value proxy. + * Get doc link or throw if not a cell value proxy. * - * @param {any} value - The value to get the cell reference from. + * @param {any} value - The value to get the doc link from. * @returns {DocLink} * @throws {Error} If the value is not a cell value proxy. */ @@ -342,7 +294,7 @@ export function getDocLinkOrThrow(value: any): DocLink { } /** - * Check if value is a cell proxy. + * Check if value is a cell value proxy. * * @param {any} value - The value to check. * @returns {boolean} @@ -355,8 +307,8 @@ export function isQueryResult(value: any): value is QueryResult { const getDocLink = Symbol("isQueryResultProxy"); /** - * Check if value is a cell proxy. Return as type that allows dereferencing, but - * not using the proxy. + * Check if value is a cell value proxy. Return as type that allows + * dereferencing, but not using the proxy. * * @param {any} value - The value to check. * @returns {boolean} diff --git a/typescript/packages/common-runner/src/recipe-map.ts b/typescript/packages/common-runner/src/recipe-map.ts index 0b8c11143..9e75aea0b 100644 --- a/typescript/packages/common-runner/src/recipe-map.ts +++ b/typescript/packages/common-runner/src/recipe-map.ts @@ -1,5 +1,5 @@ import type { Module, Recipe } from "@commontools/builder"; -import { createRef } from "./cell-map.ts"; +import { createRef } from "./doc-map.ts"; const recipeById = new Map(); const recipeNameById = new Map(); diff --git a/typescript/packages/common-runner/src/runner.ts b/typescript/packages/common-runner/src/runner.ts index c81d6f13c..50fcf433f 100644 --- a/typescript/packages/common-runner/src/runner.ts +++ b/typescript/packages/common-runner/src/runner.ts @@ -27,13 +27,13 @@ import { containsOpaqueRef, deepCopy, extractDefaultValues, - findAllAliasedCells, + findAllAliasedDocs, followAliases, mergeObjects, sendValueToBinding, - staticDataToNestedCells, + staticDataToNestedDocs, unsafe_noteParentOnRecipes, - unwrapOneLevelAndBindtoCell, + unwrapOneLevelAndBindtoDoc, } from "./utils.ts"; import { getModuleByRef } from "./module.ts"; import { type AddCancel, type Cancel, useCancelGroup } from "./cancel.ts"; @@ -62,9 +62,8 @@ export const cancels = new WeakMap, Cancel>(); * * @param recipeFactory - Function that takes the argument and returns a recipe. * @param argument - The argument to pass to the recipe. Can be static data - * and/or cell references, including cell value proxies and regular cells. - * @param resultCell - Optional cell to run the recipe into. If not given, a new - * cell is created. + * and/or cell references, including cell value proxies, docs and regular cells. + * @param resultDoc - Doc to run the recipe off. * @returns The result cell. */ export function run( @@ -110,7 +109,7 @@ export function run( if (!recipeOrModule) throw new Error(`Unknown recipe: ${recipeId}`); } else if (!recipeOrModule) { console.warn( - "No recipe provided and no recipe found in process cell. Not running.", + "No recipe provided and no recipe found in process doc. Not running.", ); return resultCell; } @@ -143,7 +142,7 @@ export function run( if (cancels.has(resultCell)) { // If it's already running and no new recipe or argument are given, - // we are just returning the result cell + // we are just returning the result doc if (argument === undefined && recipeId === processCell.get()?.[TYPE]) { return resultCell; } @@ -162,10 +161,10 @@ export function run( // Walk the recipe's schema and extract all default values const defaults = extractDefaultValues(recipe.argumentSchema); - // If the bindings are a cell or cell reference, convert them to an object - // where each property is a cell reference. + // If the bindings are a cell, doc or doc link, convert them to an object + // where each property is a doc link. // TODO(seefeld): If new keys are added after first load, this won't work. - + // TODO(seefeld): Note why we need this. Is it still needed? if ( isDoc(argument) || isDocLink(argument) || @@ -203,8 +202,8 @@ export function run( ...processCell.get()?.internal, }; - // Ensure static data is converted to cell references, e.g. for arrays - argument = staticDataToNestedCells( + // Ensure static data is converted to doc link, e.g. for arrays + argument = staticDataToNestedDocs( processCell, argument, undefined, @@ -221,9 +220,9 @@ export function run( resultRef: { cell: resultCell, path: [] }, }); - // Send "query" to results to the result cell + // Send "query" to results to the result doc resultCell.send( - unwrapOneLevelAndBindtoCell(recipe.result as R, processCell), + unwrapOneLevelAndBindtoDoc(recipe.result as R, processCell), ); // [unsafe closures:] For recipes from closures, add a materialize factory @@ -233,14 +232,19 @@ export function run( } for (const node of recipe.nodes) { - // Generate causal IDs for all cells read and written to by this node, if + // Generate causal IDs for all docs read and written to by this node, if // they don't have any yet. [node.inputs, node.outputs].forEach((bindings) => - findAllAliasedCells(bindings, processCell).forEach(({ cell, path }) => { - if (!cell.entityId && processCell.entityId) { - cell.generateEntityId({ cell: processCell, path }, processCell.space); - } - }) + findAllAliasedDocs(bindings, processCell).forEach( + ({ cell: doc, path }) => { + if (!doc.entityId && processCell.entityId) { + doc.generateEntityId( + { cell: processCell, path }, + processCell.space, + ); + } + }, + ) ); instantiateNode( node.module, @@ -263,7 +267,7 @@ export function run( * A better strategy would be to schedule based on effects and unregister the * effects driving execution, e.g. the UI. * - * @param resultCell - The result cell to stop. + * @param resultCell - The result doc to stop. */ export function stop(resultCell: DocImpl) { cancels.get(resultCell)?.(); @@ -346,18 +350,18 @@ function instantiateJavaScriptNode( addCancel: AddCancel, recipe: Recipe, ) { - const inputs = unwrapOneLevelAndBindtoCell( + const inputs = unwrapOneLevelAndBindtoDoc( inputBindings as { [key: string]: any }, processCell, ); - // TODO(seefeld): This isn't correct, as module can write into passed cells. We - // should look at the schema to find out what cells are read and + // TODO(seefeld): This isn't correct, as module can write into passed docs. We + // should look at the schema to find out what docs are read and // written. - const reads = findAllAliasedCells(inputs, processCell); + const reads = findAllAliasedDocs(inputs, processCell); - const outputs = unwrapOneLevelAndBindtoCell(outputBindings, processCell); - const writes = findAllAliasedCells(outputs, processCell); + const outputs = unwrapOneLevelAndBindtoDoc(outputBindings, processCell); + const writes = findAllAliasedDocs(outputs, processCell); let fn = ( typeof module.implementation === "string" @@ -372,17 +376,17 @@ function instantiateJavaScriptNode( // Check if any of the read cells is a stream alias let streamRef: DocLink | undefined = undefined; for (const key in inputs) { - let cell = processCell; + let doc = processCell; let path: PropertyKey[] = [key]; let value = inputs[key]; while (isAlias(value)) { const ref = followAliases(value, processCell); - cell = ref.cell; + doc = ref.cell; path = ref.path; - value = cell.getAtPath(path); + value = doc.getAtPath(path); } if (isStreamAlias(value)) { - streamRef = { cell, path }; + streamRef = { cell: doc, path }; break; } } @@ -521,11 +525,11 @@ function instantiateRawNode( // Built-ins can define their own scheduling logic, so they'll // implement parts of the above themselves. - const mappedInputBindings = unwrapOneLevelAndBindtoCell( + const mappedInputBindings = unwrapOneLevelAndBindtoDoc( inputBindings, processCell, ); - const mappedOutputBindings = unwrapOneLevelAndBindtoCell( + const mappedOutputBindings = unwrapOneLevelAndBindtoDoc( outputBindings, processCell, ); @@ -534,8 +538,8 @@ function instantiateRawNode( // note the parent recipe on the closure recipes. unsafe_noteParentOnRecipes(recipe, mappedInputBindings); - const inputCells = findAllAliasedCells(mappedInputBindings, processCell); - const outputCells = findAllAliasedCells(mappedOutputBindings, processCell); + const inputCells = findAllAliasedDocs(mappedInputBindings, processCell); + const outputCells = findAllAliasedDocs(mappedOutputBindings, processCell); const action = module.implementation( getDoc(mappedInputBindings), @@ -556,12 +560,12 @@ function instantiatePassthroughNode( processCell: DocImpl, addCancel: AddCancel, ) { - const inputs = unwrapOneLevelAndBindtoCell(inputBindings, processCell); + const inputs = unwrapOneLevelAndBindtoDoc(inputBindings, processCell); const inputsCell = getDoc(inputs); - const reads = findAllAliasedCells(inputs, processCell); + const reads = findAllAliasedDocs(inputs, processCell); - const outputs = unwrapOneLevelAndBindtoCell(outputBindings, processCell); - const writes = findAllAliasedCells(outputs, processCell); + const outputs = unwrapOneLevelAndBindtoDoc(outputBindings, processCell); + const writes = findAllAliasedDocs(outputs, processCell); const action: Action = (log: ReactivityLog) => { const inputsProxy = inputsCell.getAsQueryResult([], log); @@ -579,11 +583,11 @@ function instantiateRecipeNode( addCancel: AddCancel, ) { if (!isRecipe(module.implementation)) throw new Error(`Invalid recipe`); - const recipe = unwrapOneLevelAndBindtoCell( + const recipe = unwrapOneLevelAndBindtoDoc( module.implementation, processCell, ); - const inputs = unwrapOneLevelAndBindtoCell(inputBindings, processCell); + const inputs = unwrapOneLevelAndBindtoDoc(inputBindings, processCell); const resultCell = getDoc( undefined, { diff --git a/typescript/packages/common-runner/src/scheduler.ts b/typescript/packages/common-runner/src/scheduler.ts index ccf63adae..c067d510f 100644 --- a/typescript/packages/common-runner/src/scheduler.ts +++ b/typescript/packages/common-runner/src/scheduler.ts @@ -1,5 +1,4 @@ import type { DocImpl, DocLink } from "./doc.ts"; -import { compactifyPaths, pathAffected } from "./utils.ts"; import type { Cancel } from "./cancel.ts"; export type Action = (log: ReactivityLog) => any; @@ -22,7 +21,7 @@ const MAX_ITERATIONS_PER_RUN = 100; /** * Reactivity log. * - * Used to log reads and writes to cells. Used by scheduler to keep track of + * Used to log reads and writes to docs. Used by scheduler to keep track of * dependencies and to topologically sort pending actions before executing them. */ export type ReactivityLog = { @@ -32,7 +31,7 @@ export type ReactivityLog = { export function schedule(action: Action, log: ReactivityLog): Cancel { const reads = setDependencies(action, log); - reads.forEach(({ cell }) => dirty.add(cell)); + reads.forEach(({ cell: doc }) => dirty.add(doc)); queueExecution(); pending.add(action); @@ -81,7 +80,7 @@ export async function run(action: Action): Promise { } // Note: By adding the listeners after the call we avoid triggering a re-run - // of the action if it changed a r/w cell. Note that this also means that + // of the action if it changed a r/w doc. Note that this also means that // those actions can't loop on themselves. subscribe(action, log); running = undefined; @@ -160,18 +159,18 @@ async function execute() { // In case a directly invoked `run` is still running, wait for it to finish. if (running) await running; - // Process next event from the event queue. Will mark more cells as dirty. + // Process next event from the event queue. Will mark more docs as dirty. eventQueue.shift()?.(); const order = topologicalSort(pending, dependencies, dirty); - // Clear pending and dirty sets, and cancel all listeners for cells on already + // Clear pending and dirty sets, and cancel all listeners for docs on already // scheduled actions. pending.clear(); dirty.clear(); for (const fn of order) cancels.get(fn)?.forEach((cancel) => cancel()); - // Now run all functions. This will create new listeners to mark cells dirty + // Now run all functions. This will create new listeners to mark docs dirty // and schedule the next run. for (const fn of order) { loopCounter.set(fn, (loopCounter.get(fn) || 0) + 1); @@ -210,7 +209,7 @@ function topologicalSort( // Actions with no dependencies are always relevant. Note that they must // be manually added to `pending`, which happens only once on `schedule`. relevantActions.add(action); - } else if (reads.some(({ cell }) => dirty.has(cell))) { + } else if (reads.some(({ cell: doc }) => dirty.has(doc))) { relevantActions.add(action); } } @@ -304,3 +303,42 @@ function topologicalSort( return result; } + +// Remove longer paths already covered by shorter paths +export function compactifyPaths(entries: DocLink[]): DocLink[] { + // First group by doc via a Map + const docToPaths = new Map, PropertyKey[][]>(); + for (const { cell: doc, path } of entries) { + const paths = docToPaths.get(doc) || []; + paths.push(path.map((key) => key.toString())); // Normalize to strings as keys + docToPaths.set(doc, paths); + } + + // For each cell, sort the paths by length, then only return those that don't + // have a prefix earlier in the list + const result: DocLink[] = []; + for (const [doc, paths] of docToPaths.entries()) { + paths.sort((a, b) => a.length - b.length); + for (let i = 0; i < paths.length; i++) { + const earlier = paths.slice(0, i); + if ( + earlier.some((path) => + path.every((key, index) => key === paths[i][index]) + ) + ) { + continue; + } + result.push({ cell: doc, path: paths[i] }); + } + } + return result; +} + +function pathAffected(changedPath: PropertyKey[], path: PropertyKey[]) { + changedPath = changedPath.map((key) => key.toString()); // Normalize to strings as keys + return ( + (changedPath.length <= path.length && + changedPath.every((key, index) => key === path[index])) || + path.every((key, index) => key === changedPath[index]) + ); +} diff --git a/typescript/packages/common-runner/src/utils.ts b/typescript/packages/common-runner/src/utils.ts index f6f6a00c9..7cdc4dff9 100644 --- a/typescript/packages/common-runner/src/utils.ts +++ b/typescript/packages/common-runner/src/utils.ts @@ -16,7 +16,7 @@ import { } from "./query-result-proxy.ts"; import { isCell } from "./cell.ts"; import { type ReactivityLog } from "./scheduler.ts"; -import { createRef } from "./cell-map.ts"; +import { createRef } from "./doc-map.ts"; export function extractDefaultValues(schema: any): any { if (typeof schema !== "object" || schema === null) return undefined; @@ -81,19 +81,19 @@ export function mergeObjects(...objects: any[]): any { // Sends a value to a binding. If the binding is an array or object, it'll // traverse the binding and the value in parallel accordingly. If the binding is // an alias, it will follow all aliases and send the value to the last aliased -// cell. If the binding is a literal, we verify that it matches the value and +// doc. If the binding is a literal, we verify that it matches the value and // throw an error otherwise. export function sendValueToBinding( - cell: DocImpl, + doc: DocImpl, binding: any, value: any, log?: ReactivityLog, ) { if (isAlias(binding)) { - const ref = followAliases(binding, cell, log); + const ref = followAliases(binding, doc, log); if (!isDocLink(value) && !isDoc(value) && !isAlias(value)) { normalizeToDocLinks( - cell, + doc, value, ref.cell.getAtPath(ref.path), log, @@ -104,12 +104,12 @@ export function sendValueToBinding( } else if (Array.isArray(binding)) { if (Array.isArray(value)) { for (let i = 0; i < Math.min(binding.length, value.length); i++) { - sendValueToBinding(cell, binding[i], value[i], log); + sendValueToBinding(doc, binding[i], value[i], log); } } } else if (typeof binding === "object" && binding !== null) { for (const key of Object.keys(binding)) { - if (key in value) sendValueToBinding(cell, binding[key], value[key], log); + if (key in value) sendValueToBinding(doc, binding[key], value[key], log); } } else { if (binding !== value) { @@ -119,17 +119,17 @@ export function sendValueToBinding( } // Sets a value at a path, following aliases and recursing into objects. Returns -// success, meaning no frozen cells were in the way. That is, also returns true +// success, meaning no frozen docs were in the way. That is, also returns true // if there was no change. export function setNestedValue( - currentCell: DocImpl, + doc: DocImpl, path: PropertyKey[], value: any, log?: ReactivityLog, ): boolean { - const destValue = currentCell.getAtPath(path); + const destValue = doc.getAtPath(path); if (isAlias(destValue)) { - const ref = followAliases(destValue, currentCell, log); + const ref = followAliases(destValue, doc, log); return setNestedValue(ref.cell, ref.path, value, log); } @@ -149,20 +149,20 @@ export function setNestedValue( for (const key in value) { if (key in destValue) { success &&= setNestedValue( - currentCell, + doc, [...path, key], value[key], log, ); } else { - if (currentCell.isFrozen()) success = false; - else currentCell.setAtPath([...path, key], value[key], log); + if (doc.isFrozen()) success = false; + else doc.setAtPath([...path, key], value[key], log); } } for (const key in destValue) { if (!(key in value)) { - if (currentCell.isFrozen()) success = false; - else currentCell.setAtPath([...path, key], undefined, log); + if (doc.isFrozen()) success = false; + else doc.setAtPath([...path, key], undefined, log); } } @@ -171,13 +171,13 @@ export function setNestedValue( if ( value.cell !== destValue.cell || !arrayEqual(value.path, destValue.path) ) { - currentCell.setAtPath(path, value, log); + doc.setAtPath(path, value, log); } return true; } else if (!Object.is(destValue, value)) { // Use Object.is for comparison to handle NaN and -0 correctly - if (currentCell.isFrozen()) return false; - currentCell.setAtPath(path, value, log); + if (doc.isFrozen()) return false; + doc.setAtPath(path, value, log); return true; } @@ -186,8 +186,8 @@ export function setNestedValue( /** * Unwraps one level of aliases, and - * - binds top-level aliases to passed cell - * - reduces wrapping count of closure cells by one + * - binds top-level aliases to passed doc + * - reduces wrapping count of closure docs by one * * This is used for arguments to nodes (which can be recipes, e.g. for map) and * for the recipe in recipe nodes. @@ -197,16 +197,16 @@ export function setNestedValue( * = Nested two layers deep, an argment for a nested recipe * - { $alias: { path: ["a"] } } * = One layer deep, e.g. a recipe that will be passed to `run` - * - { $alias: { cell: , path: ["a"] } } + * - { $alias: { cell: , path: ["a"] } } * = Unwrapped, executing the recipe * * @param binding - The binding to unwrap. - * @param cell - The cell to bind to. + * @param doc - The doc to bind to. * @returns The unwrapped binding. */ -export function unwrapOneLevelAndBindtoCell( +export function unwrapOneLevelAndBindtoDoc( binding: T, - cell: DocImpl, + doc: DocImpl, ): T { function convert(binding: any, processStatic = false): any { if (isStatic(binding) && !processStatic) { @@ -214,8 +214,8 @@ export function unwrapOneLevelAndBindtoCell( } else if (isAlias(binding)) { if (typeof binding.$alias.cell === "number") { if (binding.$alias.cell === 1) { - // Moved to the next-to-top level. Don't assign a cell, so that on - // next unwrap, the right cell be assigned. + // Moved to the next-to-top level. Don't assign a doc, so that on + // next unwrap, the right doc be assigned. return { $alias: { path: binding.$alias.path } }; } else { return { @@ -228,15 +228,15 @@ export function unwrapOneLevelAndBindtoCell( } } else { return { - // Bind to passed cell, if there isn't already one + // Bind to passed doc, if there isn't already one $alias: { - cell: binding.$alias.cell ?? cell, + cell: binding.$alias.cell ?? doc, path: binding.$alias.path, }, }; } } else if (isDoc(binding)) { - return binding; // Don't enter cells + return binding; // Don't enter docs } else if (Array.isArray(binding)) { return binding.map((value) => convert(value)); } else if (typeof binding === "object" && binding !== null) { @@ -277,23 +277,23 @@ export function unsafe_createParentBindings( } } -// Traverses binding and returns all cells reacheable through aliases. -export function findAllAliasedCells( +// Traverses binding and returns all docs reacheable through aliases. +export function findAllAliasedDocs( binding: any, - cell: DocImpl, + doc: DocImpl, ): DocLink[] { - const cells: DocLink[] = []; - function find(binding: any, origCell: DocImpl) { + const docs: DocLink[] = []; + function find(binding: any, origDoc: DocImpl) { if (isAlias(binding)) { - // Numbered cells are yet to be unwrapped nested recipes. Ignore them. + // Numbered docs are yet to be unwrapped nested recipes. Ignore them. if (typeof binding.$alias.cell === "number") return; - const cell = binding.$alias.cell ?? origCell; + const doc = binding.$alias.cell ?? origDoc; const path = binding.$alias.path; - if (cells.find((c) => c.cell === cell && c.path === path)) return; - cells.push({ cell, path }); - find(cell.getAtPath(path), cell); + if (docs.find((c) => c.cell === doc && c.path === path)) return; + docs.push({ cell: doc, path }); + find(doc.getAtPath(path), doc); } else if (Array.isArray(binding)) { - for (const value of binding) find(value, origCell); + for (const value of binding) find(value, origDoc); } else if ( typeof binding === "object" && binding !== null && @@ -301,11 +301,21 @@ export function findAllAliasedCells( !isDoc(binding) && !isCell(binding) ) { - for (const value of Object.values(binding)) find(value, origCell); + for (const value of Object.values(binding)) find(value, origDoc); } } - find(binding, cell); - return cells; + find(binding, doc); + return docs; +} + +export function resolveLinkToValue( + doc: DocImpl, + path: PropertyKey[], + log?: ReactivityLog, + seen: DocLink[] = [], +): DocLink { + const ref = resolvePath(doc, path, log, seen); + return followLinks(ref, seen, log); } export function resolvePath( @@ -321,6 +331,8 @@ export function resolvePath( // If the path points to a redirect itself, we don't want to follow it: Other // functions like followLwill do that. We just want to skip the interim ones. // + // All taken links are logged, but not the final one. + // // Let's look at a few examples: // // Doc: { link }, path: [] --> no change @@ -349,7 +361,9 @@ export function resolvePath( return ref; } -// Follows links and returns the last one. +// Follows links and returns the last one, which is pointing to a value. It'll +// log all taken links, so not the returned one, and thus nothing if the ref +// already pointed to a value. export function followLinks( ref: DocLink, seen: DocLink[] = [], @@ -398,7 +412,6 @@ export function followLinks( return ref; } -// Follows cell references and returns the last one // Follows cell references and returns the last one export function followCellReferences( reference: DocLink, @@ -441,78 +454,39 @@ export function followAliases( return result!; } -// Remove longer paths already covered by shorter paths -export function compactifyPaths(entries: DocLink[]): DocLink[] { - // First group by cell via a Map - const cellToPaths = new Map, PropertyKey[][]>(); - for (const { cell, path } of entries) { - const paths = cellToPaths.get(cell) || []; - paths.push(path.map((key) => key.toString())); // Normalize to strings as keys - cellToPaths.set(cell, paths); - } - - // For each cell, sort the paths by length, then only return those that don't - // have a prefix earlier in the list - const result: DocLink[] = []; - for (const [cell, paths] of cellToPaths.entries()) { - paths.sort((a, b) => a.length - b.length); - for (let i = 0; i < paths.length; i++) { - const earlier = paths.slice(0, i); - if ( - earlier.some((path) => - path.every((key, index) => key === paths[i][index]) - ) - ) { - continue; - } - result.push({ cell, path: paths[i] }); - } - } - return result; -} - -export function pathAffected(changedPath: PropertyKey[], path: PropertyKey[]) { - changedPath = changedPath.map((key) => key.toString()); // Normalize to strings as keys - return ( - (changedPath.length <= path.length && - changedPath.every((key, index) => key === path[index])) || - path.every((key, index) => key === changedPath[index]) - ); -} - /** - * Ensures that all elements of an array are cells. If not, i.e. they are static - * data, turn them into cell references. Also unwraps proxies. + * Ensures that all elements of an array are docs. If not, i.e. they are static + * data, turn them into doc links. Also unwraps proxies. * * Use e.g. when running a recipe and getting static data as input. * * @param value - The value to traverse and make sure all arrays are arrays of - * cells. NOTE: The passed value is mutated. + * docs. NOTE: The passed value is mutated. * @returns The (potentially unwrapped) input value */ -export function staticDataToNestedCells( - parentCell: DocImpl, +export function staticDataToNestedDocs( + parentDoc: DocImpl, value: any, log?: ReactivityLog, cause?: any, ): any { value = maybeUnwrapProxy(value); value = deepCopy(value); - normalizeToDocLinks(parentCell, value, undefined, log, cause); + normalizeToDocLinks(parentDoc, value, undefined, log, cause); return value; } /** - * Ensures that all elements of an array are cells. If not, i.e. they are static - * data, turn them into cells. "Is a cell" means it's either a cell, a cell - * reference or an alias. + * Ensures that all elements of an array are docs. If not, i.e. they are static + * data, turn them into doc links. "Is a doc" means it's either a doc, a doc + * link or an alias. * - * Pass the previous value to reuse cells from previous transitions. It does so + * Pass the previous value to reuse docs from previous transitions. It does so * if the values match, but only on arrays (as for objects we don't (yet?) do * this behind the scenes translation). * * @param value - The value to traverse and make sure all arrays are arrays of - * cells. + * docs. * @returns Whether the value was changed. */ export function normalizeToDocLinks( diff --git a/typescript/packages/common-runner/test/cell.test.ts b/typescript/packages/common-runner/test/cell.test.ts index 714889e45..ea1422d91 100644 --- a/typescript/packages/common-runner/test/cell.test.ts +++ b/typescript/packages/common-runner/test/cell.test.ts @@ -1,12 +1,11 @@ import { describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; -import { getDoc, isDoc, isDocLink } from "../src/doc.ts"; +import { type DocImpl, getDoc, isDoc, isDocLink } from "../src/doc.ts"; import { isCell } from "../src/cell.ts"; import { isQueryResult } from "../src/query-result-proxy.ts"; import { type ReactivityLog } from "../src/scheduler.ts"; import { JSONSchema } from "@commontools/builder"; import { addEventHandler, idle } from "../src/scheduler.ts"; -import { compactifyPaths } from "../src/utils.ts"; import { getSpace } from "../src/space.ts"; describe("Cell", () => { @@ -863,14 +862,12 @@ describe("asCell with schema", () => { expect(isCell(value.context.nested)).toBe(true); expect(value.context.nested.get().value).toBe(42); - // All references in the chain should be read - const reads = compactifyPaths(log.reads); - - expect(reads.length).toBe(4); - expect(reads[0].cell).toBe(c); - expect(reads[1].cell).toBe(ref3.cell); - expect(reads[2].cell).toBe(ref2.cell); - expect(reads[3].cell).toBe(ref1.cell); + const readDocs = new Set>(log.reads.map((r) => r.cell)); + expect(readDocs.size).toBe(4); + expect(readDocs.has(c)).toBe(true); + expect(readDocs.has(ref3.cell)).toBe(true); + expect(readDocs.has(ref2.cell)).toBe(true); + expect(readDocs.has(ref1.cell)).toBe(true); // Changes to the original cell should propagate through the chain innerCell.send({ value: 100 }); diff --git a/typescript/packages/common-runner/test/call-map.test.ts b/typescript/packages/common-runner/test/doc-map.test.ts similarity index 99% rename from typescript/packages/common-runner/test/call-map.test.ts rename to typescript/packages/common-runner/test/doc-map.test.ts index a60d6e0fc..c14c407af 100644 --- a/typescript/packages/common-runner/test/call-map.test.ts +++ b/typescript/packages/common-runner/test/doc-map.test.ts @@ -5,7 +5,7 @@ import { type EntityId, getDocByEntityId, getEntityId, -} from "../src/cell-map.ts"; +} from "../src/doc-map.ts"; import { getDoc } from "../src/doc.ts"; import { refer } from "merkle-reference"; import { getSpace } from "../src/space.ts"; diff --git a/typescript/packages/common-runner/test/scheduler.test.ts b/typescript/packages/common-runner/test/scheduler.test.ts index 99afcf910..6f101804b 100644 --- a/typescript/packages/common-runner/test/scheduler.test.ts +++ b/typescript/packages/common-runner/test/scheduler.test.ts @@ -6,6 +6,7 @@ import { type ReactivityLog } from "../src/scheduler.ts"; import { type Action, addEventHandler, + compactifyPaths, type EventHandler, idle, onError, @@ -362,3 +363,52 @@ describe("event handling", () => { expect(lastEventSeen).toBe(2); }); }); + +describe("compactifyPaths", () => { + it("should compactify paths", () => { + const testCell = getDoc({}); + const paths = [ + { cell: testCell, path: ["a", "b"] }, + { cell: testCell, path: ["a"] }, + { cell: testCell, path: ["c"] }, + ]; + const result = compactifyPaths(paths); + expect(result).toEqual([ + { cell: testCell, path: ["a"] }, + { cell: testCell, path: ["c"] }, + ]); + }); + + it("should remove duplicate paths", () => { + const testCell = getDoc({}); + const paths = [ + { cell: testCell, path: ["a", "b"] }, + { cell: testCell, path: ["a", "b"] }, + ]; + const result = compactifyPaths(paths); + expect(result).toEqual([{ cell: testCell, path: ["a", "b"] }]); + }); + + it("should not compactify across cells", () => { + const cellA = getDoc({}); + const cellB = getDoc({}); + const paths = [ + { cell: cellA, path: ["a", "b"] }, + { cell: cellB, path: ["a", "b"] }, + ]; + const result = compactifyPaths(paths); + expect(result).toEqual(paths); + }); + + it("empty paths should trump all other ones", () => { + const cellA = getDoc({}); + const paths = [ + { cell: cellA, path: ["a", "b"] }, + { cell: cellA, path: ["c"] }, + { cell: cellA, path: ["d"] }, + { cell: cellA, path: [] }, + ]; + const result = compactifyPaths(paths); + expect(result).toEqual([{ cell: cellA, path: [] }]); + }); +}); diff --git a/typescript/packages/common-runner/test/space.test.ts b/typescript/packages/common-runner/test/space.test.ts index 6b8d536d5..6c63b4517 100644 --- a/typescript/packages/common-runner/test/space.test.ts +++ b/typescript/packages/common-runner/test/space.test.ts @@ -2,7 +2,7 @@ import { describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; import { getSpace } from "../src/space.ts"; import { getDoc } from "../src/doc.ts"; -import { getDocByEntityId } from "../src/cell-map.ts"; +import { getDocByEntityId } from "../src/doc-map.ts"; describe("Space", () => { it("should create spaces with URIs", () => { diff --git a/typescript/packages/common-runner/test/utils.test.ts b/typescript/packages/common-runner/test/utils.test.ts index c7b96b132..c7473e471 100644 --- a/typescript/packages/common-runner/test/utils.test.ts +++ b/typescript/packages/common-runner/test/utils.test.ts @@ -1,7 +1,6 @@ import { describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; import { - compactifyPaths, extractDefaultValues, followAliases, followCellReferences, @@ -9,7 +8,7 @@ import { normalizeToDocLinks, sendValueToBinding, setNestedValue, - unwrapOneLevelAndBindtoCell, + unwrapOneLevelAndBindtoDoc, } from "../src/utils.ts"; import { DocLink, getDoc, isDocLink } from "../src/doc.ts"; import { type ReactivityLog } from "../src/scheduler.ts"; @@ -230,7 +229,7 @@ describe("mapBindingToCell", () => { z: 3, }; - const result = unwrapOneLevelAndBindtoCell(binding, testCell); + const result = unwrapOneLevelAndBindtoDoc(binding, testCell); expect(result).toEqual({ x: { $alias: { cell: testCell, path: ["a"] } }, y: { $alias: { cell: testCell, path: ["b", "c"] } }, @@ -299,55 +298,6 @@ describe("followAliases", () => { }); }); -describe("compactifyPaths", () => { - it("should compactify paths", () => { - const testCell = getDoc({}); - const paths = [ - { cell: testCell, path: ["a", "b"] }, - { cell: testCell, path: ["a"] }, - { cell: testCell, path: ["c"] }, - ]; - const result = compactifyPaths(paths); - expect(result).toEqual([ - { cell: testCell, path: ["a"] }, - { cell: testCell, path: ["c"] }, - ]); - }); - - it("should remove duplicate paths", () => { - const testCell = getDoc({}); - const paths = [ - { cell: testCell, path: ["a", "b"] }, - { cell: testCell, path: ["a", "b"] }, - ]; - const result = compactifyPaths(paths); - expect(result).toEqual([{ cell: testCell, path: ["a", "b"] }]); - }); - - it("should not compactify across cells", () => { - const cellA = getDoc({}); - const cellB = getDoc({}); - const paths = [ - { cell: cellA, path: ["a", "b"] }, - { cell: cellB, path: ["a", "b"] }, - ]; - const result = compactifyPaths(paths); - expect(result).toEqual(paths); - }); - - it("empty paths should trump all other ones", () => { - const cellA = getDoc({}); - const paths = [ - { cell: cellA, path: ["a", "b"] }, - { cell: cellA, path: ["c"] }, - { cell: cellA, path: ["d"] }, - { cell: cellA, path: [] }, - ]; - const result = compactifyPaths(paths); - expect(result).toEqual([{ cell: cellA, path: [] }]); - }); -}); - describe("makeArrayElementsAllCells", () => { it("should convert non-cell array elements to cell references", () => { const input = [1, 2, 3];