Skip to content

Commit d3d708b

Browse files
authored
Allow wish to read favorites for resolution (tags) (commontoolsinc#2166)
* Research + plan change * Rework favorites data structure for `wish` usage * Update issues * Rebase using cell.cell.asSchemaFromLinks * Add `Required` to `WishState` * Format * Wish MVP demo * Use `tx` during `navigateTo` (fixed StorageTransactionCompleteError) * Format plan * Add `favorites-manager.tsx` * Format and fix * Remove `isSameEntity` * Format pass * Remove plans * Fix circular import * Add documentation
1 parent 24a42ba commit d3d708b

File tree

15 files changed

+328
-110
lines changed

15 files changed

+328
-110
lines changed

docs/common/FAVORITES.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
Charms can be favorites and added to your [[HOME_SPACE]]. These charms can be accessed from _any_ space, via this list.
2+
3+
# Accessing the Favorites list
4+
5+
You can `wish` for the favorites list itself (see `favorites-manager.tsx` for a full example):
6+
7+
```tsx
8+
type Favorite = { cell: Cell<{ [NAME]?: string }>; description: string };
9+
const wishResult = wish<Array<Favorite>>({ tag: "#favorites" });
10+
```
11+
12+
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.
13+
14+
# Wishing for A Specific Charm
15+
16+
See `wish.tsx` for a full example.
17+
18+
In `note.tsx` I decorate my schema with a description containing "#note":
19+
```tsx
20+
/** Represents a small #note a user took to remember some text. */
21+
type Output = {
22+
mentioned: Default<Array<MentionableCharm>, []>;
23+
backlinks: MentionableCharm[];
24+
25+
content: Default<string, "">;
26+
grep: Stream<{ query: string }>;
27+
translate: Stream<{ language: string }>;
28+
editContent: Stream<{ detail: { value: string } }>;
29+
};
30+
```
31+
32+
Later, I wish for "#note" and discover the first matching item in the list.
33+
34+
```tsx
35+
const wishResult = wish<{ content: string }>({ tag: "#note" });
36+
```
37+
38+
# Intended Usage
39+
40+
Keep a handle to important information in a charm, e.g. google auth, user preferences/biography, cross-cutting data (calendar).
41+
42+
# Future Plans
43+
44+
This is the minimum viable design. We will later:
45+
46+
- find tags on specific sub-schemas and properly discover the paths to the subtrees
47+
- result a 'result picker' UI from in the `wishResult` to choose between many options and/or override
48+
- support filtering `wish` to certain scopes

packages/api/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,11 +1272,11 @@ export type WishState<T> = {
12721272

12731273
export type NavigateToFunction = (cell: OpaqueRef<any>) => OpaqueRef<boolean>;
12741274
export type WishFunction = {
1275-
<T = unknown>(target: Opaque<WishParams>): OpaqueRef<WishState<T>>;
1275+
<T = unknown>(target: Opaque<WishParams>): OpaqueRef<Required<WishState<T>>>;
12761276
<S extends JSONSchema = JSONSchema>(
12771277
target: Opaque<WishParams>,
12781278
schema: S,
1279-
): OpaqueRef<WishState<Schema<S>>>;
1279+
): OpaqueRef<Required<WishState<Schema<S>>>>;
12801280

12811281
// TODO(seefeld): Remove old interface mid December 2025
12821282
<T = unknown>(target: Opaque<string>): OpaqueRef<T>;

packages/background-charm-service/src/worker.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,13 @@ async function runCharm(data: RunData): Promise<void> {
151151
// Reset error tracking
152152
latestError = null;
153153

154+
// Get the charm cell from the charmId
155+
const charmCell = manager.runtime.getCellFromEntityId(spaceId, {
156+
"/": charmId,
157+
});
158+
154159
// Check whether the charm is still active (in charms or pinned-charms)
155-
const charmsEntryCell = manager.getActiveCharm({ "/": charmId });
160+
const charmsEntryCell = manager.getActiveCharm(charmCell);
156161
if (charmsEntryCell === undefined) {
157162
// Skip any charms that aren't still in one of the lists
158163
throw new Error(`No charms list entry found for charm: ${charmId}`);

packages/charm/src/favorites.ts

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,44 @@
1-
import { type Cell, getEntityId, type IRuntime } from "@commontools/runner";
2-
import { charmListSchema, isSameEntity } from "./manager.ts";
1+
import { type Cell, type IRuntime } from "@commontools/runner";
2+
import { type FavoriteList, favoriteListSchema } from "./manager.ts";
33

44
/**
5-
* Filters an array of charms by removing any that match the target entity
5+
* Get cell description (schema as string) for tag-based search.
6+
* Uses asSchemaFromLinks() to resolve schema through links and pattern resultSchema.
7+
* Returns empty string if no schema available (won't match searches).
68
*/
7-
function filterOutEntity(
8-
list: Cell<Cell<unknown>[]>,
9+
function getCellDescription(cell: Cell<unknown>): string {
10+
try {
11+
const { schema } = cell.asSchemaFromLinks().getAsNormalizedFullLink();
12+
if (schema !== undefined) {
13+
return JSON.stringify(schema);
14+
}
15+
} catch (e) {
16+
console.error("Failed to get cell schema for favorite tag:", e);
17+
}
18+
return "";
19+
}
20+
21+
/**
22+
* Filters an array of favorite entries by removing any that match the target cell
23+
*/
24+
function filterOutCell(
25+
list: Cell<FavoriteList>,
926
target: Cell<unknown>,
10-
): Cell<unknown>[] {
11-
const targetId = getEntityId(target);
12-
if (!targetId) return list.get() as Cell<unknown>[];
13-
return list.get().filter((charm) => !isSameEntity(charm, targetId));
27+
): FavoriteList {
28+
const resolvedTarget = target.resolveAsCell();
29+
return list.get().filter((entry) =>
30+
!entry.cell.resolveAsCell().equals(resolvedTarget)
31+
);
1432
}
1533

1634
/**
1735
* Get the favorites cell from the home space (singleton across all spaces).
1836
* See docs/common/HOME_SPACE.md for more details.
1937
*/
20-
export function getHomeFavorites(runtime: IRuntime): Cell<Cell<unknown>[]> {
21-
return runtime.getHomeSpaceCell().key("favorites").asSchema(charmListSchema);
38+
export function getHomeFavorites(runtime: IRuntime): Cell<FavoriteList> {
39+
return runtime.getHomeSpaceCell().key("favorites").asSchema(
40+
favoriteListSchema,
41+
);
2242
}
2343

2444
/**
@@ -31,17 +51,21 @@ export async function addFavorite(
3151
const favorites = getHomeFavorites(runtime);
3252
await favorites.sync();
3353

34-
const id = getEntityId(charm);
35-
if (!id) return;
54+
const resolvedCharm = charm.resolveAsCell();
3655

3756
await runtime.editWithRetry((tx) => {
3857
const favoritesWithTx = favorites.withTx(tx);
3958
const current = favoritesWithTx.get() || [];
4059

4160
// Check if already favorited
42-
if (current.some((c) => isSameEntity(c, id))) return;
61+
if (
62+
current.some((entry) => entry.cell.resolveAsCell().equals(resolvedCharm))
63+
) return;
64+
65+
// Get the schema tag for this cell
66+
const tag = getCellDescription(charm);
4367

44-
favoritesWithTx.push(charm);
68+
favoritesWithTx.push({ cell: charm, tag });
4569
});
4670

4771
await runtime.idle();
@@ -55,16 +79,13 @@ export async function removeFavorite(
5579
runtime: IRuntime,
5680
charm: Cell<unknown>,
5781
): Promise<boolean> {
58-
const id = getEntityId(charm);
59-
if (!id) return false;
60-
6182
const favorites = getHomeFavorites(runtime);
6283
await favorites.sync();
6384

6485
let removed = false;
6586
const result = await runtime.editWithRetry((tx) => {
6687
const favoritesWithTx = favorites.withTx(tx);
67-
const filtered = filterOutEntity(favoritesWithTx, charm);
88+
const filtered = filterOutCell(favoritesWithTx, charm);
6889
if (filtered.length !== favoritesWithTx.get().length) {
6990
favoritesWithTx.set(filtered);
7091
removed = true;
@@ -79,13 +100,13 @@ export async function removeFavorite(
79100
* Check if a charm is in the user's favorites (in home space)
80101
*/
81102
export function isFavorite(runtime: IRuntime, charm: Cell<unknown>): boolean {
82-
const id = getEntityId(charm);
83-
if (!id) return false;
84-
85103
try {
104+
const resolvedCharm = charm.resolveAsCell();
86105
const favorites = getHomeFavorites(runtime);
87106
const cached = favorites.get();
88-
return cached?.some((c: Cell<unknown>) => isSameEntity(c, id)) ?? false;
107+
return cached?.some((entry) =>
108+
entry.cell.resolveAsCell().equals(resolvedCharm)
109+
) ?? false;
89110
} catch (_error) {
90111
// If we can't access the home space (e.g., authorization error),
91112
// assume the charm is not favorited rather than throwing

packages/charm/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export {
22
charmId,
33
charmListSchema,
44
CharmManager,
5+
favoriteListSchema,
56
getRecipeIdFromCharm,
67
type NameSchema,
78
nameSchema,

0 commit comments

Comments
 (0)