@lunora/db 1.0.0-alpha.4 → 1.0.0-alpha.40

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.
package/LICENSE.md CHANGED
@@ -103,3 +103,9 @@ Unless required by applicable law or agreed to in writing, software distributed
103
103
  under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
104
104
  CONDITIONS OF ANY KIND, either express or implied. See the License for the
105
105
  specific language governing permissions and limitations under the License.
106
+
107
+ <!-- DEPENDENCIES -->
108
+ <!-- /DEPENDENCIES -->
109
+
110
+ <!-- TYPE_DEPENDENCIES -->
111
+ <!-- /TYPE_DEPENDENCIES -->
package/README.md CHANGED
@@ -85,6 +85,18 @@ export const createCollections = (client: LunoraClient) =>
85
85
 
86
86
  > `vis generate lunora-collections` scaffolds this from your `schema.ts` + functions.
87
87
 
88
+ ### Local-first sync engine
89
+
90
+ Beyond whole-table collections, the `@lunora/db/collections` and
91
+ `@lunora/db/mutators` subpaths expose the local-first sync engine:
92
+ `lunoraCollectionOptions({ shape })` syncs a **partial replication shape** (only
93
+ the rows a client needs, scoped by a server-resolved predicate) over the poke
94
+ diff protocol, and `defineMutator` + `bindMutators` run **optimistic custom
95
+ mutators** (a local body first, a server-authoritative impl second, rebased on
96
+ every sync tick). The framework adapters add a `useMutator` / `createMutator` /
97
+ `mutator` hook over a bound handle. See the
98
+ **[local-first guide](https://lunora.sh/docs/concepts/local-first)**.
99
+
88
100
  > This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs/addons/db)**.
89
101
 
90
102
  ## Related
@@ -0,0 +1,5 @@
1
+ export { type C as CheckpointRegistry, type L as LunoraCollectionConfig, type a as LunoraCollectionOptions, c as createCheckpointRegistry, l as lunoraCollectionOptions } from "../packem_shared/collection-options.d-B_2IXvdU.mjs";
2
+ export { type C as CollectionDef, type I as InsertBinding, type L as LunoraDb, d as defineCollections } from "../packem_shared/define-collections.d-C6J1Y4Q_.mjs";
3
+ import '@lunora/client';
4
+ import '@tanstack/db';
5
+ import '@tanstack/offline-transactions';
@@ -0,0 +1,5 @@
1
+ export { type C as CheckpointRegistry, type L as LunoraCollectionConfig, type a as LunoraCollectionOptions, c as createCheckpointRegistry, l as lunoraCollectionOptions } from "../packem_shared/collection-options.d-B_2IXvdU.js";
2
+ export { type C as CollectionDef, type I as InsertBinding, type L as LunoraDb, d as defineCollections } from "../packem_shared/define-collections.d-DAFPHFxr.js";
3
+ import '@lunora/client';
4
+ import '@tanstack/db';
5
+ import '@tanstack/offline-transactions';
@@ -0,0 +1 @@
1
+ import{createCheckpointRegistry as t,lunoraCollectionOptions as r}from"../packem_shared/CHECKPOINT_FALLBACK_MS-6GmTGWsH.mjs";import{defineCollections as n}from"../packem_shared/defineCollections-4CPSF9OJ.mjs";export{t as createCheckpointRegistry,n as defineCollections,r as lunoraCollectionOptions};
package/dist/index.d.mts CHANGED
@@ -1,142 +1,82 @@
1
- import { FunctionReference, SubscriptionError, LunoraClient } from '@lunora/client';
2
- import { Transaction, Collection } from '@tanstack/db';
3
- import { OnlineDetector, OfflineExecutor } from '@tanstack/offline-transactions';
4
- /** A row carrying the Lunora document id. */
5
- type Row = Record<string, unknown> & {
6
- _id: string;
7
- };
8
- /** The subset of a TanStack DB sync write channel that {@link makeDiffEmit} drives. */
9
- interface SyncWriter<T extends object> {
10
- begin: () => void;
11
- commit: () => void;
12
- write: (message: {
13
- type: "insert" | "update";
14
- value: T;
15
- } | {
16
- key: string;
17
- type: "delete";
18
- }) => void;
1
+ import { Collection } from '@tanstack/db';
2
+ import { R as Row } from "./packem_shared/collection-options.d-B_2IXvdU.mjs";
3
+ export { b as CHECKPOINT_FALLBACK_MS, type d as CheckpointFallbackEvent, type C as CheckpointRegistry, type e as CheckpointRegistryOptions, type f as CheckpointRegistryStats, type g as CheckpointWatermark, type E as ExecutorOutboxSinkOptions, type L as LunoraCollectionConfig, type a as LunoraCollectionOptions, O as OUTBOX_MUTATION_FN_NAME, type h as OutboxExecutor, type i as OutboxMutationMetadata, type S as SyncWriter, c as createCheckpointRegistry, j as createExecutorOutboxSink, k as createOptimisticOnlineDetector, m as getShardCheckpoints, l as lunoraCollectionOptions, n as makeDiffEmit, r as releaseShardCheckpoints, o as runOutboxMutation, s as shardCheckpointStats, t as toMap } from "./packem_shared/collection-options.d-B_2IXvdU.mjs";
4
+ export { type C as CollectionDef, type D as DefineCollectionsOptions, type I as InsertBinding, type L as LunoraDb, type W as WriteRejectedEvent, d as defineCollections } from "./packem_shared/define-collections.d-C6J1Y4Q_.mjs";
5
+ export { type B as BindMutatorsContext, type a as BoundMutatorApi, type b as BoundMutators, type C as ClientMutatorContext, type c as ClientMutatorDef, type d as CollectionMap, D as DIRECT_TRANSACTION_METADATA_KEY, type g as MutatorReference, type M as MutatorRejectedEvent, e as bindMutators, f as defineMutator, i as initMutators } from "./packem_shared/define-mutators.d-BXQN7fju.mjs";
6
+ import '@lunora/client';
7
+ import '@tanstack/offline-transactions';
8
+ /** One row to insert. `_id` may be pre-minted client-side so the optimistic row keys match the persisted one. */
9
+ interface PlanInsert {
10
+ /** Row body. Include `_id` to key the row yourself (the server honors it as the `clientId`). */
11
+ row: Record<string, unknown> & {
12
+ _id?: string;
13
+ };
14
+ table: string;
19
15
  }
20
- /** Index a row list into a keyed map. */
21
- declare const toMap: <T extends object>(rows: ReadonlyArray<T>, getKey: (row: T) => string) => Map<string, T>;
22
- /**
23
- * Build an `emit(next)` that diffs a desired keyed snapshot into a collection's
24
- * sync channel — only changed rows are written, so a reconnect snapshot or a
25
- * scope change never churns the synced view out from under a pending optimistic
26
- * row. The last-synced base is tracked in `synced`.
27
- *
28
- * Change detection compares rows by `JSON.stringify`, which is key-order
29
- * sensitive — safe here because `synced` only ever holds server snapshots, whose
30
- * column order is stable across reconnects (same query projection). A sync source
31
- * with unstable key ordering would need a structural compare instead.
32
- */
33
- declare const makeDiffEmit: <T extends object>(synced: Map<string, T>, writer: SyncWriter<T>) => (next: Map<string, T>) => void;
34
- /**
35
- * Run a Lunora mutation under the outbox's retry policy.
36
- *
37
- * The retryable/permanent split keys on whether the failure carries a server
38
- * application error `code` (set by `@lunora/client`'s rpc when the server returns
39
- * a `{ error: { code, … } }` envelope — validation, conflict, etc.). A coded
40
- * error is a definite verdict: surface it as a `NonRetriableError` so the executor
41
- * stops and TanStack DB rolls the optimistic insert back. Everything without a
42
- * code is transient — a `fetch` network failure (`TypeError`) or an HTTP/infra
43
- * blip the rpc surfaces as a code-less `Error` (a 5xx gateway page, a non-JSON
44
- * body) — so it's rethrown as-is and the durable outbox replays it. Keying on
45
- * `error instanceof TypeError` alone would wrongly drop the latter.
46
- */
47
- declare const runOutboxMutation: (mutate: () => Promise<unknown>) => Promise<void>;
48
- /**
49
- * An "always attempt" online detector. We deliberately don't trust
50
- * `navigator.onLine`: some environments (and Playwright's `setOffline` under
51
- * Firefox) leave it stuck, which would freeze the outbox. Instead the executor
52
- * always tries the send and {@link runOutboxMutation}'s transient-error retry
53
- * handles real offline; the periodic tick nudges the executor to drain the outbox
54
- * so a queued write replays promptly once connectivity returns.
55
- *
56
- * `isOnline` is therefore intentionally always `true` — it gates the executor's
57
- * attempts, not a UI signal. A consumer that wants to show real connectivity
58
- * should read `navigator.onLine` itself, separately from this detector.
59
- */
60
- declare const createOptimisticOnlineDetector: () => OnlineDetector;
61
- /** Element type of an array (the row type a `list` query returns). */
62
- type Element<T> = T extends ReadonlyArray<infer E> ? E : never;
63
- /** `true` for the `any` type, `false` otherwise. */
64
- type IsAny<T> = 0 extends 1 & T ? true : false;
65
- /**
66
- * The row type a `list` query syncs. For `TList = any` (the heterogeneous-map
67
- * constraint) it resolves to the permissive {@link Row}, not `never` — otherwise
68
- * the constraint would force every `optimistic` to return `never`. For a concrete
69
- * `FunctionReference` it's the element type of the query's array return.
70
- */
71
- type RowOfList<TList> = IsAny<TList> extends true ? Row : TList extends FunctionReference<infer _K, infer _A, infer R> ? Element<R> & Row : never;
72
- /** Maps a write through the durable outbox: optimistic insert + a retried mutation. */
73
- interface InsertBinding<TRow extends Row, TInput> {
74
- /** The Lunora mutation that persists the row. */
75
- mutation: FunctionReference;
76
- /** Build the optimistic row to insert from the action input + the generated client id. */
77
- optimistic: (input: TInput, id: string) => TRow;
78
- /** Build the mutation args from the persisted optimistic row (forward `_id` as the `clientId`). */
79
- toArgs: (row: TRow) => Record<string, unknown>;
16
+ /** One row to patch, by id. */
17
+ interface PlanPatch {
18
+ fields: Record<string, unknown>;
19
+ id: string;
20
+ table: string;
80
21
  }
81
- /** Declarative binding of a Lunora table to a live collection (+ optional write action). */
82
- interface CollectionDef<TList extends FunctionReference, TInput = never> {
83
- /** Row key extractor — defaults to `row._id`. */
84
- getKey?: (row: RowOfList<TList>) => string;
85
- /** Optional write binding — present iff this collection is written through the outbox. */
86
- insert?: InsertBinding<RowOfList<TList>, TInput>;
87
- /** The Lunora query that lists the rows (the sync source). */
88
- list: TList;
89
- /**
90
- * Notified when the underlying `list` subscription errors (e.g. the server
91
- * rejects it). Without this the error would be swallowed and the collection
92
- * could hang in `loading`; the binding always moves the collection out of
93
- * `loading` on error, and forwards the error here if supplied.
94
- */
95
- onError?: (error: SubscriptionError) => void;
96
- /** A field that scopes the list (e.g. a shard key); makes the collection re-pointable via `scope`. */
97
- scopeBy?: string;
22
+ /** One row to delete, by id. */
23
+ interface PlanDelete {
24
+ id: string;
25
+ table: string;
98
26
  }
99
- type AnyDef = CollectionDef<any, any>;
100
27
  /**
101
- * The public row type a collection exposes the element type of its `list`
102
- * query's return, with no `& Row`: a `Collection&lt;T>` is invariant in `T`, so the
103
- * exposed type must be exactly the document type, not a subtype.
104
- */
105
- type RowOf<C extends AnyDef> = C["list"] extends FunctionReference<infer _K, infer _A, infer R> ? Element<R> : never;
106
- /** The action input type, inferred structurally from the def's optimistic insert. */
107
- type InputOf<C> = C extends {
108
- insert: {
109
- optimistic: (input: infer I, id: string) => unknown;
110
- };
111
- } ? I : never;
112
- /** The wired data layer `defineCollections` returns. */
113
- interface LunoraDb<D extends Record<string, AnyDef>> {
114
- /** Optimistic, durable, retried write actionspresent for `insert` collections. */
115
- actions: { [K in keyof D]: D[K] extends {
116
- insert: object;
117
- } ? (input: InputOf<D[K]>) => {
118
- id: string;
119
- transaction: Transaction;
120
- } : never };
121
- /** The live, synced collections feed these to `useLiveQuery`. */
122
- collections: { [K in keyof D]: Collection<RowOf<D[K]>, string> };
123
- /** The shared offline executor (the outbox). */
124
- executor: OfflineExecutor;
125
- /** Re-point a `scopeBy` collection's subscription (omit `args` to detach) present for scoped collections. */
126
- scope: { [K in keyof D]: D[K] extends {
127
- scopeBy: string;
128
- } ? (args?: Record<string, unknown>) => void : never };
28
+ * A change plan: what a mutator intends to write.
29
+ *
30
+ * Applied in a fixed order **deletes, then patches, then inserts** — by both
31
+ * appliers. The order is part of the contract, not an implementation detail: a plan
32
+ * that deletes a row and inserts its replacement under the same natural key only
33
+ * behaves the same on both sides if both sides agree which happens first.
34
+ */
35
+ interface ChangePlan {
36
+ deletes?: ReadonlyArray<PlanDelete>;
37
+ inserts?: ReadonlyArray<PlanInsert>;
38
+ patches?: ReadonlyArray<PlanPatch>;
39
+ }
40
+ /**
41
+ * The `ctx.db` methods {@link applyPlanToDb} needs structural, so any writer
42
+ * satisfies it.
43
+ *
44
+ * The `never` parameter positions are deliberate, not laziness. `ctx.db.delete` is
45
+ * `<T extends string>(id: Id<T>) => …` over the **branded** `Id<T>`, and under
46
+ * `strictFunctionTypes` a parameter is contravariant — so declaring `id: string` here
47
+ * would make the real `ctx.db` un-assignable to `PlanWriter` (`string` is not
48
+ * assignable to `string & { __table }`), and every caller would need a cast at the
49
+ * call site instead. `never` accepts any branded id, which keeps `applyPlanToDb(ctx.db,
50
+ * plan)` cast-free for the caller and confines the two `as never` casts to this module.
51
+ *
52
+ * The trade-off is real: argument checking inside `applyPlanToDb` is erased, so a plan
53
+ * naming a table the schema doesn't have is a runtime error. Use `ctx.db.asId(table,
54
+ * id)` when building the plan to catch a malformed id at the boundary.
55
+ */
56
+ interface PlanWriter {
57
+ delete: (id: never) => Promise<void>;
58
+ insert: (tableName: never, document: Record<string, unknown>, options?: {
59
+ clientId?: string;
60
+ }) => Promise<unknown>;
61
+ patch: (id: never, patch: Record<string, unknown>) => Promise<void>;
129
62
  }
130
63
  /**
131
- * Wire a set of Lunora tables into a TanStack DB data layer in one declaration:
132
- * each entry becomes a live, auto-indexed collection synced from its `list` query,
133
- * and `insert` entries get an optimistic write action backed by the
134
- * offline-transactions outbox (durable, retried, client-id-keyed). Scoped
135
- * (`scopeBy`) collections are re-pointable for sharded queries.
136
- *
137
- * This is the hand-written form; `@lunora/codegen` can emit a fully-typed call to
138
- * it from `schema.ts`, so an app writes nothing.
139
- */
140
- declare const defineCollections: <D extends Record<string, AnyDef>>(client: LunoraClient, defs: D) => LunoraDb<D>;
64
+ * Apply `plan` to a map of TanStack collections the client (optimistic) half.
65
+ *
66
+ * A plan naming a table with no wired collection is **skipped, not an error**: an app
67
+ * legitimately syncs a subset of the tables its mutators write (a server-only audit
68
+ * row has no client collection), and throwing would make the optimistic body fail on
69
+ * a write the server handles fine.
70
+ */
71
+ declare const applyPlanToCollections: (collections: Record<string, Collection<Row, string>>, plan: ChangePlan) => void;
72
+ /**
73
+ * Apply `plan` to a `ctx.db` writer the server (authoritative) half.
74
+ *
75
+ * Sequential by design: the shard's SQLite is single-threaded and a mutation runs
76
+ * inside one BEGIN/COMMIT span, so ordering is observable and a mid-plan failure
77
+ * rolls the whole plan back. An insert carrying an `_id` forwards it as `clientId`,
78
+ * which is how a client-minted key becomes the persisted primary key.
79
+ */
80
+ declare const applyPlanToDb: (db: PlanWriter, plan: ChangePlan) => Promise<void>;
141
81
  declare const VERSION = "0.0.0";
142
- export { type CollectionDef, type InsertBinding, type LunoraDb, type Row, type SyncWriter, VERSION, createOptimisticOnlineDetector, defineCollections, makeDiffEmit, runOutboxMutation, toMap };
82
+ export { type ChangePlan, type PlanDelete, type PlanInsert, type PlanPatch, type PlanWriter, type Row, VERSION, applyPlanToCollections, applyPlanToDb };
package/dist/index.d.ts CHANGED
@@ -1,142 +1,82 @@
1
- import { FunctionReference, SubscriptionError, LunoraClient } from '@lunora/client';
2
- import { Transaction, Collection } from '@tanstack/db';
3
- import { OnlineDetector, OfflineExecutor } from '@tanstack/offline-transactions';
4
- /** A row carrying the Lunora document id. */
5
- type Row = Record<string, unknown> & {
6
- _id: string;
7
- };
8
- /** The subset of a TanStack DB sync write channel that {@link makeDiffEmit} drives. */
9
- interface SyncWriter<T extends object> {
10
- begin: () => void;
11
- commit: () => void;
12
- write: (message: {
13
- type: "insert" | "update";
14
- value: T;
15
- } | {
16
- key: string;
17
- type: "delete";
18
- }) => void;
1
+ import { Collection } from '@tanstack/db';
2
+ import { R as Row } from "./packem_shared/collection-options.d-B_2IXvdU.js";
3
+ export { b as CHECKPOINT_FALLBACK_MS, type d as CheckpointFallbackEvent, type C as CheckpointRegistry, type e as CheckpointRegistryOptions, type f as CheckpointRegistryStats, type g as CheckpointWatermark, type E as ExecutorOutboxSinkOptions, type L as LunoraCollectionConfig, type a as LunoraCollectionOptions, O as OUTBOX_MUTATION_FN_NAME, type h as OutboxExecutor, type i as OutboxMutationMetadata, type S as SyncWriter, c as createCheckpointRegistry, j as createExecutorOutboxSink, k as createOptimisticOnlineDetector, m as getShardCheckpoints, l as lunoraCollectionOptions, n as makeDiffEmit, r as releaseShardCheckpoints, o as runOutboxMutation, s as shardCheckpointStats, t as toMap } from "./packem_shared/collection-options.d-B_2IXvdU.js";
4
+ export { type C as CollectionDef, type D as DefineCollectionsOptions, type I as InsertBinding, type L as LunoraDb, type W as WriteRejectedEvent, d as defineCollections } from "./packem_shared/define-collections.d-DAFPHFxr.js";
5
+ export { type B as BindMutatorsContext, type a as BoundMutatorApi, type b as BoundMutators, type C as ClientMutatorContext, type c as ClientMutatorDef, type d as CollectionMap, D as DIRECT_TRANSACTION_METADATA_KEY, type g as MutatorReference, type M as MutatorRejectedEvent, e as bindMutators, f as defineMutator, i as initMutators } from "./packem_shared/define-mutators.d-DLP_vYu9.js";
6
+ import '@lunora/client';
7
+ import '@tanstack/offline-transactions';
8
+ /** One row to insert. `_id` may be pre-minted client-side so the optimistic row keys match the persisted one. */
9
+ interface PlanInsert {
10
+ /** Row body. Include `_id` to key the row yourself (the server honors it as the `clientId`). */
11
+ row: Record<string, unknown> & {
12
+ _id?: string;
13
+ };
14
+ table: string;
19
15
  }
20
- /** Index a row list into a keyed map. */
21
- declare const toMap: <T extends object>(rows: ReadonlyArray<T>, getKey: (row: T) => string) => Map<string, T>;
22
- /**
23
- * Build an `emit(next)` that diffs a desired keyed snapshot into a collection's
24
- * sync channel — only changed rows are written, so a reconnect snapshot or a
25
- * scope change never churns the synced view out from under a pending optimistic
26
- * row. The last-synced base is tracked in `synced`.
27
- *
28
- * Change detection compares rows by `JSON.stringify`, which is key-order
29
- * sensitive — safe here because `synced` only ever holds server snapshots, whose
30
- * column order is stable across reconnects (same query projection). A sync source
31
- * with unstable key ordering would need a structural compare instead.
32
- */
33
- declare const makeDiffEmit: <T extends object>(synced: Map<string, T>, writer: SyncWriter<T>) => (next: Map<string, T>) => void;
34
- /**
35
- * Run a Lunora mutation under the outbox's retry policy.
36
- *
37
- * The retryable/permanent split keys on whether the failure carries a server
38
- * application error `code` (set by `@lunora/client`'s rpc when the server returns
39
- * a `{ error: { code, … } }` envelope — validation, conflict, etc.). A coded
40
- * error is a definite verdict: surface it as a `NonRetriableError` so the executor
41
- * stops and TanStack DB rolls the optimistic insert back. Everything without a
42
- * code is transient — a `fetch` network failure (`TypeError`) or an HTTP/infra
43
- * blip the rpc surfaces as a code-less `Error` (a 5xx gateway page, a non-JSON
44
- * body) — so it's rethrown as-is and the durable outbox replays it. Keying on
45
- * `error instanceof TypeError` alone would wrongly drop the latter.
46
- */
47
- declare const runOutboxMutation: (mutate: () => Promise<unknown>) => Promise<void>;
48
- /**
49
- * An "always attempt" online detector. We deliberately don't trust
50
- * `navigator.onLine`: some environments (and Playwright's `setOffline` under
51
- * Firefox) leave it stuck, which would freeze the outbox. Instead the executor
52
- * always tries the send and {@link runOutboxMutation}'s transient-error retry
53
- * handles real offline; the periodic tick nudges the executor to drain the outbox
54
- * so a queued write replays promptly once connectivity returns.
55
- *
56
- * `isOnline` is therefore intentionally always `true` — it gates the executor's
57
- * attempts, not a UI signal. A consumer that wants to show real connectivity
58
- * should read `navigator.onLine` itself, separately from this detector.
59
- */
60
- declare const createOptimisticOnlineDetector: () => OnlineDetector;
61
- /** Element type of an array (the row type a `list` query returns). */
62
- type Element<T> = T extends ReadonlyArray<infer E> ? E : never;
63
- /** `true` for the `any` type, `false` otherwise. */
64
- type IsAny<T> = 0 extends 1 & T ? true : false;
65
- /**
66
- * The row type a `list` query syncs. For `TList = any` (the heterogeneous-map
67
- * constraint) it resolves to the permissive {@link Row}, not `never` — otherwise
68
- * the constraint would force every `optimistic` to return `never`. For a concrete
69
- * `FunctionReference` it's the element type of the query's array return.
70
- */
71
- type RowOfList<TList> = IsAny<TList> extends true ? Row : TList extends FunctionReference<infer _K, infer _A, infer R> ? Element<R> & Row : never;
72
- /** Maps a write through the durable outbox: optimistic insert + a retried mutation. */
73
- interface InsertBinding<TRow extends Row, TInput> {
74
- /** The Lunora mutation that persists the row. */
75
- mutation: FunctionReference;
76
- /** Build the optimistic row to insert from the action input + the generated client id. */
77
- optimistic: (input: TInput, id: string) => TRow;
78
- /** Build the mutation args from the persisted optimistic row (forward `_id` as the `clientId`). */
79
- toArgs: (row: TRow) => Record<string, unknown>;
16
+ /** One row to patch, by id. */
17
+ interface PlanPatch {
18
+ fields: Record<string, unknown>;
19
+ id: string;
20
+ table: string;
80
21
  }
81
- /** Declarative binding of a Lunora table to a live collection (+ optional write action). */
82
- interface CollectionDef<TList extends FunctionReference, TInput = never> {
83
- /** Row key extractor — defaults to `row._id`. */
84
- getKey?: (row: RowOfList<TList>) => string;
85
- /** Optional write binding — present iff this collection is written through the outbox. */
86
- insert?: InsertBinding<RowOfList<TList>, TInput>;
87
- /** The Lunora query that lists the rows (the sync source). */
88
- list: TList;
89
- /**
90
- * Notified when the underlying `list` subscription errors (e.g. the server
91
- * rejects it). Without this the error would be swallowed and the collection
92
- * could hang in `loading`; the binding always moves the collection out of
93
- * `loading` on error, and forwards the error here if supplied.
94
- */
95
- onError?: (error: SubscriptionError) => void;
96
- /** A field that scopes the list (e.g. a shard key); makes the collection re-pointable via `scope`. */
97
- scopeBy?: string;
22
+ /** One row to delete, by id. */
23
+ interface PlanDelete {
24
+ id: string;
25
+ table: string;
98
26
  }
99
- type AnyDef = CollectionDef<any, any>;
100
27
  /**
101
- * The public row type a collection exposes the element type of its `list`
102
- * query's return, with no `& Row`: a `Collection&lt;T>` is invariant in `T`, so the
103
- * exposed type must be exactly the document type, not a subtype.
104
- */
105
- type RowOf<C extends AnyDef> = C["list"] extends FunctionReference<infer _K, infer _A, infer R> ? Element<R> : never;
106
- /** The action input type, inferred structurally from the def's optimistic insert. */
107
- type InputOf<C> = C extends {
108
- insert: {
109
- optimistic: (input: infer I, id: string) => unknown;
110
- };
111
- } ? I : never;
112
- /** The wired data layer `defineCollections` returns. */
113
- interface LunoraDb<D extends Record<string, AnyDef>> {
114
- /** Optimistic, durable, retried write actionspresent for `insert` collections. */
115
- actions: { [K in keyof D]: D[K] extends {
116
- insert: object;
117
- } ? (input: InputOf<D[K]>) => {
118
- id: string;
119
- transaction: Transaction;
120
- } : never };
121
- /** The live, synced collections feed these to `useLiveQuery`. */
122
- collections: { [K in keyof D]: Collection<RowOf<D[K]>, string> };
123
- /** The shared offline executor (the outbox). */
124
- executor: OfflineExecutor;
125
- /** Re-point a `scopeBy` collection's subscription (omit `args` to detach) present for scoped collections. */
126
- scope: { [K in keyof D]: D[K] extends {
127
- scopeBy: string;
128
- } ? (args?: Record<string, unknown>) => void : never };
28
+ * A change plan: what a mutator intends to write.
29
+ *
30
+ * Applied in a fixed order **deletes, then patches, then inserts** — by both
31
+ * appliers. The order is part of the contract, not an implementation detail: a plan
32
+ * that deletes a row and inserts its replacement under the same natural key only
33
+ * behaves the same on both sides if both sides agree which happens first.
34
+ */
35
+ interface ChangePlan {
36
+ deletes?: ReadonlyArray<PlanDelete>;
37
+ inserts?: ReadonlyArray<PlanInsert>;
38
+ patches?: ReadonlyArray<PlanPatch>;
39
+ }
40
+ /**
41
+ * The `ctx.db` methods {@link applyPlanToDb} needs structural, so any writer
42
+ * satisfies it.
43
+ *
44
+ * The `never` parameter positions are deliberate, not laziness. `ctx.db.delete` is
45
+ * `<T extends string>(id: Id<T>) => …` over the **branded** `Id<T>`, and under
46
+ * `strictFunctionTypes` a parameter is contravariant — so declaring `id: string` here
47
+ * would make the real `ctx.db` un-assignable to `PlanWriter` (`string` is not
48
+ * assignable to `string & { __table }`), and every caller would need a cast at the
49
+ * call site instead. `never` accepts any branded id, which keeps `applyPlanToDb(ctx.db,
50
+ * plan)` cast-free for the caller and confines the two `as never` casts to this module.
51
+ *
52
+ * The trade-off is real: argument checking inside `applyPlanToDb` is erased, so a plan
53
+ * naming a table the schema doesn't have is a runtime error. Use `ctx.db.asId(table,
54
+ * id)` when building the plan to catch a malformed id at the boundary.
55
+ */
56
+ interface PlanWriter {
57
+ delete: (id: never) => Promise<void>;
58
+ insert: (tableName: never, document: Record<string, unknown>, options?: {
59
+ clientId?: string;
60
+ }) => Promise<unknown>;
61
+ patch: (id: never, patch: Record<string, unknown>) => Promise<void>;
129
62
  }
130
63
  /**
131
- * Wire a set of Lunora tables into a TanStack DB data layer in one declaration:
132
- * each entry becomes a live, auto-indexed collection synced from its `list` query,
133
- * and `insert` entries get an optimistic write action backed by the
134
- * offline-transactions outbox (durable, retried, client-id-keyed). Scoped
135
- * (`scopeBy`) collections are re-pointable for sharded queries.
136
- *
137
- * This is the hand-written form; `@lunora/codegen` can emit a fully-typed call to
138
- * it from `schema.ts`, so an app writes nothing.
139
- */
140
- declare const defineCollections: <D extends Record<string, AnyDef>>(client: LunoraClient, defs: D) => LunoraDb<D>;
64
+ * Apply `plan` to a map of TanStack collections the client (optimistic) half.
65
+ *
66
+ * A plan naming a table with no wired collection is **skipped, not an error**: an app
67
+ * legitimately syncs a subset of the tables its mutators write (a server-only audit
68
+ * row has no client collection), and throwing would make the optimistic body fail on
69
+ * a write the server handles fine.
70
+ */
71
+ declare const applyPlanToCollections: (collections: Record<string, Collection<Row, string>>, plan: ChangePlan) => void;
72
+ /**
73
+ * Apply `plan` to a `ctx.db` writer the server (authoritative) half.
74
+ *
75
+ * Sequential by design: the shard's SQLite is single-threaded and a mutation runs
76
+ * inside one BEGIN/COMMIT span, so ordering is observable and a mid-plan failure
77
+ * rolls the whole plan back. An insert carrying an `_id` forwards it as `clientId`,
78
+ * which is how a client-minted key becomes the persisted primary key.
79
+ */
80
+ declare const applyPlanToDb: (db: PlanWriter, plan: ChangePlan) => Promise<void>;
141
81
  declare const VERSION = "0.0.0";
142
- export { type CollectionDef, type InsertBinding, type LunoraDb, type Row, type SyncWriter, VERSION, createOptimisticOnlineDetector, defineCollections, makeDiffEmit, runOutboxMutation, toMap };
82
+ export { type ChangePlan, type PlanDelete, type PlanInsert, type PlanPatch, type PlanWriter, type Row, VERSION, applyPlanToCollections, applyPlanToDb };
package/dist/index.mjs CHANGED
@@ -1,6 +1 @@
1
- export { defineCollections } from './packem_shared/defineCollections-BAmslSrF.mjs';
2
- export { createOptimisticOnlineDetector, makeDiffEmit, runOutboxMutation, toMap } from './packem_shared/createOptimisticOnlineDetector-CRulWqZ7.mjs';
3
-
4
- const VERSION = "0.0.0";
5
-
6
- export { VERSION };
1
+ import{applyPlanToCollections as r,applyPlanToDb as i}from"./packem_shared/applyPlanToCollections-C29s6OoX.mjs";import{CHECKPOINT_FALLBACK_MS as a,createCheckpointRegistry as p,getShardCheckpoints as c,lunoraCollectionOptions as l,releaseShardCheckpoints as s,shardCheckpointStats as C}from"./packem_shared/CHECKPOINT_FALLBACK_MS-6GmTGWsH.mjs";import{defineCollections as T}from"./packem_shared/defineCollections-4CPSF9OJ.mjs";import{DIRECT_TRANSACTION_METADATA_KEY as u,bindMutators as x,defineMutator as A,initMutators as M}from"./packem_shared/DIRECT_TRANSACTION_METADATA_KEY-D1XuUJtq.mjs";import{OUTBOX_MUTATION_FN_NAME as E,createExecutorOutboxSink as _,createOptimisticOnlineDetector as h,makeDiffEmit as N,runOutboxMutation as S,toMap as d}from"./packem_shared/OUTBOX_MUTATION_FN_NAME-CebgkYw2.mjs";const t="0.0.0";export{a as CHECKPOINT_FALLBACK_MS,u as DIRECT_TRANSACTION_METADATA_KEY,E as OUTBOX_MUTATION_FN_NAME,t as VERSION,r as applyPlanToCollections,i as applyPlanToDb,x as bindMutators,p as createCheckpointRegistry,_ as createExecutorOutboxSink,h as createOptimisticOnlineDetector,T as defineCollections,A as defineMutator,c as getShardCheckpoints,M as initMutators,l as lunoraCollectionOptions,N as makeDiffEmit,s as releaseShardCheckpoints,S as runOutboxMutation,C as shardCheckpointStats,d as toMap};
@@ -0,0 +1,5 @@
1
+ export { type B as BindMutatorsContext, type a as BoundMutatorApi, type b as BoundMutators, type C as ClientMutatorContext, type c as ClientMutatorDef, type d as CollectionMap, type M as MutatorRejectedEvent, e as bindMutators, f as defineMutator, i as initMutators } from "../packem_shared/define-mutators.d-BXQN7fju.mjs";
2
+ import '@lunora/client';
3
+ import '@tanstack/db';
4
+ import "../packem_shared/collection-options.d-B_2IXvdU.mjs";
5
+ import '@tanstack/offline-transactions';
@@ -0,0 +1,5 @@
1
+ export { type B as BindMutatorsContext, type a as BoundMutatorApi, type b as BoundMutators, type C as ClientMutatorContext, type c as ClientMutatorDef, type d as CollectionMap, type M as MutatorRejectedEvent, e as bindMutators, f as defineMutator, i as initMutators } from "../packem_shared/define-mutators.d-DLP_vYu9.js";
2
+ import '@lunora/client';
3
+ import '@tanstack/db';
4
+ import "../packem_shared/collection-options.d-B_2IXvdU.js";
5
+ import '@tanstack/offline-transactions';
@@ -0,0 +1 @@
1
+ import{bindMutators as r,defineMutator as i,initMutators as a}from"../packem_shared/DIRECT_TRANSACTION_METADATA_KEY-D1XuUJtq.mjs";export{r as bindMutators,i as defineMutator,a as initMutators};
@@ -0,0 +1 @@
1
+ import{LunoraError as y}from"@lunora/errors";import{BTreeIndex as b}from"@tanstack/db";import{toMap as C,makeDiffEmit as T}from"./OUTBOX_MUTATION_FN_NAME-CebgkYw2.mjs";const I=()=>{let e=Number.NEGATIVE_INFINITY;const r=[];return{advance:o=>{if(!(o<=e)){e=o;for(let a=r.length-1;a>=0;a-=1){const i=r[a];i&&i.threshold<=e&&(i.resolve(),r.splice(a,1))}}},await:o=>o<=e?Promise.resolve():new Promise(a=>{r.push({resolve:a,threshold:o})}),passed:o=>o<=e,waiting:()=>r.length}},M=3e3;let g=!1;const N=e=>{g||(g=!0,console.warn(`[@lunora/db] released an optimistic overlay via the ${String(e.waitedMs)}ms checkpoint fallback: the server confirmed ${e.kind} ${String(e.watermark)} but no sync frame ever echoed it. A dropped shape poke or \`settled\` frame is the usual cause — inspect the subscription rather than raising \`fallbackMs\`. (Reported once per process.)`))},S=(e={})=>{const r=e.fallbackMs??M,o=e.onFallback??N,a=I(),i=I();let d=0;const c=new Set,l=(t,s)=>{for(const n of c)n.kind===t&&n.threshold<=s&&(clearTimeout(n.handle),c.delete(n))},p=(t,s)=>{if(r<=0)return;const n=t==="checkpoint"?a:i;if(n.passed(s))return;for(const k of c)if(k.kind===t&&k.threshold>=s)return;const u={handle:setTimeout(()=>{if(c.delete(u),!n.passed(s)){d+=1,n.advance(s);try{o({kind:t,waitedMs:r,watermark:s})}catch{}}},r),kind:t,threshold:s};c.add(u)};return{acknowledge:({checkpoint:t,mutationId:s})=>{t!==void 0&&p("checkpoint",t),s!==void 0&&p("mutationId",s)},awaitCheckpoint:t=>a.await(t),awaitMutationId:t=>i.await(t),dispose:()=>{for(const t of c)clearTimeout(t.handle);c.clear()},resolve:({checkpoint:t,mutationId:s})=>{t!==void 0&&(a.advance(t),l("checkpoint",t)),s!==void 0&&(i.advance(s),l("mutationId",s))},stats:()=>({fallbacks:d,pendingCheckpointWaiters:a.waiting(),pendingMutationWaiters:i.waiting()})}},m=new WeakMap,E=(e,r,o)=>{let a=m.get(e);a||(a=new Map,m.set(e,a));const i=r??"",d=a.get(i);if(d)return d;const c=S(o);return a.set(i,c),c},w=new WeakSet,K=e=>{w.add(e)},x=e=>w.has(e),F=e=>{const r=m.get(e);if(r){for(const o of r.values())o.resolve({checkpoint:Number.POSITIVE_INFINITY,mutationId:Number.POSITIVE_INFINITY}),o.dispose();m.delete(e)}},O=e=>{const r=m.get(e),o={};if(r)for(const[a,i]of r)o[a]=i.stats();return o},P=e=>{if(e.list===void 0==(e.shape===void 0))throw new y("INTERNAL","lunoraCollectionOptions: pass exactly one of `list` or `shape`");const r=e.getKey??(n=>n._id),o=e.checkpoints??E(e.client,e.shape?.shardKey??e.shardKey);K(o);const a=new Map;let i,d,c,l,p;const t=(n,u)=>{const k=h=>{i?.(C(h,r)),u?.(),e.shape===void 0&&(o.resolve({mutationId:p??e.client.confirmedMutationWatermark(e.shardKey)}),p=void 0)},f=h=>c?.(h),v=h=>{h.mutationId!==void 0&&(p=h.mutationId),o.resolve(h)};return e.shape!==void 0?e.client.subscribeShape({args:n,name:e.shape.name},k,{onCheckpoint:v,onError:f,shardKey:e.shape.shardKey}):e.client.subscribe(e.list,n,k,{onCheckpoint:v,onError:f,shardKey:e.shardKey})},s={autoIndex:"eager",defaultIndexType:b,getKey:r,id:e.id??e.list?.__lunoraRef??`shape:${e.shape?.name??""}`,...e.load==="eager"?{startSync:!0}:{},sync:{sync:n=>(i=T(a,n),c=u=>{n.markReady(),e.onError?.(u)},e.scopeBy===void 0?d=t(e.shape?.args??{},()=>{n.markReady()}):(n.markReady(),l!==void 0&&(d=t(l,void 0))),()=>{i=void 0,c=void 0,d?.(),d=void 0,a.clear()})}};return{checkpoints:o,config:s,scope:n=>{e.scopeBy!==void 0&&(l=n,d?.(),d=void 0,i?.(new Map),n!==void 0&&i!==void 0&&(d=t(n,void 0)))}}};export{M as CHECKPOINT_FALLBACK_MS,S as createCheckpointRegistry,E as getShardCheckpoints,x as hasCheckpointsAttached,P as lunoraCollectionOptions,K as markCheckpointsAttached,F as releaseShardCheckpoints,O as shardCheckpointStats};
@@ -0,0 +1 @@
1
+ import{LunoraError as d}from"@lunora/errors";import{createTransaction as w}from"@tanstack/db";import{getShardCheckpoints as M,hasCheckpointsAttached as g}from"./CHECKPOINT_FALLBACK_MS-6GmTGWsH.mjs";import{runOutboxMutation as _}from"./OUTBOX_MUTATION_FN_NAME-CebgkYw2.mjs";const v="__tanstack_db_direct",k="defineMutator: `serverRef` must be a generated mutator reference (api.mutators.*) or a 'namespace:fn' string",T=r=>{const t=typeof r=="string"?r:r?.__lunoraRef;if(typeof t!="string"||t.length===0)throw new d("INTERNAL",k);return t},A=r=>({__lunoraClientMutator:!0,apply:r.apply,serverRef:T(r.serverRef)}),E=(r,t,l)=>{let i=0;const h=()=>(i=Math.max(i,r.confirmedMutationWatermark(t.shardKey))+1,i);let u=Promise.resolve();const p=(o,a)=>{const s=u.then(async()=>{for(let c=0;;c+=1){const n=h();try{const{applied:e}=await r.callMutator(o,a,{clientSeq:n,shardKey:t.shardKey});if(e)return n}catch(e){throw i=r.confirmedMutationWatermark(t.shardKey),e}if(c>=32)throw new d("INTERNAL",`lunora: custom mutator "${o}" could not claim a fresh client sequence after ${String(32)} attempts`)}});return u=s.then(()=>{},()=>{}),s},y=()=>{if(t.checkpoints===!1)return;if(t.checkpoints)return t.checkpoints;const o=M(r,t.shardKey);return g(o)?o:void 0},R=(o,a,s,c)=>{const{onWriteRejected:n}=t;n&&o.isPersisted.promise.catch(e=>{const m=e instanceof Error?e:new Error(String(e));try{n({args:c,code:m.code,error:m,mutator:a,serverRef:s})}catch{}})},f={};for(const[o,a]of Object.entries(l))f[o]=s=>{const c=w({autoCommit:!0,metadata:{[v]:!0,serverRef:a.serverRef},mutationFn:async()=>{let n=0;await _(async()=>{n=await p(a.serverRef,s)});const e=y();e&&(e.acknowledge({mutationId:n}),await e.awaitMutationId(n))}});return c.mutate(()=>{a.apply({collections:t.collections},s)}),R(c,o,a.serverRef,s),c};return f},b=()=>({bindMutators:E,defineMutator:A});export{v as DIRECT_TRANSACTION_METADATA_KEY,E as bindMutators,A as defineMutator,b as initMutators};
@@ -0,0 +1 @@
1
+ import{createCollection as f,safeRandomUUID as m}from"@tanstack/db";import{NonRetriableError as y}from"@tanstack/offline-transactions";const p=1e3,d="__lunora_outbox__",l=new WeakMap,w=()=>f({getKey:e=>e._id,id:`${d}:${m()}`,startSync:!0,sync:{sync:e=>(e.markReady(),()=>{})}}),x=(e,t)=>{l.set(e,t)};let u=!1;const g=(e,t={})=>{const r=t.maxItems??1e3,n=t.mutationFnName??d;return{enqueue(o){if(e.getPendingCount()>=r){const c=new Error("offline outbox is full");return c.code="OFFLINE_QUEUE_OVERFLOW",Promise.reject(c)}const i={args:o.args,clientId:o.clientId,functionPath:o.functionPath,idempotencyKey:o.idempotencyKey,identity:o.identity,mutationId:o.mutationId,shardKey:o.shardKey},a=l.get(e);!a&&!u&&(u=!0,console.warn("[@lunora/db] createExecutorOutboxSink: no outbox carrier is registered for this executor. On a TanStack OfflineExecutor a zero-mutation transaction is silently dropped, so offline writes may be lost — wire the sink to the executor returned by defineCollections()."));const s=e.createOfflineTransaction({autoCommit:!1,idempotencyKey:o.idempotencyKey,metadata:i,mutationFnName:n});return s.mutate(()=>{a?.insert({_id:o.idempotencyKey})}),s.commit?.().catch(()=>{}),Promise.resolve()}}},h=(e,t)=>{const r=new Map;for(const n of e)r.set(t(n),n);return r},E=(e,t)=>r=>{t.begin();const n=new Map;for(const[o,i]of r){const a=JSON.stringify(i);n.set(o,a),e.has(o)?e.get(o)!==a&&t.write({type:"update",value:i}):t.write({type:"insert",value:i})}for(const o of e.keys())r.has(o)||t.write({key:o,type:"delete"});t.commit(),e.clear();for(const[o,i]of n)e.set(o,i)},_=async e=>{try{await e()}catch(t){if(typeof t.code=="string"){const r=new y(t instanceof Error?t.message:String(t));throw r.code=t.code,r}throw t}},I=()=>{const e=new Set;return{dispose:()=>{for(const t of e)clearInterval(t);e.clear()},isOnline:()=>!0,notifyOnline:()=>{},subscribe:t=>{const r=setInterval(t,p);return e.add(r),()=>{clearInterval(r),e.delete(r)}}}};export{d as OUTBOX_MUTATION_FN_NAME,g as createExecutorOutboxSink,I as createOptimisticOnlineDetector,w as createOutboxCarrier,E as makeDiffEmit,x as registerOutboxCarrier,_ as runOutboxMutation,h as toMap};
@@ -0,0 +1 @@
1
+ const l=t=>({deletes:t.deletes??[],inserts:t.inserts??[],patches:t.patches??[]}),r=(t,o)=>{const{deletes:n,inserts:i,patches:a}=l(o);for(const e of n)t[e.table]?.delete(e.id);for(const e of a){const s=t[e.table];s&&s.update(e.id,c=>{Object.assign(c,e.fields)})}for(const e of i){const s=t[e.table];if(s){if(typeof e.row._id!="string")throw new TypeError(`applyPlanToCollections: insert into "${e.table}" needs an "_id" — mint it client-side so the optimistic row keys match the persisted one`);s.insert(e.row)}}},d=async(t,o)=>{const{deletes:n,inserts:i,patches:a}=l(o);for(const e of n)await t.delete(e.id);for(const e of a)await t.patch(e.id,e.fields);for(const e of i){const{_id:s,...c}=e.row;await t.insert(e.table,c,...typeof s=="string"?[{clientId:s}]:[])}};export{r as applyPlanToCollections,d as applyPlanToDb};