diff --git a/packages/patterns/wish-note-example.tsx b/packages/patterns/wish-note-example.tsx new file mode 100644 index 0000000000..4909c5fbb2 --- /dev/null +++ b/packages/patterns/wish-note-example.tsx @@ -0,0 +1,19 @@ +/// +import { NAME, pattern, UI, wish } from "commontools"; + +export default pattern>((_) => { + const wishResult = wish<{ content: string }>({ query: "#note" }); + + return { + [NAME]: "Wish tester", + [UI]: ( +
+
{wishResult.result.content}
+
+ {wishResult.result} +
+ {wishResult} +
+ ), + }; +}); diff --git a/packages/patterns/wish.tsx b/packages/patterns/wish.tsx index d92cd20e30..abc9968f3f 100644 --- a/packages/patterns/wish.tsx +++ b/packages/patterns/wish.tsx @@ -1,19 +1,42 @@ /// -import { NAME, pattern, UI, wish } from "commontools"; +import { + type Cell, + computed, + type Default, + pattern, + UI, + type WishState, +} from "commontools"; -export default pattern>((_) => { - const wishResult = wish<{ content: string }>({ query: "#note" }); +// Copy of the original with less fancy types, since we ran into limits of the +// schema translation here. +export type WishParams = { + query: string; + path?: string[]; + context?: Record; + schema?: any; + scope?: string[]; +}; - return { - [NAME]: "Wish tester", - [UI]: ( -
-
{wishResult.result.content}
-
- {wishResult.result} -
- {wishResult[UI]} -
- ), - }; -}); +export default pattern< + WishParams & { candidates: Default[], []> }, + WishState +>( + ({ query: _query, context: _context, candidates }) => { + return { + result: computed(() => candidates.length > 0 ? candidates[0] : undefined), + [UI]: ( +
+ {candidates.map((candidate) => ( + /* TODO(seefeld/ben): Implement picker that updates `result` */ +
+ +
+ ))} +
+ ), + }; + }, +); diff --git a/packages/runner/src/builtins/wish.ts b/packages/runner/src/builtins/wish.ts index 1c0716fd83..a16d5d842b 100644 --- a/packages/runner/src/builtins/wish.ts +++ b/packages/runner/src/builtins/wish.ts @@ -5,6 +5,7 @@ import { type WishTag, } from "@commontools/api"; import { h } from "@commontools/html"; +import { HttpProgramResolver } from "@commontools/js-compiler"; import { type Cell } from "../cell.ts"; import { type Action } from "../scheduler.ts"; import type { IRuntime } from "../runtime.ts"; @@ -14,7 +15,7 @@ import type { } from "../storage/interface.ts"; import type { EntityId } from "../create-ref.ts"; import { ALL_CHARMS_ID } from "./well-known.ts"; -import { type JSONSchema, UI } from "../builder/types.ts"; +import { type JSONSchema, type Recipe, UI } from "../builder/types.ts"; // Define locally to avoid circular dependency with @commontools/charm const favoriteEntrySchema = { @@ -30,14 +31,9 @@ const favoriteListSchema = { type: "array", items: favoriteEntrySchema, } as const satisfies JSONSchema; +import { getRecipeEnvironment } from "../env.ts"; -function errorUI(message: string): VNode { - return h("span", { style: "color: red" }, `⚠️ ${message}`); -} - -function cellLinkUI(cell: Cell): VNode { - return h("ct-cell-link", { $cell: cell }); -} +const WISH_TSX_PATH = getRecipeEnvironment().apiUrl + "api/patterns/wish.tsx"; class WishError extends Error { constructor(message: string) { @@ -147,19 +143,19 @@ function formatTarget(parsed: ParsedWishTarget): string { function resolveBase( parsed: ParsedWishTarget, ctx: WishContext, -): BaseResolution { +): BaseResolution[] { switch (parsed.key) { case "/": - return { cell: getSpaceCell(ctx) }; + return [{ cell: getSpaceCell(ctx) }]; case "#default": - return { cell: getSpaceCell(ctx), pathPrefix: ["defaultPattern"] }; + return [{ cell: getSpaceCell(ctx), pathPrefix: ["defaultPattern"] }]; case "#mentionable": - return { + return [{ cell: getSpaceCell(ctx), pathPrefix: ["defaultPattern", "backlinksIndex", "mentionable"], - }; + }]; case "#recent": - return { cell: getSpaceCell(ctx), pathPrefix: ["recentCharms"] }; + return [{ cell: getSpaceCell(ctx), pathPrefix: ["recentCharms"] }]; case "#favorites": { // Favorites always come from the HOME space (user identity DID) const userDID = ctx.runtime.userIdentityDID; @@ -175,7 +171,7 @@ function resolveBase( // No path = return favorites list if (parsed.path.length === 0) { - return { cell: homeSpaceCell, pathPrefix: ["favorites"] }; + return [{ cell: homeSpaceCell, pathPrefix: ["favorites"] }]; } // Path provided = search by tag @@ -194,10 +190,10 @@ function resolveBase( throw new WishError(`No favorite found matching "${searchTerm}"`); } - return { + return [{ cell: match.cell, pathPrefix: parsed.path.slice(1), // remaining path after search term - }; + }]; } case "#now": { if (parsed.path.length > 0) { @@ -211,7 +207,7 @@ function resolveBase( undefined, ctx.tx, ); - return { cell: nowCell }; + return [{ cell: nowCell }]; } default: { // Check if it's a well-known target @@ -223,41 +219,83 @@ function resolveBase( ctx.parentCell.space, ctx.tx, ); - return { cell: baseCell }; + return [{ cell: baseCell }]; } - // Unknown tag = search favorites by tag - const userDID = ctx.runtime.userIdentityDID; - if (!userDID) { - throw new WishError( - "User identity DID not available for favorites search", + // Hash tag: Look for exact matches in favorites. + if (parsed.key.startsWith("#")) { + // Unknown tag = search favorites by tag + const userDID = ctx.runtime.userIdentityDID; + if (!userDID) { + throw new WishError( + "User identity DID not available for favorites search", + ); + } + + const homeSpaceCell = ctx.runtime.getHomeSpaceCell(ctx.tx); + const favoritesCell = homeSpaceCell.key("favorites").asSchema( + favoriteListSchema, ); + const favorites = favoritesCell.get() || []; + + // Match hash tags in tag field (the schema), all lowercase. + const searchTerm = parsed.key.toLowerCase(); + const matches = favorites.filter((entry) => { + const hashtags = + entry.tag?.toLowerCase().matchAll(/#([a-z0-9-]+)/g) ?? []; + return [...hashtags].some((m) => m[0] === searchTerm); + }); + + if (matches.length === 0) { + throw new WishError(`No favorite found matching "${searchTerm}"`); + } + + return matches.map((match) => ({ + cell: match.cell, + pathPrefix: parsed.path, + })); } - const homeSpaceCell = ctx.runtime.getHomeSpaceCell(ctx.tx); - const favoritesCell = homeSpaceCell.key("favorites").asSchema( - favoriteListSchema, - ); - const favorites = favoritesCell.get() || []; + throw new WishError(`Wish target "${parsed.key}" is not recognized.`); + } + } +} - // Search term is the tag without the # prefix - const searchTerm = parsed.key.slice(1).toLowerCase(); - const match = favorites.find((entry) => - entry.tag?.toLowerCase().includes(searchTerm) - ); +// fetchWishPattern runs at runtime scope, shared across all wish invocations +let wishPatternFetchPromise: Promise | undefined; +let wishPattern: Recipe | undefined; - if (!match) { - throw new WishError(`No favorite found matching "${searchTerm}"`); - } +async function fetchWishPattern( + runtime: IRuntime, +): Promise { + try { + const program = await runtime.harness.resolve( + new HttpProgramResolver(WISH_TSX_PATH), + ); - return { - cell: match.cell, - pathPrefix: parsed.path, - }; + if (!program) { + throw new WishError("Can't load wish.tsx"); } + const pattern = await runtime.recipeManager.compileRecipe(program); + + if (!pattern) throw new WishError("Can't compile wish.tsx"); + + return pattern; + } catch (e) { + console.error("Can't load wish.tsx", e); + return undefined; } } +function errorUI(message: string): VNode { + return h("span", { style: "color: red" }, `⚠️ ${message}`); +} + +// TODO(seefeld): Add button to replace this with wish.tsx getting more options +function cellLinkUI(cell: Cell): VNode { + return h("ct-cell-link", { $cell: cell }); +} + const TARGET_SCHEMA = { anyOf: [{ type: "string", @@ -270,17 +308,64 @@ const TARGET_SCHEMA = { context: { type: "object", additionalProperties: { asCell: true } }, scope: { type: "array", items: { type: "string" } }, }, + required: ["query"], }], } as const satisfies JSONSchema; export function wish( inputsCell: Cell<[unknown, unknown]>, sendResult: (tx: IExtendedStorageTransaction, result: unknown) => void, - _addCancel: (cancel: () => void) => void, - _cause: Cell[], + addCancel: (cancel: () => void) => void, + cause: Cell[], parentCell: Cell, runtime: IRuntime, ): Action { + // Per-instance wish pattern loading. + let wishPatternInput: WishParams | undefined; + let wishPatternResultCell: Cell> | undefined; + + function launchWishPattern( + input?: WishParams & { candidates?: Cell[] }, + providedTx?: IExtendedStorageTransaction, + ) { + if (input) wishPatternInput = input; + + const tx = providedTx || runtime.edit(); + + if (!wishPatternResultCell) { + wishPatternResultCell = runtime.getCell( + parentCell.space, + { wish: { wishPattern: cause } }, + undefined, + tx, + ); + + addCancel(() => runtime.runner.stop(wishPatternResultCell!)); + } + + if (!wishPattern) { + if (!wishPatternFetchPromise) { + wishPatternFetchPromise = fetchWishPattern(runtime).then((pattern) => { + wishPattern = pattern; + return pattern; + }); + } + wishPatternFetchPromise.then((pattern) => { + if (pattern) { + launchWishPattern(); + } + }); + } else { + runtime.runSynced(wishPatternResultCell, wishPattern, wishPatternInput); + } + + if (!providedTx) tx.commit(); + + return wishPatternResultCell; + } + + // Wish action, reactive to changes in inputsCell and any cell we read during + // initial resolution return (tx: IExtendedStorageTransaction) => { const inputsWithTx = inputsCell.withTx(tx); const targetValue = inputsWithTx.asSchema(TARGET_SCHEMA).get(); @@ -295,14 +380,15 @@ export function wish( try { const parsed = parseWishTarget(wishTarget); const ctx: WishContext = { runtime, tx, parentCell }; - const baseResolution = resolveBase(parsed, ctx); - const combinedPath = baseResolution.pathPrefix - ? [...baseResolution.pathPrefix, ...parsed.path] + const baseResolutions = resolveBase(parsed, ctx); + + // Just use the first result (if there aren't any, the above throws) + const combinedPath = baseResolutions[0].pathPrefix + ? [...baseResolutions[0].pathPrefix, ...parsed.path] : parsed.path; - const resolvedCell = resolvePath(baseResolution.cell, combinedPath); + const resolvedCell = resolvePath(baseResolutions[0].cell, combinedPath); sendResult(tx, resolvedCell); - } catch (e) { - console.error(e instanceof WishError ? e.message : e); + } catch (_e) { sendResult(tx, undefined); } return; @@ -314,7 +400,6 @@ export function wish( const errorMsg = `Wish target "${ JSON.stringify(targetValue) }" has no query.`; - console.error(errorMsg); sendResult( tx, { error: errorMsg, [UI]: errorUI(errorMsg) } satisfies WishState, @@ -330,21 +415,33 @@ export function wish( path: path ?? [], }; const ctx: WishContext = { runtime, tx, parentCell }; - const baseResolution = resolveBase(parsed, ctx); - const combinedPath = baseResolution.pathPrefix - ? [...baseResolution.pathPrefix, ...(path ?? [])] - : path ?? []; - const resolvedCell = resolvePath(baseResolution.cell, combinedPath); - const resultCell = schema - ? resolvedCell.asSchema(schema) - : resolvedCell; - sendResult(tx, { - result: resultCell, - [UI]: cellLinkUI(resultCell), + const baseResolutions = resolveBase(parsed, ctx); + const resultCells = baseResolutions.map((baseResolution) => { + const combinedPath = baseResolution.pathPrefix + ? [...baseResolution.pathPrefix, ...(path ?? [])] + : path ?? []; + const resolvedCell = resolvePath(baseResolution.cell, combinedPath); + return schema ? resolvedCell.asSchema(schema) : resolvedCell; }); + if (resultCells.length === 1) { + // If it's one result, just return it directly + sendResult(tx, { + result: resultCells[0], + [UI]: cellLinkUI(resultCells[0]), + }); + } else { + // If it's multiple result, launch the wish pattern, which will + // immediately return the first candidate as result + sendResult( + tx, + launchWishPattern({ + ...targetValue as WishParams, + candidates: resultCells, + }, tx), + ); + } } catch (e) { const errorMsg = e instanceof WishError ? e.message : String(e); - console.error(errorMsg); sendResult( tx, { error: errorMsg, [UI]: errorUI(errorMsg) } satisfies WishState< @@ -353,17 +450,12 @@ export function wish( ); } } else { - const errorMsg = "Non hash tag or path query not yet supported"; - console.error(errorMsg); - sendResult( - tx, - { error: errorMsg, [UI]: errorUI(errorMsg) } satisfies WishState, - ); + // Otherwise it's a generic query and we need to launch the wish pattern + sendResult(tx, launchWishPattern(targetValue as WishParams, tx)); } return; } else { const errorMsg = `Wish target is not recognized: ${targetValue}`; - console.error(errorMsg); sendResult( tx, { error: errorMsg, [UI]: errorUI(errorMsg) } satisfies WishState, diff --git a/packages/runner/src/cell.ts b/packages/runner/src/cell.ts index 76a1917730..006f54a4b8 100644 --- a/packages/runner/src/cell.ts +++ b/packages/runner/src/cell.ts @@ -69,7 +69,10 @@ import type { IExtendedStorageTransaction, IReadOptions, } from "./storage/interface.ts"; -import { NonReactiveTransaction } from "./storage/extended-storage-transaction.ts"; +import { + createChildCellTransaction, + createNonReactiveTransaction, +} from "./storage/extended-storage-transaction.ts"; import { fromURI } from "./uri-utils.ts"; import { ContextualFlowControl } from "./cfc.ts"; @@ -532,11 +535,11 @@ export class CellImpl implements ICell, IStreamable { sample(): Readonly { if (!this.synced) this.sync(); // No await, just kicking this off - // Wrap the transaction with NonReactiveTransaction to make all reads - // non-reactive. Child cells created during validateAndTransform will - // use the original transaction (via getTransactionForChildCells). + // Wrap the transaction to make all reads non-reactive. Child cells created + // during validateAndTransform will use the original transaction (via + // getTransactionForChildCells). const readTx = this.runtime.readTx(this.tx); - const nonReactiveTx = new NonReactiveTransaction(readTx); + const nonReactiveTx = createNonReactiveTransaction(readTx); return validateAndTransform( this.runtime, @@ -1287,17 +1290,14 @@ function subscribeToReferencedDocs( const cancel = runtime.scheduler.subscribe((tx) => { if (isCancel(cleanup)) cleanup(); - // Run once with tx to capture _this_ cell's read dependencies. - validateAndTransform(runtime, tx, link, true); - - // Using a new transaction for the callback, as we're only interested in + // Using a new transaction for child cells, as we're only interested in // dependencies for the initial get, not further cells the callback might // read. The callback is responsible for calling sink on those cells if it // wants to stay updated. - const extraTx = runtime.edit(); + const wrappedTx = createChildCellTransaction(tx, extraTx); - const newValue = validateAndTransform(runtime, extraTx, link, true); + const newValue = validateAndTransform(runtime, wrappedTx, link, true); cleanup = callback(newValue); // no async await here, but that also means no retry. TODO(seefeld): Should diff --git a/packages/runner/src/storage/extended-storage-transaction.ts b/packages/runner/src/storage/extended-storage-transaction.ts index cd369f18ac..e7e99496bb 100644 --- a/packages/runner/src/storage/extended-storage-transaction.ts +++ b/packages/runner/src/storage/extended-storage-transaction.ts @@ -216,21 +216,44 @@ export class ExtendedStorageTransaction implements IExtendedStorageTransaction { } /** - * A wrapper around an IExtendedStorageTransaction that adds ignoreReadForScheduling - * meta to all read operations. This makes reads non-reactive - they won't trigger - * re-execution of handlers when the read values change. + * Options for configuring a TransactionWrapper. + */ +export interface TransactionWrapperOptions { + /** + * If true, adds ignoreReadForScheduling meta to all reads, making them + * non-reactive. + */ + nonReactive?: boolean; + + /** + * Transaction to use for creating child cells. If not provided, uses the + * wrapped transaction. + */ + childCellTx?: IExtendedStorageTransaction; +} + +/** + * A configurable wrapper around an IExtendedStorageTransaction. * - * Used by Cell.sample() to read values without subscribing to changes. + * Supports two modes that can be combined: + * - nonReactive: Adds ignoreReadForScheduling meta to all reads + * - childCellTx: Uses a different transaction for child cells + * + * Used by: + * - Cell.sample(): nonReactive=true, childCellTx=wrapped (child cells reactive) + * - Cell.sink(): nonReactive=false, childCellTx=extraTx (child cells on separate tx) */ -export class NonReactiveTransaction implements IExtendedStorageTransaction { - constructor(private wrapped: IExtendedStorageTransaction) {} +export class TransactionWrapper implements IExtendedStorageTransaction { + constructor( + private wrapped: IExtendedStorageTransaction, + private options: TransactionWrapperOptions = {}, + ) {} /** * Get the transaction to use for creating child cells. - * Child cells should be reactive, so this returns the unwrapped transaction. */ getTransactionForChildCells(): IExtendedStorageTransaction { - return this.wrapped; + return this.options.childCellTx ?? this.wrapped; } get tx(): IStorageTransaction { @@ -249,7 +272,10 @@ export class NonReactiveTransaction implements IExtendedStorageTransaction { return this.wrapped.reader(space); } - private addNonReactiveMeta(options?: IReadOptions): IReadOptions { + private transformReadOptions(options?: IReadOptions): IReadOptions { + if (!this.options.nonReactive) { + return options ?? {}; + } return { ...options, meta: { ...options?.meta, ...ignoreReadForScheduling }, @@ -260,14 +286,17 @@ export class NonReactiveTransaction implements IExtendedStorageTransaction { address: IMemorySpaceAddress, options?: IReadOptions, ): Result { - return this.wrapped.read(address, this.addNonReactiveMeta(options)); + return this.wrapped.read(address, this.transformReadOptions(options)); } readOrThrow( address: IMemorySpaceAddress, options?: IReadOptions, ): JSONValue | undefined { - return this.wrapped.readOrThrow(address, this.addNonReactiveMeta(options)); + return this.wrapped.readOrThrow( + address, + this.transformReadOptions(options), + ); } readValueOrThrow( @@ -276,7 +305,7 @@ export class NonReactiveTransaction implements IExtendedStorageTransaction { ): JSONValue | undefined { return this.wrapped.readValueOrThrow( address, - this.addNonReactiveMeta(options), + this.transformReadOptions(options), ); } @@ -318,18 +347,39 @@ export class NonReactiveTransaction implements IExtendedStorageTransaction { } } +/** + * Create a non-reactive transaction wrapper for Cell.sample(). + * Reads won't trigger re-execution, but child cells will be reactive. + */ +export function createNonReactiveTransaction( + tx: IExtendedStorageTransaction, +): TransactionWrapper { + return new TransactionWrapper(tx, { nonReactive: true, childCellTx: tx }); +} + +/** + * Create a transaction wrapper for Cell.sink() that uses a separate transaction + * for child cells. + */ +export function createChildCellTransaction( + tx: IExtendedStorageTransaction, + childCellTx: IExtendedStorageTransaction, +): TransactionWrapper { + return new TransactionWrapper(tx, { childCellTx }); +} + /** * Helper function to get the transaction to use for creating child cells from a - * potentially wrapped NonReactiveTransaction. If the transaction is not wrapped, - * returns it as-is. + * potentially wrapped transaction. If the transaction is not wrapped, returns + * it as-is. * - * Used when creating child cells that should be reactive even when the parent - * read was non-reactive (e.g., in Cell.sample()). + * Used when creating child cells that should use a different transaction than + * the parent read (e.g., in Cell.sample() or Cell.sink()). */ export function getTransactionForChildCells( tx: IExtendedStorageTransaction | undefined, ): IExtendedStorageTransaction | undefined { - if (tx instanceof NonReactiveTransaction) { + if (tx instanceof TransactionWrapper) { return tx.getTransactionForChildCells(); } return tx; diff --git a/packages/runner/src/storage/interface.ts b/packages/runner/src/storage/interface.ts index 564a82848f..9ea916916f 100644 --- a/packages/runner/src/storage/interface.ts +++ b/packages/runner/src/storage/interface.ts @@ -922,5 +922,9 @@ export interface IAttestation { readonly value?: JSONValue; } -// Re-export NonReactiveTransaction from implementation -export { NonReactiveTransaction } from "./extended-storage-transaction.ts"; +// Re-export transaction wrapper utilities from implementation +export { + createChildCellTransaction, + createNonReactiveTransaction, + TransactionWrapper, +} from "./extended-storage-transaction.ts"; diff --git a/packages/runner/test/wish.test.ts b/packages/runner/test/wish.test.ts index 9f1ca8161a..f09e8dccbd 100644 --- a/packages/runner/test/wish.test.ts +++ b/packages/runner/test/wish.test.ts @@ -373,35 +373,24 @@ describe("wish built-in", () => { }); it("returns undefined for unknown wishes", async () => { - const errors: unknown[] = []; - const originalError = console.error; - console.error = (...args: unknown[]) => { - errors.push(args); - }; - - try { - const wishRecipe = recipe("wish unknown target", () => { - const missing = wish("commontools://unknown"); - return { missing }; - }); + const wishRecipe = recipe("wish unknown target", () => { + const missing = wish("commontools://unknown"); + return { missing }; + }); - const resultCell = runtime.getCell<{ missing?: unknown }>( - space, - "wish built-in missing target", - undefined, - tx, - ); - const result = runtime.run(tx, wishRecipe, {}, resultCell); - await tx.commit(); - tx = runtime.edit(); + const resultCell = runtime.getCell<{ missing?: unknown }>( + space, + "wish built-in missing target", + undefined, + tx, + ); + const result = runtime.run(tx, wishRecipe, {}, resultCell); + await tx.commit(); + tx = runtime.edit(); - await runtime.idle(); + await runtime.idle(); - expect(result.key("missing").get()).toBeUndefined(); - expect(errors.length).toBeGreaterThan(0); - } finally { - console.error = originalError; - } + expect(result.key("missing").get()).toBeUndefined(); }); describe("object-based wish syntax", () => { @@ -596,76 +585,54 @@ describe("wish built-in", () => { }); it("returns error for unknown tag", async () => { - const errors: unknown[] = []; - const originalError = console.error; - console.error = (...args: unknown[]) => { - errors.push(args); - }; - - try { - const wishRecipe = recipe("wish object syntax unknown", () => { - const missing = wish({ query: "#unknownTag" }); - return { missing }; - }); + const wishRecipe = recipe("wish object syntax unknown", () => { + const missing = wish({ query: "#unknownTag" }); + return { missing }; + }); - const resultCell = runtime.getCell<{ - missing?: { result?: unknown }; - }>( - space, - "wish object syntax unknown result", - undefined, - tx, - ); - const result = runtime.run(tx, wishRecipe, {}, resultCell); - await tx.commit(); - await runtime.idle(); - tx = runtime.edit(); + const resultCell = runtime.getCell<{ + missing?: { result?: unknown }; + }>( + space, + "wish object syntax unknown result", + undefined, + tx, + ); + const result = runtime.run(tx, wishRecipe, {}, resultCell); + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); - await runtime.idle(); + await runtime.idle(); - const missingResult = result.key("missing").get(); - // Unknown tags now search favorites, returning "No favorite found" error - expect(missingResult?.error).toMatch(/No favorite found matching/); - expect(errors.length).toBeGreaterThan(0); - } finally { - console.error = originalError; - } + const missingResult = result.key("missing").get(); + // Unknown tags now search favorites, returning "No favorite found" error + expect(missingResult?.error).toMatch(/No favorite found matching/); }); it("returns error when tag is missing", async () => { - const errors: unknown[] = []; - const originalError = console.error; - console.error = (...args: unknown[]) => { - errors.push(args); - }; - - try { - const wishRecipe = recipe("wish object syntax no tag", () => { - const missing = wish({ query: "", path: ["some", "path"] }); - return { missing }; - }); + const wishRecipe = recipe("wish object syntax no tag", () => { + const missing = wish({ query: "", path: ["some", "path"] }); + return { missing }; + }); - const resultCell = runtime.getCell<{ - missing?: { error?: string }; - }>( - space, - "wish object syntax no tag result", - undefined, - tx, - ); - const result = runtime.run(tx, wishRecipe, {}, resultCell); - await tx.commit(); - await runtime.idle(); - tx = runtime.edit(); + const resultCell = runtime.getCell<{ + missing?: { error?: string }; + }>( + space, + "wish object syntax no tag result", + undefined, + tx, + ); + const result = runtime.run(tx, wishRecipe, {}, resultCell); + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); - await runtime.idle(); + await runtime.idle(); - const missingResult = result.key("missing").get(); - expect(missingResult?.error).toMatch(/no query/); - expect(errors.length).toBeGreaterThan(0); - } finally { - console.error = originalError; - } + const missingResult = result.key("missing").get(); + expect(missingResult?.error).toMatch(/no query/); }); it("returns UI with ct-cell-link on success", async () => {