Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/common/FAVORITES.md
Original file line number Diff line number Diff line change
@@ -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<Array<Favorite>>({ 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<Array<MentionableCharm>, []>;
backlinks: MentionableCharm[];

content: Default<string, "">;
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
27 changes: 25 additions & 2 deletions packages/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1252,8 +1252,33 @@ export type CompileAndRunFunction = <T = any, S = any>(
params: Opaque<BuiltInCompileAndRunParams<T>>,
) => OpaqueRef<BuiltInCompileAndRunState<S>>;

export type WishTag = `/${string}` | `#${string}`;

export type DID = `did:${string}:${string}`;

export type WishParams = {
tag?: WishTag;
path?: string[];
context?: Record<string, any>;
schema?: JSONSchema;
scope?: (DID | "~" | ".")[];
};

export type WishState<T> = {
result?: T;
error?: any;
[UI]?: VNode;
};

export type NavigateToFunction = (cell: OpaqueRef<any>) => OpaqueRef<boolean>;
export type WishFunction = {
<T = unknown>(target: Opaque<WishParams>): OpaqueRef<Required<WishState<T>>>;
<S extends JSONSchema = JSONSchema>(
target: Opaque<WishParams>,
schema: S,
): OpaqueRef<Required<WishState<Schema<S>>>>;

// TODO(seefeld): Remove old interface mid December 2025
<T = unknown>(target: Opaque<string>): OpaqueRef<T>;
<S extends JSONSchema = JSONSchema>(
target: Opaque<string>,
Expand Down Expand Up @@ -1337,8 +1362,6 @@ export type Mutable<T> = T extends ReadonlyArray<infer U> ? Mutable<U>[]
: T extends object ? ({ -readonly [P in keyof T]: Mutable<T[P]> })
: T;

export type WishKey = `/${string}` | `#${string}`;

// ===== JSON Pointer Path Resolution Utilities =====

/**
Expand Down
7 changes: 6 additions & 1 deletion packages/background-charm-service/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,13 @@ async function runCharm(data: RunData): Promise<void> {
// 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}`);
Expand Down
67 changes: 44 additions & 23 deletions packages/charm/src/favorites.ts
Original file line number Diff line number Diff line change
@@ -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<Cell<unknown>[]>,
function getCellDescription(cell: Cell<unknown>): 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<FavoriteList>,
target: Cell<unknown>,
): Cell<unknown>[] {
const targetId = getEntityId(target);
if (!targetId) return list.get() as Cell<unknown>[];
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<Cell<unknown>[]> {
return runtime.getHomeSpaceCell().key("favorites").asSchema(charmListSchema);
export function getHomeFavorites(runtime: IRuntime): Cell<FavoriteList> {
return runtime.getHomeSpaceCell().key("favorites").asSchema(
favoriteListSchema,
);
}

/**
Expand All @@ -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();
Expand All @@ -55,16 +79,13 @@ export async function removeFavorite(
runtime: IRuntime,
charm: Cell<unknown>,
): Promise<boolean> {
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;
Expand All @@ -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<unknown>): 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<unknown>) => 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
Expand Down
1 change: 1 addition & 0 deletions packages/charm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
charmId,
charmListSchema,
CharmManager,
favoriteListSchema,
getRecipeIdFromCharm,
type NameSchema,
nameSchema,
Expand Down
Loading
Loading