diff --git a/docs/common/FAVORITES.md b/docs/common/FAVORITES.md new file mode 100644 index 000000000..b9e690bc5 --- /dev/null +++ b/docs/common/FAVORITES.md @@ -0,0 +1,48 @@ +Charms can be favorites and added to your [[HOME_SPACE]]. These charms can be accessed from _any_ space, via this list. + +# Accessing the Favorites list + +You can `wish` for the favorites list itself (see `favorites-manager.tsx` for a full example): + +```tsx +type Favorite = { cell: Cell<{ [NAME]?: string }>; description: string }; +const wishResult = wish>({ tag: "#favorites" }); +``` + +The `description` field contains the serialized `resultSchema` of the charm pointed to by `cell`. This is useful, because the description can contain tags as hints to the `wish` system. + +# Wishing for A Specific Charm + +See `wish.tsx` for a full example. + +In `note.tsx` I decorate my schema with a description containing "#note": +```tsx +/** Represents a small #note a user took to remember some text. */ +type Output = { + mentioned: Default, []>; + backlinks: MentionableCharm[]; + + content: Default; + grep: Stream<{ query: string }>; + translate: Stream<{ language: string }>; + editContent: Stream<{ detail: { value: string } }>; +}; +``` + +Later, I wish for "#note" and discover the first matching item in the list. + +```tsx +const wishResult = wish<{ content: string }>({ tag: "#note" }); +``` + +# Intended Usage + +Keep a handle to important information in a charm, e.g. google auth, user preferences/biography, cross-cutting data (calendar). + +# Future Plans + +This is the minimum viable design. We will later: + +- find tags on specific sub-schemas and properly discover the paths to the subtrees +- result a 'result picker' UI from in the `wishResult` to choose between many options and/or override +- support filtering `wish` to certain scopes diff --git a/packages/api/index.ts b/packages/api/index.ts index 581f256a5..3147a5a60 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -1252,8 +1252,33 @@ export type CompileAndRunFunction = ( params: Opaque>, ) => OpaqueRef>; +export type WishTag = `/${string}` | `#${string}`; + +export type DID = `did:${string}:${string}`; + +export type WishParams = { + tag?: WishTag; + path?: string[]; + context?: Record; + schema?: JSONSchema; + scope?: (DID | "~" | ".")[]; +}; + +export type WishState = { + result?: T; + error?: any; + [UI]?: VNode; +}; + export type NavigateToFunction = (cell: OpaqueRef) => OpaqueRef; export type WishFunction = { + (target: Opaque): OpaqueRef>>; + ( + target: Opaque, + schema: S, + ): OpaqueRef>>>; + + // TODO(seefeld): Remove old interface mid December 2025 (target: Opaque): OpaqueRef; ( target: Opaque, @@ -1337,8 +1362,6 @@ export type Mutable = T extends ReadonlyArray ? Mutable[] : T extends object ? ({ -readonly [P in keyof T]: Mutable }) : T; -export type WishKey = `/${string}` | `#${string}`; - // ===== JSON Pointer Path Resolution Utilities ===== /** diff --git a/packages/background-charm-service/src/worker.ts b/packages/background-charm-service/src/worker.ts index d47a7f3d9..1ca0b286f 100644 --- a/packages/background-charm-service/src/worker.ts +++ b/packages/background-charm-service/src/worker.ts @@ -151,8 +151,13 @@ async function runCharm(data: RunData): Promise { // Reset error tracking latestError = null; + // Get the charm cell from the charmId + const charmCell = manager.runtime.getCellFromEntityId(spaceId, { + "/": charmId, + }); + // Check whether the charm is still active (in charms or pinned-charms) - const charmsEntryCell = manager.getActiveCharm({ "/": charmId }); + const charmsEntryCell = manager.getActiveCharm(charmCell); if (charmsEntryCell === undefined) { // Skip any charms that aren't still in one of the lists throw new Error(`No charms list entry found for charm: ${charmId}`); diff --git a/packages/charm/src/favorites.ts b/packages/charm/src/favorites.ts index 24deb1fad..72b89c24c 100644 --- a/packages/charm/src/favorites.ts +++ b/packages/charm/src/favorites.ts @@ -1,24 +1,44 @@ -import { type Cell, getEntityId, type IRuntime } from "@commontools/runner"; -import { charmListSchema, isSameEntity } from "./manager.ts"; +import { type Cell, type IRuntime } from "@commontools/runner"; +import { type FavoriteList, favoriteListSchema } from "./manager.ts"; /** - * Filters an array of charms by removing any that match the target entity + * Get cell description (schema as string) for tag-based search. + * Uses asSchemaFromLinks() to resolve schema through links and pattern resultSchema. + * Returns empty string if no schema available (won't match searches). */ -function filterOutEntity( - list: Cell[]>, +function getCellDescription(cell: Cell): string { + try { + const { schema } = cell.asSchemaFromLinks().getAsNormalizedFullLink(); + if (schema !== undefined) { + return JSON.stringify(schema); + } + } catch (e) { + console.error("Failed to get cell schema for favorite tag:", e); + } + return ""; +} + +/** + * Filters an array of favorite entries by removing any that match the target cell + */ +function filterOutCell( + list: Cell, target: Cell, -): Cell[] { - const targetId = getEntityId(target); - if (!targetId) return list.get() as Cell[]; - return list.get().filter((charm) => !isSameEntity(charm, targetId)); +): FavoriteList { + const resolvedTarget = target.resolveAsCell(); + return list.get().filter((entry) => + !entry.cell.resolveAsCell().equals(resolvedTarget) + ); } /** * Get the favorites cell from the home space (singleton across all spaces). * See docs/common/HOME_SPACE.md for more details. */ -export function getHomeFavorites(runtime: IRuntime): Cell[]> { - return runtime.getHomeSpaceCell().key("favorites").asSchema(charmListSchema); +export function getHomeFavorites(runtime: IRuntime): Cell { + return runtime.getHomeSpaceCell().key("favorites").asSchema( + favoriteListSchema, + ); } /** @@ -31,17 +51,21 @@ export async function addFavorite( const favorites = getHomeFavorites(runtime); await favorites.sync(); - const id = getEntityId(charm); - if (!id) return; + const resolvedCharm = charm.resolveAsCell(); await runtime.editWithRetry((tx) => { const favoritesWithTx = favorites.withTx(tx); const current = favoritesWithTx.get() || []; // Check if already favorited - if (current.some((c) => isSameEntity(c, id))) return; + if ( + current.some((entry) => entry.cell.resolveAsCell().equals(resolvedCharm)) + ) return; + + // Get the schema tag for this cell + const tag = getCellDescription(charm); - favoritesWithTx.push(charm); + favoritesWithTx.push({ cell: charm, tag }); }); await runtime.idle(); @@ -55,16 +79,13 @@ export async function removeFavorite( runtime: IRuntime, charm: Cell, ): Promise { - const id = getEntityId(charm); - if (!id) return false; - const favorites = getHomeFavorites(runtime); await favorites.sync(); let removed = false; const result = await runtime.editWithRetry((tx) => { const favoritesWithTx = favorites.withTx(tx); - const filtered = filterOutEntity(favoritesWithTx, charm); + const filtered = filterOutCell(favoritesWithTx, charm); if (filtered.length !== favoritesWithTx.get().length) { favoritesWithTx.set(filtered); removed = true; @@ -79,13 +100,13 @@ export async function removeFavorite( * Check if a charm is in the user's favorites (in home space) */ export function isFavorite(runtime: IRuntime, charm: Cell): boolean { - const id = getEntityId(charm); - if (!id) return false; - try { + const resolvedCharm = charm.resolveAsCell(); const favorites = getHomeFavorites(runtime); const cached = favorites.get(); - return cached?.some((c: Cell) => isSameEntity(c, id)) ?? false; + return cached?.some((entry) => + entry.cell.resolveAsCell().equals(resolvedCharm) + ) ?? false; } catch (_error) { // If we can't access the home space (e.g., authorization error), // assume the charm is not favorited rather than throwing diff --git a/packages/charm/src/index.ts b/packages/charm/src/index.ts index 2e2bfa3a9..4bd8c165f 100644 --- a/packages/charm/src/index.ts +++ b/packages/charm/src/index.ts @@ -2,6 +2,7 @@ export { charmId, charmListSchema, CharmManager, + favoriteListSchema, getRecipeIdFromCharm, type NameSchema, nameSchema, diff --git a/packages/charm/src/manager.ts b/packages/charm/src/manager.ts index ad7caa6cc..649dc458f 100644 --- a/packages/charm/src/manager.ts +++ b/packages/charm/src/manager.ts @@ -69,6 +69,24 @@ export const charmLineageSchema = { } as const satisfies JSONSchema; export type CharmLineage = Schema; +export const favoriteEntrySchema = { + type: "object", + properties: { + cell: { not: true, asCell: true }, + tag: { type: "string", default: "" }, + }, + required: ["cell"], +} as const satisfies JSONSchema; + +export type FavoriteEntry = Schema; + +export const favoriteListSchema = { + type: "array", + items: favoriteEntrySchema, +} as const satisfies JSONSchema; + +export type FavoriteList = Schema; + export const charmSourceCellSchema = { type: "object", properties: { @@ -94,28 +112,16 @@ export const processSchema = { } as const satisfies JSONSchema; /** - * Helper to consistently compare entity IDs between cells - */ -export function isSameEntity( - a: Cell | string | EntityId, - b: Cell | string | EntityId, -): boolean { - const idA = getEntityId(a); - const idB = getEntityId(b); - return idA && idB ? idA["/"] === idB["/"] : false; -} - -/** - * Filters an array of charms by removing any that match the target entity + * Filters an array of charms by removing any that match the target cell */ -function filterOutEntity( +function filterOutCell( list: Cell[]>, - target: Cell | string | EntityId, + target: Cell, ): Cell[] { - const targetId = getEntityId(target); - if (!targetId) return list.get() as Cell[]; - - return list.get().filter((charm) => !isSameEntity(charm, targetId)); + const resolvedTarget = target.resolveAsCell(); + return list.get().filter((charm) => + !charm.resolveAsCell().equals(resolvedTarget) + ); } export class CharmManager { @@ -223,21 +229,21 @@ export class CharmManager { async pin(charm: Cell) { await this.syncCharms(this.pinnedCharms); // Check if already pinned - if ( - !filterOutEntity(this.pinnedCharms, charm).some((c) => - isSameEntity(c, charm) - ) - ) { + const resolvedCharm = charm.resolveAsCell(); + const alreadyPinned = this.pinnedCharms.get().some((c) => + c.resolveAsCell().equals(resolvedCharm) + ); + if (!alreadyPinned) { this.pinnedCharms.push(charm); await this.runtime.idle(); } } - async unpinById(charmId: EntityId) { + async unpin(charm: Cell) { await this.syncCharms(this.pinnedCharms); const { ok } = await this.runtime.editWithRetry((tx) => { const pinnedCharms = this.pinnedCharms.withTx(tx); - const newPinnedCharms = filterOutEntity(pinnedCharms, charmId); + const newPinnedCharms = filterOutCell(pinnedCharms, charm); if (newPinnedCharms.length !== pinnedCharms.get().length) { this.pinnedCharms.withTx(tx).set(newPinnedCharms); return true; @@ -248,13 +254,6 @@ export class CharmManager { return !!ok; } - async unpin(charm: Cell | string | EntityId) { - const id = getEntityId(charm); - if (!id) return false; - - return await this.unpinById(id); - } - getPinned(): Cell[]> { this.syncCharms(this.pinnedCharms); return this.pinnedCharms; @@ -285,15 +284,16 @@ export class CharmManager { * @param charm - The charm to track */ async trackRecentCharm(charm: Cell): Promise { - const id = getEntityId(charm); - if (!id) return; + const resolvedCharm = charm.resolveAsCell(); await this.runtime.editWithRetry((tx) => { const recentCharmsWithTx = this.recentCharms.withTx(tx); const recentCharms = recentCharmsWithTx.get() || []; // Remove any existing instance of this charm to avoid duplicates - const filtered = recentCharms.filter((c) => !isSameEntity(c, id)); + const filtered = recentCharms.filter((c) => + !c.resolveAsCell().equals(resolvedCharm) + ); // Add charm to the beginning of the list const updated = [charm, ...filtered]; @@ -465,6 +465,7 @@ export class CharmManager { const seenEntityIds = new Set(); // Track entities we've already processed const maxDepth = 10; // Prevent infinite recursion const maxResults = 50; // Prevent too many results from overwhelming the UI + const resolvedCharm = charm.resolveAsCell(); if (!charm) return result; @@ -501,14 +502,15 @@ export class CharmManager { return cId && docId["/"] === cId["/"]; }); - if ( - matchingCharm && !isSameEntity(matchingCharm, charm) && - !result.some((c) => isSameEntity(c, matchingCharm)) - ) { - // Check if we've already found too many references - if (result.length < maxResults) { + if (matchingCharm) { + const resolvedMatching = matchingCharm.resolveAsCell(); + const isNotSelf = !resolvedMatching.equals(resolvedCharm); + const notAlreadyInResult = !result.some((c) => + c.resolveAsCell().equals(resolvedMatching) + ); + + if (isNotSelf && notAlreadyInResult && result.length < maxResults) { result.push(matchingCharm); - // Reference added to result } } }; @@ -662,6 +664,8 @@ export class CharmManager { const charmId = getEntityId(charm); if (!charmId) return result; + const resolvedCharm = charm.resolveAsCell(); + // Helper function to add a matching charm to the result const addReadingCharm = (otherCharm: Cell) => { const otherCharmId = getEntityId(otherCharm); @@ -675,12 +679,13 @@ export class CharmManager { if (seenEntityIds.has(entityIdStr)) return; seenEntityIds.add(entityIdStr); - if (!result.some((c) => isSameEntity(c, otherCharm))) { - // Check if we've already found too many references - if (result.length < maxResults) { - result.push(otherCharm); - // Charm reading from target added to result - } + const resolvedOther = otherCharm.resolveAsCell(); + const notAlreadyInResult = !result.some((c) => + c.resolveAsCell().equals(resolvedOther) + ); + + if (notAlreadyInResult && result.length < maxResults) { + result.push(otherCharm); } }; @@ -799,7 +804,7 @@ export class CharmManager { // Check each charm to see if it references this charm for (const otherCharm of allCharms) { - if (isSameEntity(otherCharm, charm)) continue; // Skip self + if (otherCharm.resolveAsCell().equals(resolvedCharm)) continue; // Skip self if (checkRefersToTarget(otherCharm, otherCharm, new Set(), 0)) { addReadingCharm(otherCharm); @@ -875,22 +880,19 @@ export class CharmManager { } // note: removing a charm doesn't clean up the charm's cells - async remove(idOrCharm: string | EntityId | Cell) { + async remove(charm: Cell) { await Promise.all([ this.syncCharms(this.charms), this.syncCharms(this.pinnedCharms), ]); - const id = getEntityId(idOrCharm); - if (!id) return false; - - await this.unpin(idOrCharm); + await this.unpin(charm); const { ok } = await this.runtime.editWithRetry((tx) => { const charms = this.charms.withTx(tx); // Remove from main list - const newCharms = filterOutEntity(charms, id); + const newCharms = filterOutCell(charms, charm); if (newCharms.length !== charms.get().length) { charms.set(newCharms); return true; @@ -1032,9 +1034,14 @@ export class CharmManager { // Returns the charm from one of our active charm lists if it is present, // or undefined if it is not - getActiveCharm(charmId: Cell | EntityId | string) { - return this.charms.get().find((charm) => isSameEntity(charm, charmId)) ?? - this.pinnedCharms.get().find((charm) => isSameEntity(charm, charmId)); + getActiveCharm(charmCell: Cell) { + const resolved = charmCell.resolveAsCell(); + return this.charms.get().find((charm) => + charm.resolveAsCell().equals(resolved) + ) ?? + this.pinnedCharms.get().find((charm) => + charm.resolveAsCell().equals(resolved) + ); } async link( @@ -1127,9 +1134,9 @@ export class CharmManager { /** * Get the favorites cell from the home space - * @returns Cell containing the array of favorited charms + * @returns Cell containing the array of favorite entries with cell and tag */ - getFavorites(): Cell[]> { + getFavorites(): Cell { return favorites.getHomeFavorites(this.runtime); } } diff --git a/packages/charm/src/ops/charms-controller.ts b/packages/charm/src/ops/charms-controller.ts index 19b9891d3..56eecb7a2 100644 --- a/packages/charm/src/ops/charms-controller.ts +++ b/packages/charm/src/ops/charms-controller.ts @@ -79,7 +79,11 @@ export class CharmsController { async remove(charmId: string): Promise { this.disposeCheck(); - const removed = await this.#manager.remove(charmId); + const charm = this.#manager.runtime.getCellFromEntityId( + this.#manager.getSpace(), + { "/": charmId }, + ); + const removed = await this.#manager.remove(charm); // Ensure full synchronization if (removed) { await this.#manager.runtime.idle(); diff --git a/packages/patterns/favorites-manager.tsx b/packages/patterns/favorites-manager.tsx new file mode 100644 index 000000000..90c800da2 --- /dev/null +++ b/packages/patterns/favorites-manager.tsx @@ -0,0 +1,39 @@ +/// +import { Cell, handler, NAME, pattern, UI, wish } from "commontools"; + +type Favorite = { cell: Cell<{ [NAME]?: string }>; description: string }; + +const onRemoveFavorite = handler< + Record, + { favorites: Cell>; item: Cell } +>((_, { favorites, item }) => { + favorites.set([ + ...favorites.get().filter((f: Favorite) => !f.cell.equals(item)), + ]); +}); + +export default pattern>((_) => { + const wishResult = wish>({ tag: "#favorites" }); + + return { + [NAME]: "Favorites Manager", + [UI]: ( +
+ {wishResult.result.map((item) => ( +
+ + + Remove + +
{item.description}
+
+ ))} +
+ ), + }; +}); diff --git a/packages/patterns/note.tsx b/packages/patterns/note.tsx index 696972e16..a80792494 100644 --- a/packages/patterns/note.tsx +++ b/packages/patterns/note.tsx @@ -21,11 +21,11 @@ type Input = { content?: Cell>; }; +/** Represents a small #note a user took to remember some text. */ type Output = { mentioned: Default, []>; backlinks: MentionableCharm[]; - /** The content of the note */ content: Default; grep: Stream<{ query: string }>; translate: Stream<{ language: string }>; diff --git a/packages/patterns/wish.tsx b/packages/patterns/wish.tsx new file mode 100644 index 000000000..f168381f7 --- /dev/null +++ b/packages/patterns/wish.tsx @@ -0,0 +1,19 @@ +/// +import { NAME, pattern, UI, wish } from "commontools"; + +export default pattern>((_) => { + const wishResult = wish<{ content: string }>({ tag: "#note" }); + + return { + [NAME]: "Wish tester", + [UI]: ( +
+
{wishResult.result.content}
+
+ {wishResult.result} +
+ {wishResult[UI]} +
+ ), + }; +}); diff --git a/packages/runner/src/builder/built-in.ts b/packages/runner/src/builder/built-in.ts index ecc52f619..db6c6a5a3 100644 --- a/packages/runner/src/builder/built-in.ts +++ b/packages/runner/src/builder/built-in.ts @@ -22,7 +22,11 @@ import type { BuiltInLLMState, FetchOptions, PatternToolFunction, + WishParams, + WishState, } from "commontools"; +import { isRecord } from "@commontools/utils/types"; +import { isCell } from "../cell.ts"; export const compileAndRun = createNodeFactory({ type: "ref", @@ -117,17 +121,39 @@ export const navigateTo = createNodeFactory({ implementation: "navigateTo", }) as (cell: OpaqueRef) => OpaqueRef; +export function wish( + target: Opaque, +): OpaqueRef>>; +export function wish( + target: Opaque, + schema: JSONSchema, +): OpaqueRef>>; export function wish( target: Opaque, -): OpaqueRef; +): OpaqueRef; export function wish( target: Opaque, schema: JSONSchema, ): OpaqueRef; export function wish( - target: Opaque, + target: Opaque | Opaque, schema?: JSONSchema, -): OpaqueRef { +): OpaqueRef>> { + let param; + let resultSchema; + + if (schema !== undefined && isRecord(target) && !isCell(target)) { + param = { + schema, + ...target, // Pass in after, so schema here overrides any schema in target + }; + resultSchema = !isCell(param.schema) + ? param.schema as JSONSchema | undefined + : schema; + } else { + param = target; + resultSchema = schema; + } return createNodeFactory({ type: "ref", implementation: "wish", @@ -135,8 +161,8 @@ export function wish( type: "string", default: "", } as const satisfies JSONSchema, - resultSchema: schema, - })(target); + resultSchema, + })(param); } // Example: diff --git a/packages/runner/src/builtins/llm-dialog.ts b/packages/runner/src/builtins/llm-dialog.ts index bf3f4d368..99629958b 100644 --- a/packages/runner/src/builtins/llm-dialog.ts +++ b/packages/runner/src/builtins/llm-dialog.ts @@ -103,25 +103,13 @@ function normalizeInputSchema(schemaLike: unknown): JSONSchema { function getCellSchema( cell: Cell, ): { schema?: JSONSchema; rootSchema?: JSONSchema } | undefined { - if (cell.schema !== undefined) return { schema: cell.schema }; + // Extract schema from cell, including from resultSchema of associated pattern + const { schema, rootSchema } = cell.asSchemaFromLinks() + .getAsNormalizedFullLink(); - const sourceCell = cell.getSourceCell<{ resultRef: Cell }>({ - type: "object", - properties: { resultRef: { asCell: true } }, - }); - const sourceCellSchema = sourceCell?.key("resultRef").get()?.schema; - if (sourceCellSchema !== undefined) { - const cfc = new ContextualFlowControl(); - return { - schema: cfc.schemaAtPath( - sourceCellSchema, - cell.getAsNormalizedFullLink().path, - sourceCellSchema, - ), - rootSchema: sourceCellSchema, - }; - } + if (schema !== undefined) return { schema, rootSchema }; + // Fall back to minimal schema based on current value return { schema: buildMinimalSchemaFromValue(cell) }; } diff --git a/packages/runner/src/builtins/navigate-to.ts b/packages/runner/src/builtins/navigate-to.ts index 17ea2c989..346bb1fee 100644 --- a/packages/runner/src/builtins/navigate-to.ts +++ b/packages/runner/src/builtins/navigate-to.ts @@ -19,7 +19,7 @@ export function navigateTo( // The main reason we might be called again after navigating is that the // transaction to update the result cell failed, so we'll just set it again. if (navigated) { - resultCell.set(true); + resultCell?.withTx(tx).set(true); return; } @@ -40,7 +40,7 @@ export function navigateTo( } // If the result cell is already true, we've already navigated. - if (resultCell.get()) return; + if (resultCell.withTx(tx).get()) return; // Read with a schema that won't subscribe to the whole charm const inputsWithLog = inputsCell.asSchema({ not: true, asCell: true }) diff --git a/packages/runner/src/builtins/wish.ts b/packages/runner/src/builtins/wish.ts index c9dea3c6c..91d077a90 100644 --- a/packages/runner/src/builtins/wish.ts +++ b/packages/runner/src/builtins/wish.ts @@ -1,4 +1,10 @@ -import { type WishKey } from "@commontools/api"; +import { + type VNode, + type WishParams, + type WishState, + type WishTag, +} from "@commontools/api"; +import { h } from "@commontools/html"; import { type Cell } from "../cell.ts"; import { type Action } from "../scheduler.ts"; import type { IRuntime } from "../runtime.ts"; @@ -8,14 +14,44 @@ import type { } from "../storage/interface.ts"; import type { EntityId } from "../create-ref.ts"; import { ALL_CHARMS_ID } from "./well-known.ts"; -import type { JSONSchema } from "../builder/types.ts"; +import { type JSONSchema, UI } from "../builder/types.ts"; + +// Define locally to avoid circular dependency with @commontools/charm +const favoriteEntrySchema = { + type: "object", + properties: { + cell: { not: true, asCell: true }, + tag: { type: "string", default: "" }, + }, + required: ["cell"], +} as const satisfies JSONSchema; + +const favoriteListSchema = { + type: "array", + items: favoriteEntrySchema, +} as const satisfies JSONSchema; + +function errorUI(message: string): VNode { + return h("span", { style: "color: red" }, `⚠️ ${message}`); +} + +function cellLinkUI(cell: Cell): VNode { + return h("ct-cell-link", { $cell: cell }); +} + +class WishError extends Error { + constructor(message: string) { + super(message); + this.name = "WishError"; + } +} type WishResolution = { entityId: EntityId; path?: readonly string[]; }; -const WISH_TARGETS: Partial> = { +const WISH_TARGETS: Partial> = { "#allCharms": { entityId: { "/": ALL_CHARMS_ID } }, }; @@ -24,31 +60,39 @@ function resolveWishTarget( runtime: IRuntime, space: MemorySpace, tx: IExtendedStorageTransaction, -): Cell | undefined { - return runtime.getCellFromEntityId( +): Cell { + const cell = runtime.getCellFromEntityId( space, resolution.entityId, resolution.path, undefined, tx, ); + if (!cell) { + throw new WishError("Failed to resolve wish target"); + } + return cell; } type ParsedWishTarget = { - key: "/" | WishKey; + key: "/" | WishTag; path: string[]; }; -function parseWishTarget(target: string): ParsedWishTarget | undefined { +function parseWishTarget(target: string): ParsedWishTarget { const trimmed = target.trim(); - if (trimmed === "") return undefined; + if (trimmed === "") { + throw new WishError(`Wish target "${target}" is empty.`); + } if (trimmed.startsWith("#")) { const segments = trimmed.slice(1).split("/").filter((segment) => segment.length > 0 ); - if (segments.length === 0) return undefined; - const key = `#${segments[0]}` as WishKey; + if (segments.length === 0) { + throw new WishError(`Wish target "${target}" is not recognized.`); + } + const key = `#${segments[0]}` as WishTag; return { key, path: segments.slice(1) }; } @@ -57,7 +101,7 @@ function parseWishTarget(target: string): ParsedWishTarget | undefined { return { key: "/", path: segments }; } - return undefined; + throw new WishError(`Wish target "${target}" is not recognized.`); } type WishContext = { @@ -95,32 +139,32 @@ function resolvePath( return current.resolveAsCell(); } +function formatTarget(parsed: ParsedWishTarget): string { + return parsed.key + + (parsed.path.length > 0 ? "/" + parsed.path.join("/") : ""); +} + function resolveBase( parsed: ParsedWishTarget, ctx: WishContext, - wishTarget: string, -): BaseResolution | undefined { +): BaseResolution { switch (parsed.key) { case "/": return { cell: getSpaceCell(ctx) }; - case "#default": { + case "#default": return { cell: getSpaceCell(ctx), pathPrefix: ["defaultPattern"] }; - } - case "#mentionable": { + case "#mentionable": return { cell: getSpaceCell(ctx), pathPrefix: ["defaultPattern", "backlinksIndex", "mentionable"], }; - } - case "#recent": { + case "#recent": return { cell: getSpaceCell(ctx), pathPrefix: ["recentCharms"] }; - } case "#favorites": { // Favorites always come from the HOME space (user identity DID) const userDID = ctx.runtime.userIdentityDID; if (!userDID) { - console.error("User identity DID not available for #favorites"); - return undefined; + throw new WishError("User identity DID not available for #favorites"); } const homeSpaceCell = ctx.runtime.getCell( userDID, @@ -128,12 +172,38 @@ function resolveBase( undefined, ctx.tx, ); - return { cell: homeSpaceCell, pathPrefix: ["favorites"] }; + + // No path = return favorites list + if (parsed.path.length === 0) { + return { cell: homeSpaceCell, pathPrefix: ["favorites"] }; + } + + // Path provided = search by tag + const searchTerm = parsed.path[0].toLowerCase(); + const favoritesCell = homeSpaceCell.key("favorites").asSchema( + favoriteListSchema, + ); + const favorites = favoritesCell.get() || []; + + // Case-insensitive search in tag + const match = favorites.find((entry) => + entry.tag?.toLowerCase().includes(searchTerm) + ); + + if (!match) { + throw new WishError(`No favorite found matching "${searchTerm}"`); + } + + return { + cell: match.cell, + pathPrefix: parsed.path.slice(1), // remaining path after search term + }; } case "#now": { if (parsed.path.length > 0) { - console.error(`Wish target "${wishTarget}" is not recognized.`); - return undefined; + throw new WishError( + `Wish target "${formatTarget(parsed)}" is not recognized.`, + ); } const nowCell = ctx.runtime.getImmutableCell( ctx.parentCell.space, @@ -144,32 +214,63 @@ function resolveBase( return { cell: nowCell }; } default: { + // Check if it's a well-known target const resolution = WISH_TARGETS[parsed.key]; - if (!resolution) { - console.error(`Wish target "${wishTarget}" is not recognized.`); - return undefined; + if (resolution) { + const baseCell = resolveWishTarget( + resolution, + ctx.runtime, + ctx.parentCell.space, + ctx.tx, + ); + return { cell: baseCell }; } - const baseCell = resolveWishTarget( - resolution, - ctx.runtime, - ctx.parentCell.space, - ctx.tx, + // 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() || []; - if (!baseCell) { - console.error(`Wish target "${wishTarget}" is not recognized.`); - return undefined; + // 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) + ); + + if (!match) { + throw new WishError(`No favorite found matching "${searchTerm}"`); } - return { cell: baseCell }; + return { + cell: match.cell, + pathPrefix: parsed.path, + }; } } } const TARGET_SCHEMA = { - type: "string", - default: "", + anyOf: [{ + type: "string", + default: "", + }, { + type: "object", + properties: { + tag: { type: "string" }, + path: { type: "array", items: { type: "string" } }, + context: { type: "object", additionalProperties: { asCell: true } }, + scope: { type: "array", items: { type: "string" } }, + }, + }], } as const satisfies JSONSchema; export function wish( @@ -184,32 +285,75 @@ export function wish( const inputsWithTx = inputsCell.withTx(tx); const targetValue = inputsWithTx.asSchema(TARGET_SCHEMA).get(); - const wishTarget = targetValue?.trim(); - - if (!wishTarget) { - sendResult(tx, undefined); + // TODO(seefeld): Remove legacy wish string support mid December 2025 + if (typeof targetValue === "string") { + const wishTarget = targetValue.trim(); + if (!wishTarget) { + sendResult(tx, undefined); + return; + } + try { + const parsed = parseWishTarget(wishTarget); + const ctx: WishContext = { runtime, tx, parentCell }; + const baseResolution = resolveBase(parsed, ctx); + const combinedPath = baseResolution.pathPrefix + ? [...baseResolution.pathPrefix, ...parsed.path] + : parsed.path; + const resolvedCell = resolvePath(baseResolution.cell, combinedPath); + sendResult(tx, resolvedCell); + } catch (e) { + console.error(e instanceof WishError ? e.message : e); + sendResult(tx, undefined); + } return; - } + } else if (typeof targetValue === "object") { + const { tag, path, schema, context: _context, scope: _scope } = + targetValue as WishParams; - const parsed = parseWishTarget(wishTarget); - if (!parsed) { - console.error(`Wish target "${wishTarget}" is not recognized.`); - sendResult(tx, undefined); - return; - } + if (!tag) { + const errorMsg = `Wish target "${ + JSON.stringify(targetValue) + }" is not recognized.`; + console.error(errorMsg); + sendResult( + tx, + { error: errorMsg, [UI]: errorUI(errorMsg) } satisfies WishState, + ); + return; + } - const ctx: WishContext = { runtime, tx, parentCell }; - const baseResolution = resolveBase(parsed, ctx, wishTarget); - if (!baseResolution) { - sendResult(tx, undefined); + try { + const parsed: ParsedWishTarget = { key: tag, 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 !== undefined + ? resolvedCell.asSchema(schema) + : resolvedCell; + sendResult(tx, { + result: resultCell, + [UI]: cellLinkUI(resultCell), + }); + } catch (e) { + const errorMsg = e instanceof WishError ? e.message : String(e); + console.error(errorMsg); + sendResult( + tx, + { error: errorMsg, [UI]: errorUI(errorMsg) } satisfies WishState, + ); + } + return; + } else { + const errorMsg = `Wish target is not recognized: ${targetValue}`; + console.error(errorMsg); + sendResult( + tx, + { error: errorMsg, [UI]: errorUI(errorMsg) } satisfies WishState, + ); return; } - - const combinedPath = baseResolution.pathPrefix - ? [...baseResolution.pathPrefix, ...parsed.path] - : parsed.path; - const resolvedCell = resolvePath(baseResolution.cell, combinedPath); - - sendResult(tx, resolvedCell); }; } diff --git a/packages/runner/src/cell.ts b/packages/runner/src/cell.ts index 02b1c8818..51f973c8c 100644 --- a/packages/runner/src/cell.ts +++ b/packages/runner/src/cell.ts @@ -116,6 +116,7 @@ declare module "@commontools/api" { schema?: JSONSchema, rootSchema?: JSONSchema, ): Cell; + asSchemaFromLinks(): Cell; withTx(tx?: IExtendedStorageTransaction): Cell; sink(callback: (value: Readonly) => Cancel | undefined | void): Cancel; sync(): Promise> | Cell; @@ -754,6 +755,56 @@ export class CellImpl implements ICell, IStreamable { ) as unknown as Cell; } + /** + * Follow all links, even beyond write redirects to get final schema. + * + * If there is none look for resultSchema of associated pattern. + * + * Otherwise the link stays the same, i.e. it does not advance to resolved + * link. + * + * Note: That means that the schema might change if the link behind it change. + * The reads are logged though, so should trigger reactive flows. + * + * @returns Cell with schema from links + */ + asSchemaFromLinks(): Cell { + let { schema, rootSchema } = resolveLink( + this.runtime.readTx(this.tx), + this.link, + ); + + if (!schema) { + const sourceCell = this.getSourceCell<{ resultRef: Cell }>({ + type: "object", + properties: { resultRef: { asCell: true } }, + }); + const sourceCellSchema = sourceCell?.key("resultRef").get()?.schema; + if (sourceCellSchema !== undefined) { + const cfc = new ContextualFlowControl(); + schema = cfc.schemaAtPath( + sourceCellSchema, + this._link.path, + sourceCellSchema, + ); + rootSchema = sourceCellSchema; + } + } + + return new CellImpl( + this.runtime, + this.tx, + { + ...this._link, + ...(schema !== undefined && { schema }), + ...(rootSchema !== undefined && { rootSchema }), + }, + false, // Reset synced flag, since schema is changing + this._causeContainer, // Share the causeContainer with siblings + this._kind, + ) as unknown as Cell; + } + withTx(newTx?: IExtendedStorageTransaction): Cell { // withTx creates a sibling with same identity but different transaction // Share the causeContainer so .for() calls propagate diff --git a/packages/runner/src/runtime.ts b/packages/runner/src/runtime.ts index ebb8b7e38..8f3d738a5 100644 --- a/packages/runner/src/runtime.ts +++ b/packages/runner/src/runtime.ts @@ -96,7 +96,7 @@ export interface SpaceCellContents { * See docs/common/HOME_SPACE.md for more details. */ export interface HomeSpaceCellContents { - favorites: Cell[]>; + favorites: Cell<{ cell: Cell; tag: string }[]>; } export interface RuntimeOptions { @@ -130,7 +130,14 @@ export const homeSpaceCellSchema: JSONSchema = { properties: { favorites: { type: "array", - items: { not: true, asCell: true }, + items: { + type: "object", + properties: { + cell: { not: true, asCell: true }, + tag: { type: "string", default: "" }, + }, + required: ["cell"], + }, asCell: true, }, }, diff --git a/packages/runner/test/cell.test.ts b/packages/runner/test/cell.test.ts index 3ce3f786c..9cd34d050 100644 --- a/packages/runner/test/cell.test.ts +++ b/packages/runner/test/cell.test.ts @@ -3916,4 +3916,68 @@ describe("Cell success callbacks", () => { expect(cell.equalLinks(cell)).toBe(true); }); }); + + describe("asSchemaFromLinks", () => { + let runtime: Runtime; + let storageManager: ReturnType; + let tx: IExtendedStorageTransaction; + + beforeEach(() => { + storageManager = StorageManager.emulate({ as: signer }); + runtime = new Runtime({ + apiUrl: new URL(import.meta.url), + storageManager, + }); + tx = runtime.edit(); + }); + + afterEach(async () => { + await tx.commit(); + await runtime?.dispose(); + await storageManager?.close(); + }); + + it("should return schema if present on the cell", () => { + const schema: JSONSchema = { type: "string" }; + const c = runtime.getCell(space, "cell-with-schema", schema, tx); + const schemaCell = c.asSchemaFromLinks(); + expect(schemaCell.schema).toEqual(schema); + }); + + it("should return schema from pattern resultRef if not present on cell", () => { + // 1. Create the target cell (no schema initially) + const targetCell = runtime.getCell(space, "target-cell", undefined, tx); + + // 2. Create the pattern cell + const patternCell = runtime.getCell(space, "pattern-cell", undefined, tx); + + // 3. Set patternCell as the source of targetCell + targetCell.setSourceCell(patternCell); + + // 4. Create a link to targetCell that includes the desired schema + const schemaWeWant: JSONSchema = { + type: "object", + properties: { + output: { type: "number" }, + }, + }; + const linkWithSchema = targetCell + .asSchema(schemaWeWant) + .getAsLink({ includeSchema: true }); + + // 5. Set patternCell's resultRef to point to targetCell using the link with schema + patternCell.set({ resultRef: linkWithSchema }); + + // 6. Verify asSchemaFromLinks picks up the schema from the resultRef link + const schemaCell = targetCell.asSchemaFromLinks(); + + expect(schemaCell.schema).toEqual(schemaWeWant); + }); + + it("should return undefined schema if neither present nor in pattern", () => { + const c = runtime.getCell(space, "no-schema", undefined, tx); + const schemaCell = c.asSchemaFromLinks(); + expect(schemaCell.schema).toBeUndefined(); + }); + }); }); diff --git a/packages/runner/test/wish.test.ts b/packages/runner/test/wish.test.ts index a8e97fd1e..9f5b9213b 100644 --- a/packages/runner/test/wish.test.ts +++ b/packages/runner/test/wish.test.ts @@ -6,6 +6,7 @@ import { LINK_V1_TAG } from "../src/sigil-types.ts"; import { createBuilder } from "../src/builder/factory.ts"; import { Runtime } from "../src/runtime.ts"; import { ALL_CHARMS_ID } from "../src/builtins/well-known.ts"; +import { UI } from "../src/builder/types.ts"; const signer = await Identity.fromPassphrase("wish built-in tests"); const space = signer.did(); @@ -402,4 +403,361 @@ describe("wish built-in", () => { console.error = originalError; } }); + + describe("object-based wish syntax", () => { + it("resolves allCharms using tag parameter", async () => { + const allCharmsCell = runtime.getCellFromEntityId( + space, + { "/": ALL_CHARMS_ID }, + [], + undefined, + tx, + ); + const charmsData = [{ name: "Alpha", title: "Alpha" }]; + allCharmsCell.withTx(tx).set(charmsData); + + const spaceCell = runtime.getCell<{ allCharms?: unknown[] }>(space, space) + .withTx(tx); + spaceCell.key("allCharms").set(allCharmsCell.withTx(tx)); + + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); + + const wishRecipe = recipe("wish object syntax allCharms", () => { + const allCharms = wish({ tag: "#allCharms" }); + return { allCharms }; + }); + + const resultCell = runtime.getCell<{ + allCharms?: { result?: unknown[] }; + }>( + space, + "wish object syntax result", + undefined, + tx, + ); + const result = runtime.run(tx, wishRecipe, {}, resultCell); + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); + + await runtime.idle(); + + expect(result.key("allCharms").get()?.result).toEqual(charmsData); + }); + + it("resolves nested paths using tag and path parameters", async () => { + const allCharmsCell = runtime.getCellFromEntityId( + space, + { "/": ALL_CHARMS_ID }, + [], + undefined, + tx, + ); + const charmsData = [ + { name: "Alpha", title: "First Title" }, + { name: "Beta", title: "Second Title" }, + ]; + allCharmsCell.withTx(tx).set(charmsData); + + const spaceCell = runtime.getCell<{ allCharms?: unknown[] }>(space, space) + .withTx(tx); + spaceCell.key("allCharms").set(allCharmsCell.withTx(tx)); + + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); + + const wishRecipe = recipe("wish object syntax with path", () => { + const firstTitle = wish({ + tag: "#allCharms", + path: ["0", "title"], + }); + return { firstTitle }; + }); + + const resultCell = runtime.getCell<{ + firstTitle?: { result?: string }; + }>( + space, + "wish object syntax path result", + undefined, + tx, + ); + const result = runtime.run(tx, wishRecipe, {}, resultCell); + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); + + await runtime.idle(); + + expect(result.key("firstTitle").get()?.result).toEqual("First Title"); + }); + + it("resolves space cell using / tag", async () => { + const spaceCell = runtime.getCell(space, space).withTx(tx); + const spaceData = { testField: "space cell value" }; + spaceCell.set(spaceData); + + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); + + const wishRecipe = recipe("wish object syntax space", () => { + const spaceResult = wish({ tag: "/" }); + return { spaceResult }; + }); + + const resultCell = runtime.getCell<{ + spaceResult?: { result?: unknown }; + }>( + space, + "wish object syntax space result", + undefined, + tx, + ); + const result = runtime.run(tx, wishRecipe, {}, resultCell); + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); + + await runtime.idle(); + + expect(result.key("spaceResult").get()?.result).toEqual(spaceData); + }); + + it("resolves space cell subpaths using / tag with path", async () => { + const spaceCell = runtime.getCell(space, space).withTx(tx); + spaceCell.set({ + config: { setting: "value" }, + nested: { deep: { data: ["Alpha"] } }, + }); + + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); + + const wishRecipe = recipe("wish object syntax space subpaths", () => { + return { + configLink: wish({ tag: "/", path: ["config"] }), + dataLink: wish({ tag: "/", path: ["nested", "deep", "data"] }), + }; + }); + + const resultCell = runtime.getCell<{ + configLink?: { result?: unknown }; + dataLink?: { result?: unknown }; + }>( + space, + "wish object syntax space subpaths result", + undefined, + tx, + ); + const result = runtime.run(tx, wishRecipe, {}, resultCell); + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); + + await runtime.idle(); + + expect(result.key("configLink").get()?.result).toEqual({ + setting: "value", + }); + expect(result.key("dataLink").get()?.result).toEqual(["Alpha"]); + }); + + it("returns current timestamp via #now tag", async () => { + const wishRecipe = recipe("wish object syntax now", () => { + return { nowValue: wish({ tag: "#now" }) }; + }); + + const resultCell = runtime.getCell<{ + nowValue?: { result?: number }; + }>( + space, + "wish object syntax now result", + undefined, + tx, + ); + const before = Date.now(); + const result = runtime.run(tx, wishRecipe, {}, resultCell); + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); + + await runtime.idle(); + + const after = Date.now(); + const nowValue = result.key("nowValue").get()?.result; + expect(typeof nowValue).toBe("number"); + expect(nowValue).toBeGreaterThanOrEqual(before); + expect(nowValue).toBeLessThanOrEqual(after); + }); + + 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({ tag: "#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(); + + 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; + } + }); + + 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({ 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(); + + await runtime.idle(); + + const missingResult = result.key("missing").get(); + expect(missingResult?.error).toMatch(/not recognized/); + expect(errors.length).toBeGreaterThan(0); + } finally { + console.error = originalError; + } + }); + + it("returns UI with ct-cell-link on success", async () => { + const spaceCell = runtime.getCell(space, space).withTx(tx); + const spaceData = { testField: "space cell value" }; + spaceCell.set(spaceData); + + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); + + const wishRecipe = recipe("wish object syntax UI success", () => { + const spaceResult = wish({ tag: "/" }); + return { spaceResult }; + }); + + const resultCell = runtime.getCell<{ + spaceResult?: { result?: unknown }; + }>( + space, + "wish object syntax UI success result", + undefined, + tx, + ); + const result = runtime.run(tx, wishRecipe, {}, resultCell); + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); + + await runtime.idle(); + + const wishResult = result.key("spaceResult").get() as Record< + string | symbol, + unknown + >; + expect(wishResult?.result).toEqual(spaceData); + + const ui = wishResult?.[UI] as { type: string; name: string; props: any }; + expect(ui?.type).toEqual("vnode"); + expect(ui?.name).toEqual("ct-cell-link"); + expect(ui?.props?.$cell).toBeDefined(); + }); + + it("returns UI with error message on failure", async () => { + const errors: unknown[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { + errors.push(args); + }; + + try { + const wishRecipe = recipe("wish object syntax UI error", () => { + const missing = wish({ tag: "#unknownTag" }); + return { missing }; + }); + + const resultCell = runtime.getCell<{ + missing?: { error?: string }; + }>( + space, + "wish object syntax UI error result", + undefined, + tx, + ); + const result = runtime.run(tx, wishRecipe, {}, resultCell); + await tx.commit(); + await runtime.idle(); + tx = runtime.edit(); + + await runtime.idle(); + + const wishResult = result.key("missing").get() as Record< + string | symbol, + unknown + >; + // Unknown tags now search favorites, returning "No favorite found" error + expect(wishResult?.error).toMatch(/No favorite found matching/); + + const ui = wishResult?.[UI] as { + type: string; + name: string; + props: any; + children: string; + }; + expect(ui?.type).toEqual("vnode"); + expect(ui?.name).toEqual("span"); + expect(ui?.props?.style).toEqual("color: red"); + expect(ui?.children).toMatch(/⚠️/); + expect(ui?.children).toMatch(/No favorite found matching/); + } finally { + console.error = originalError; + } + }); + }); });