live-cache 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ import Collection from "./Collection";
2
+ import { ModelType } from "./Document";
3
+ import { DefaultStorageManager, StorageManager } from "./StorageManager";
4
+ /**
5
+ * Controller is the recommended integration layer for server-backed resources.
6
+ *
7
+ * It wraps a `Collection` with:
8
+ * - hydration (`initialise()`)
9
+ * - persistence (`commit()` writes a full snapshot using the configured `StorageManager`)
10
+ * - subscriptions (`publish()`)
11
+ * - invalidation hooks (`invalidate()`, `refetch()`)
12
+ *
13
+ * The intended mutation pattern is:
14
+ * 1) mutate `this.collection` (insert/update/delete)
15
+ * 2) call `await this.commit()` so subscribers update and storage persists
16
+ *
17
+ * @typeParam TVariable - the “data” shape stored in the collection (without `_id`)
18
+ * @typeParam TName - a stable, string-literal name for this controller/collection
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * type User = { id: number; name: string };
23
+ *
24
+ * class UsersController extends Controller<User, "users"> {
25
+ * async fetchAll(): Promise<[User[], number]> {
26
+ * const res = await fetch("/api/users");
27
+ * const data = (await res.json()) as User[];
28
+ * return [data, data.length];
29
+ * }
30
+ *
31
+ * invalidate(): () => void {
32
+ * this.abort();
33
+ * void this.refetch();
34
+ * return () => {};
35
+ * }
36
+ *
37
+ * async renameUser(id: number, name: string) {
38
+ * this.collection.findOneAndUpdate({ id }, { name });
39
+ * await this.commit();
40
+ * }
41
+ * }
42
+ * ```
43
+ */
44
+ export default class Controller<TVariable, TName extends string> {
45
+ name: TName;
46
+ collection: Collection<TVariable, TName>;
47
+ protected subscribers: Set<(model: ModelType<TVariable>[]) => void>;
48
+ protected storageManager: StorageManager<TVariable>;
49
+ loading: boolean;
50
+ error: unknown;
51
+ total: number;
52
+ pageSize: number;
53
+ abortController: AbortController | null;
54
+ /**
55
+ * Abort any in-flight work owned by this controller (typically network fetches).
56
+ *
57
+ * This method also installs a new `AbortController` so subclasses can safely
58
+ * pass `this.abortController.signal` to the next request.
59
+ */
60
+ abort(): void;
61
+ protected updateTotal(total: number): void;
62
+ protected updatePageSize(pageSize: number): void;
63
+ /**
64
+ * Fetch the complete dataset for this controller.
65
+ *
66
+ * Subclasses must implement this. Return `[rows, total]` where `total` is the
67
+ * total number of rows available on the backend (useful for pagination).
68
+ */
69
+ fetchAll(): Promise<[TVariable[], number]>;
70
+ /**
71
+ * Initialise (hydrate) the controller's collection.
72
+ *
73
+ * Resolution order:
74
+ * 1) If the in-memory collection is already non-empty: do nothing.
75
+ * 2) Else, try `storageManager.get(name)` and hydrate from persisted snapshot.
76
+ * 3) Else, call `fetchAll()` and populate from the backend.
77
+ *
78
+ * A successful initialise ends with `commit()` so subscribers receive the latest snapshot.
79
+ */
80
+ initialise(): Promise<void>;
81
+ /**
82
+ * Subscribe to controller updates.
83
+ *
84
+ * The callback receives the full snapshot (`ModelType<TVariable>[]`) each time `commit()` runs.
85
+ * Returns an unsubscribe function.
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * const unsubscribe = controller.publish((rows) => console.log(rows.length));
90
+ * // later...
91
+ * unsubscribe();
92
+ * ```
93
+ */
94
+ publish(onChange: (data: ModelType<TVariable>[]) => void): () => boolean;
95
+ /**
96
+ * Persist the latest snapshot and notify all subscribers.
97
+ *
98
+ * This is intentionally private: consumers should use `commit()` which computes the snapshot.
99
+ */
100
+ private subscribe;
101
+ /**
102
+ * Publish + persist the current snapshot.
103
+ *
104
+ * Call this after any local mutation of `this.collection` so:
105
+ * - subscribers are updated (UI refresh)
106
+ * - the `StorageManager` has the latest snapshot for future hydration
107
+ */
108
+ commit(): Promise<void>;
109
+ /**
110
+ * Refetch data using the controller's initialise flow.
111
+ *
112
+ * Subclasses typically use this inside `invalidate()`.
113
+ */
114
+ protected refetch(): Promise<void>;
115
+ /**
116
+ * Invalidate the cache for this controller.
117
+ *
118
+ * Subclasses must implement this. Common patterns:
119
+ * - TTL based: refetch when expired
120
+ * - SWR: revalidate in background
121
+ * - push: refetch or patch based on websocket messages
122
+ *
123
+ * This method should return a cleanup function that unregisters any timers/listeners/sockets
124
+ * created as part of invalidation wiring.
125
+ */
126
+ invalidate(...data: TVariable[]): () => void;
127
+ /**
128
+ * Clear in-memory cache and delete persisted snapshot.
129
+ * Publishes an empty snapshot to subscribers.
130
+ */
131
+ reset(): void;
132
+ /**
133
+ * Create a controller.
134
+ *
135
+ * @param name - stable controller/collection name
136
+ * @param initialise - whether to run `initialise()` immediately
137
+ * @param storageManager - where snapshots are persisted (defaults to no-op)
138
+ * @param pageSize - optional pagination hint (userland)
139
+ */
140
+ constructor(name: TName, initialise?: boolean, storageManager?: DefaultStorageManager<TVariable>, pageSize?: number);
141
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * A single stored record.
3
+ *
4
+ * `Document<T>` wraps raw data and adds:
5
+ * - a generated `_id` (MongoDB-style ObjectId)
6
+ * - `updatedAt` timestamp, refreshed on updates
7
+ *
8
+ * Use `toModel()` when you need a plain object including `_id`
9
+ * (this is what `Controller` publishes and persists).
10
+ *
11
+ * @typeParam TVariable - data shape without `_id`
12
+ */
13
+ export default class Document<TVariable> {
14
+ _id: string;
15
+ private data;
16
+ updatedAt: number;
17
+ private static processId;
18
+ constructor(data: TVariable, counter?: number);
19
+ updateData(data: Partial<TVariable>): void;
20
+ /**
21
+ * Convert to a plain model including `_id`.
22
+ */
23
+ toModel(): {
24
+ _id: string;
25
+ } & TVariable;
26
+ /**
27
+ * Convert to raw data (without `_id`).
28
+ */
29
+ toData(): TVariable;
30
+ /**
31
+ * Generate a MongoDB-style ObjectId (24 hex characters).
32
+ *
33
+ * Format: timestamp (4 bytes) + random process id (5 bytes) + counter (3 bytes)
34
+ */
35
+ static generateId(_counter: number): string;
36
+ }
37
+ /**
38
+ * Convenience type: the return type of `Document<T>["toModel"]`.
39
+ */
40
+ export type ModelType<K> = ReturnType<Document<K>["toModel"]>;
@@ -0,0 +1,47 @@
1
+ import Controller from "./Controller";
2
+ /**
3
+ * Registry for controllers, keyed by `controller.name`.
4
+ *
5
+ * Used by React helpers (`ContextProvider`, `useController`, `useRegister`), but
6
+ * can be used in any framework.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * const store = createObjectStore();
11
+ * store.register(new UsersController("users"));
12
+ * const users = store.get("users");
13
+ * ```
14
+ */
15
+ export default class ObjectStore {
16
+ store: Map<string, Controller<any, any>>;
17
+ /**
18
+ * Register a controller instance in this store.
19
+ */
20
+ register<TVariable, TName extends string>(controller: Controller<TVariable, TName>): void;
21
+ /**
22
+ * Get a controller by name.
23
+ *
24
+ * Throws if not found. Register controllers up front via `register()`.
25
+ */
26
+ get<TVariable, TName extends string>(name: TName): Controller<TVariable, TName>;
27
+ /**
28
+ * Remove a controller from the store.
29
+ */
30
+ remove<TVariable, TName extends string>(name: TName): void;
31
+ /**
32
+ * Initialise all registered controllers.
33
+ *
34
+ * This is equivalent to calling `controller.initialise()` for each controller.
35
+ */
36
+ initialise(): void;
37
+ }
38
+ /**
39
+ * Returns a singleton store instance.
40
+ *
41
+ * `ContextProvider` uses this by default.
42
+ */
43
+ export declare function getDefaultObjectStore(): ObjectStore;
44
+ /**
45
+ * Create a new store instance (non-singleton).
46
+ */
47
+ export declare function createObjectStore(): ObjectStore;
@@ -0,0 +1,35 @@
1
+ import { ModelType } from "./Document";
2
+ /**
3
+ * Storage adapter used by `Controller` to persist and hydrate snapshots.
4
+ *
5
+ * A controller stores a **full snapshot** (array of models) keyed by `name`.
6
+ * Implementations should be resilient: reads should return `[]` on failure.
7
+ */
8
+ export declare abstract class StorageManager<TVariable> {
9
+ /**
10
+ * Get a previously persisted snapshot for a controller name.
11
+ *
12
+ * @returns Array of models (each model includes `_id`)
13
+ */
14
+ abstract get<T>(name: string): Promise<ModelType<T>[]>;
15
+ /**
16
+ * Persist a snapshot for a controller name.
17
+ *
18
+ * Controllers call this from `commit()`.
19
+ */
20
+ abstract set<T>(name: string, models: ModelType<T>[]): Promise<void>;
21
+ /**
22
+ * Delete the persisted snapshot for a controller name.
23
+ */
24
+ abstract delete(name: string): Promise<void>;
25
+ }
26
+ /**
27
+ * No-op storage manager.
28
+ *
29
+ * Useful in environments where you don’t want persistence (tests, ephemeral caches, etc).
30
+ */
31
+ export declare class DefaultStorageManager<TVariable> implements StorageManager<TVariable> {
32
+ get<T>(_name: string): Promise<ModelType<T>[]>;
33
+ set<T>(_name: string, _models: ModelType<T>[]): Promise<void>;
34
+ delete(_name: string): Promise<void>;
35
+ }
@@ -0,0 +1,106 @@
1
+ import Controller from "./Controller";
2
+ import { ModelType } from "./Document";
3
+ /**
4
+ * Join data across multiple controllers by applying cross-controller equality constraints.
5
+ *
6
+ * `where.$and[controllerName]` can include:
7
+ * - literal: `{ id: 1 }`
8
+ * - join ref: `{ userId: { $ref: { controller: "users", field: "id" } } }`
9
+ * - eq: `{ status: { $eq: "active" } }`
10
+ *
11
+ * `select` supports:
12
+ * - array of qualified keys: `["users.id", "posts.title"]`
13
+ * - select-map for aliasing: `{ "users.id": "userId", "posts.title": "title" }`
14
+ * - mixed array: `["users.id", { "posts.title": "title" }]`
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const rows = join(
19
+ * [usersController, postsController] as const,
20
+ * {
21
+ * $and: {
22
+ * posts: { userId: { $ref: { controller: "users", field: "id" } } },
23
+ * },
24
+ * } as const,
25
+ * ["users.name", "posts.title"] as const,
26
+ * );
27
+ * ```
28
+ */
29
+ type ControllerName<C> = C extends Controller<any, infer N> ? N : never;
30
+ type ControllerVar<C> = C extends Controller<infer V, any> ? V : never;
31
+ type Tuple2Plus = readonly [
32
+ Controller<any, any>,
33
+ Controller<any, any>,
34
+ ...Controller<any, any>[]
35
+ ];
36
+ type Names<Cs extends readonly Controller<any, any>[]> = ControllerName<Cs[number]>;
37
+ type ControllerOfName<Cs extends readonly Controller<any, any>[], N extends string> = Extract<Cs[number], Controller<any, N>>;
38
+ type ModelOfName<Cs extends readonly Controller<any, any>[], N extends string> = ModelType<ControllerVar<ControllerOfName<Cs, N>>>;
39
+ export type JoinRef<Cs extends readonly Controller<any, any>[]> = {
40
+ [N in Names<Cs>]: {
41
+ controller: N;
42
+ field: keyof ModelOfName<Cs, N>;
43
+ };
44
+ }[Names<Cs>];
45
+ type JoinEq<Cs extends readonly Controller<any, any>[], N extends Names<Cs>, K extends keyof ModelOfName<Cs, N>> = {
46
+ $eq: ModelOfName<Cs, N>[K] | JoinRef<Cs>;
47
+ $as?: string;
48
+ };
49
+ type JoinRefCond<Cs extends readonly Controller<any, any>[]> = {
50
+ $ref: JoinRef<Cs>;
51
+ $as?: string;
52
+ };
53
+ export type FieldCondition<Cs extends readonly Controller<any, any>[], N extends Names<Cs>, K extends keyof ModelOfName<Cs, N>> = ModelOfName<Cs, N>[K] | JoinEq<Cs, N, K> | JoinRefCond<Cs>;
54
+ type PerControllerWhere<Cs extends readonly Controller<any, any>[], N extends Names<Cs>> = Partial<{
55
+ [K in keyof ModelOfName<Cs, N>]: FieldCondition<Cs, N, K>;
56
+ }>;
57
+ type AndWhere<Cs extends readonly Controller<any, any>[]> = Partial<{
58
+ [N in Names<Cs>]: PerControllerWhere<Cs, N>;
59
+ }>;
60
+ export type JoinWhere<Cs extends readonly Controller<any, any>[]> = {
61
+ $and?: AndWhere<Cs>;
62
+ };
63
+ type ExtractAs<T> = T extends {
64
+ $as?: infer S;
65
+ } ? (S extends string ? S : never) : never;
66
+ type AsInPerControllerWhere<T> = T extends object ? ExtractAs<T[keyof T]> : never;
67
+ type AsInAndWhere<T> = T extends object ? AsInPerControllerWhere<T[keyof T]> : never;
68
+ type AsInJoinWhere<W> = W extends {
69
+ $and?: infer A;
70
+ } ? AsInAndWhere<A> : never;
71
+ type QualifiedModelKey<Cs extends readonly Controller<any, any>[]> = {
72
+ [N in Names<Cs>]: `${N}.${Extract<keyof ModelOfName<Cs, N>, string>}`;
73
+ }[Names<Cs>];
74
+ type QualifiedAsKey<Cs extends readonly Controller<any, any>[], W> = AsInJoinWhere<W> extends infer A ? A extends string ? `${Names<Cs>}.${A}` : never : never;
75
+ type SelectKey<Cs extends readonly Controller<any, any>[], W> = QualifiedModelKey<Cs> | QualifiedAsKey<Cs, W>;
76
+ type ValueForKey<Cs extends readonly Controller<any, any>[], K extends PropertyKey> = K extends `${infer N}.${infer F}` ? N extends Names<Cs> ? F extends keyof ModelOfName<Cs, N> ? ModelOfName<Cs, N>[F] : unknown : unknown : unknown;
77
+ type ProjectionFromSelect<Cs extends readonly Controller<any, any>[], S extends readonly PropertyKey[]> = {
78
+ [K in S[number]]: ValueForKey<Cs, K>;
79
+ };
80
+ type SelectMap<Cs extends readonly Controller<any, any>[], W> = Partial<Record<SelectKey<Cs, W>, string>>;
81
+ type ProjectionFromSelectMap<Cs extends readonly Controller<any, any>[], M extends Partial<Record<PropertyKey, string>>> = {
82
+ [K in keyof M as M[K] extends string ? M[K] : never]: ValueForKey<Cs, K>;
83
+ };
84
+ type ProjectionFromSelectMapSafe<Cs extends readonly Controller<any, any>[], M> = M extends Partial<Record<PropertyKey, string>> ? ProjectionFromSelectMap<Cs, M> : {};
85
+ type UnionToIntersection<U> = [
86
+ U
87
+ ] extends [never] ? {} : (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : {};
88
+ type MixedSelect<Cs extends readonly Controller<any, any>[], W> = readonly (SelectKey<Cs, W> | SelectMap<Cs, W>)[];
89
+ type KeysFromMixedSelect<SA extends readonly unknown[]> = Extract<SA[number], PropertyKey>;
90
+ type MapsFromMixedSelect<SA extends readonly unknown[]> = Extract<SA[number], Partial<Record<PropertyKey, string>>>;
91
+ type ProjectionFromMixedSelect<Cs extends readonly Controller<any, any>[], SA extends readonly unknown[]> = ProjectionFromSelect<Cs, readonly KeysFromMixedSelect<SA>[]> & ProjectionFromSelectMapSafe<Cs, UnionToIntersection<MapsFromMixedSelect<SA>>>;
92
+ /**
93
+ * Joins data across controllers.
94
+ *
95
+ * `where.$and[controllerName]` can include:
96
+ * - literal: `{ id: 1 }`
97
+ * - join: `{ userId: { $ref: { controller: \"users\", field: \"id\" }, $as?: \"userId\" } }`
98
+ * - eq: `{ status: { $eq: \"active\", $as?: \"state\" } }`
99
+ *
100
+ * `$as` aliases the value into the output object (legacy).
101
+ * Prefer passing `select` as an object: `{ fieldName: "alias" }`.
102
+ */
103
+ export default function join<const Cs extends Tuple2Plus, const W extends JoinWhere<Cs>, const S extends readonly SelectKey<Cs, W>[]>(from: Cs, where: W, select: S): Array<ProjectionFromSelect<Cs, S>>;
104
+ export default function join<const Cs extends Tuple2Plus, const W extends JoinWhere<Cs>, const SM extends SelectMap<Cs, W>>(from: Cs, where: W, select: SM & SelectMap<Cs, W>): Array<ProjectionFromSelectMap<Cs, SM>>;
105
+ export default function join<const Cs extends Tuple2Plus, const W extends JoinWhere<Cs>, const SA extends MixedSelect<Cs, W>>(from: Cs, where: W, select: SA): Array<ProjectionFromMixedSelect<Cs, SA>>;
106
+ export {};