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

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,166 @@
1
+ import { FunctionReference, SubscriptionError, LunoraClient } from '@lunora/client';
2
+ import { Transaction, Collection } from '@tanstack/db';
3
+ import { OfflineExecutor, StorageDiagnostic } from '@tanstack/offline-transactions';
4
+ import { R as Row } from "./collection-options.d-B_2IXvdU.mjs";
5
+ /** Element type of an array (the row type a `list` query returns). */
6
+ type Element<T> = T extends ReadonlyArray<infer E> ? E : never;
7
+ /** `true` for the `any` type, `false` otherwise. */
8
+ type IsAny<T> = 0 extends 1 & T ? true : false;
9
+ /**
10
+ * The row type a `list` query syncs. For `TList = any` (the heterogeneous-map
11
+ * constraint) it resolves to the permissive {@link Row}, not `never` — otherwise
12
+ * the constraint would force every `optimistic` to return `never`. For a concrete
13
+ * `FunctionReference` it's the element type of the query's array return.
14
+ */
15
+ type RowOfList<TList> = IsAny<TList> extends true ? Row : TList extends FunctionReference<infer _K, infer _A, infer R> ? Element<R> & Row : never;
16
+ /** Maps a write through the durable outbox: optimistic insert + a retried mutation. */
17
+ interface InsertBinding<TRow extends Row, TInput> {
18
+ /** The Lunora mutation that persists the row. */
19
+ mutation: FunctionReference;
20
+ /** Build the optimistic row to insert from the action input + the generated client id. */
21
+ optimistic: (input: TInput, id: string) => TRow;
22
+ /** Build the mutation args from the persisted optimistic row (forward `_id` as the `clientId`). */
23
+ toArgs: (row: TRow) => Record<string, unknown>;
24
+ }
25
+ /** Declarative binding of a Lunora table to a live collection (+ optional write action). */
26
+ interface CollectionDef<TList extends FunctionReference, TInput = never> {
27
+ /** Row key extractor — defaults to `row._id`. */
28
+ getKey?: (row: RowOfList<TList>) => string;
29
+ /** Optional write binding — present iff this collection is written through the outbox. */
30
+ insert?: InsertBinding<RowOfList<TList>, TInput>;
31
+ /** The Lunora query that lists the rows (the sync source). */
32
+ list: TList;
33
+ /**
34
+ * When this collection starts syncing — `"lazy"` (default) on the first
35
+ * `useLiveQuery` subscriber, or `"eager"` at creation, for small "instant"
36
+ * reference data you want warm at boot. Pairs with `scopeBy` for partial
37
+ * (per-scope) loading — together they give the full lazy/partial/eager
38
+ * (Linear `lazy`/`partial`/`instant`) load taxonomy declaratively. No effect
39
+ * on a `scopeBy` collection (nothing to sync until scoped).
40
+ */
41
+ load?: "eager" | "lazy";
42
+ /**
43
+ * Notified when the underlying `list` subscription errors (e.g. the server
44
+ * rejects it). Without this the error would be swallowed and the collection
45
+ * could hang in `loading`; the binding always moves the collection out of
46
+ * `loading` on error, and forwards the error here if supplied.
47
+ */
48
+ onError?: (error: SubscriptionError) => void;
49
+ /** A field that scopes the list (e.g. a shard key); makes the collection re-pointable via `scope`. */
50
+ scopeBy?: string;
51
+ /**
52
+ * Routes the `list` subscription (and the confirmed-mutation watermark its
53
+ * frames advance the checkpoint gate from) to a specific shard's DO — so a
54
+ * sharded collection's overlay gate compares against that shard's mutator
55
+ * sequence line, not the default ("") watermark bucket.
56
+ */
57
+ shardKey?: string;
58
+ }
59
+ type AnyDef = CollectionDef<any, any>;
60
+ /**
61
+ * The public row type a collection exposes — the element type of its `list`
62
+ * query's return, with no `& Row`: a `Collection<T>` is invariant in `T`, so the
63
+ * exposed type must be exactly the document type, not a subtype.
64
+ */
65
+ type RowOf<C extends AnyDef> = C["list"] extends FunctionReference<infer _K, infer _A, infer R> ? Element<R> : never;
66
+ /** The action input type, inferred structurally from the def's optimistic insert. */
67
+ type InputOf<C> = C extends {
68
+ insert: {
69
+ optimistic: (input: infer I, id: string) => unknown;
70
+ };
71
+ } ? I : never;
72
+ /** A queued write that was permanently dropped, passed to {@link DefineCollectionsOptions.onWriteRejected}. */
73
+ interface WriteRejectedEvent {
74
+ /**
75
+ * The machine-readable reason — the server's error `code` (e.g. `CONFLICT`,
76
+ * `FORBIDDEN`), or `UNKNOWN_MUTATION_FN` when the write referenced a collection
77
+ * that no longer exists (removed in a deploy). Mirrors the client's
78
+ * `MutationSettledEvent.code` so a consumer can branch on the verdict.
79
+ */
80
+ code?: string;
81
+ /** The collection/table name the write targeted. */
82
+ collection: string;
83
+ /** The error that dropped the write (message carried by the underlying `NonRetriableError`). */
84
+ error: Error;
85
+ /**
86
+ * The optimistic row being rolled back (the rollback follows the callback).
87
+ * Absent only if the dropped transaction carried no recoverable row (e.g. some
88
+ * `UNKNOWN_MUTATION_FN` cases).
89
+ */
90
+ row?: Row;
91
+ }
92
+ /** Options for {@link defineCollections}. */
93
+ interface DefineCollectionsOptions {
94
+ /**
95
+ * Invoked when a leadership change occurs across tabs (only the leader tab
96
+ * drains the durable outbox). Informational — useful for diagnostics; the
97
+ * library handles the election itself.
98
+ */
99
+ onLeadershipChange?: (isLeader: boolean) => void;
100
+ /**
101
+ * Invoked when the durable outbox's storage layer fails — IndexedDB
102
+ * unavailable (private mode), blocked, or quota exceeded. The standalone
103
+ * client surfaces this via `offlineQueue.onPersistenceError`; this is the
104
+ * collection-layer counterpart. A storage failure means a write is NOT durable
105
+ * and won't survive a reload, so surface it (e.g. "your change may not be
106
+ * saved if you close this tab").
107
+ */
108
+ onStorageFailure?: (diagnostic: StorageDiagnostic) => void;
109
+ /**
110
+ * Invoked as a queued write is permanently dropped: a coded application error
111
+ * from the server (validation, RLS denial, conflict, surfaced as a
112
+ * `NonRetriableError`), OR a write whose target collection no longer exists —
113
+ * removed/renamed in a deploy (`code: "UNKNOWN_MUTATION_FN"`). This is the
114
+ * aggregate, fire-and-forget-safe channel: unlike awaiting the per-action
115
+ * `transaction` returned by `actions[name](...)`, it fires even when the caller
116
+ * never retained that handle, so a UI can surface "couldn't save" instead of a
117
+ * silently vanishing row. Transient failures (offline, 5xx) are retried by the
118
+ * outbox, not reported here.
119
+ *
120
+ * Timing: the callback runs at the point of rejection; the executor's
121
+ * optimistic-row rollback follows immediately after. The event's `row` is the
122
+ * (about-to-be-removed) optimistic row, so don't depend on the collection
123
+ * already reflecting the removal from inside the handler — use `row`/`error`
124
+ * directly (e.g. for a toast).
125
+ */
126
+ onWriteRejected?: (event: WriteRejectedEvent) => void;
127
+ }
128
+ /** The wired data layer `defineCollections` returns. */
129
+ interface LunoraDb<D extends Record<string, AnyDef>> {
130
+ /** Optimistic, durable, retried write actions — present for `insert` collections. */
131
+ actions: { [K in keyof D]: D[K] extends {
132
+ insert: object;
133
+ } ? (input: InputOf<D[K]>) => {
134
+ id: string;
135
+ transaction: Transaction;
136
+ } : never; };
137
+ /** The live, synced collections — feed these to `useLiveQuery`. */
138
+ collections: { [K in keyof D]: Collection<RowOf<D[K]>, string>; };
139
+ /** The shared offline executor (the outbox). */
140
+ executor: OfflineExecutor;
141
+ /**
142
+ * Number of writes still pending in the durable outbox — the depth for a
143
+ * "N changes waiting to sync" indicator. A convenience over reaching through
144
+ * `executor.getPendingCount()`. **Pull-only** (the underlying TanStack
145
+ * executor exposes no change subscription): read it after a `db.actions.*`
146
+ * call and on connection-status changes, or poll. The standalone
147
+ * `LunoraClient` exposes the reactive `onPendingChange` for its built-in queue.
148
+ */
149
+ pendingCount: () => number;
150
+ /** Re-point a `scopeBy` collection's subscription (omit `args` to detach) — present for scoped collections. */
151
+ scope: { [K in keyof D]: D[K] extends {
152
+ scopeBy: string;
153
+ } ? (args?: Record<string, unknown>) => void : never; };
154
+ }
155
+ /**
156
+ * Wire a set of Lunora tables into a TanStack DB data layer in one declaration:
157
+ * each entry becomes a live, auto-indexed collection synced from its `list` query,
158
+ * and `insert` entries get an optimistic write action backed by the
159
+ * offline-transactions outbox (durable, retried, client-id-keyed). Scoped
160
+ * (`scopeBy`) collections are re-pointable for sharded queries.
161
+ *
162
+ * This is the hand-written form; `@lunora/codegen` can emit a fully-typed call to
163
+ * it from `schema.ts`, so an app writes nothing.
164
+ */
165
+ declare const defineCollections: <D extends Record<string, AnyDef>>(client: LunoraClient, defs: D, options?: DefineCollectionsOptions) => LunoraDb<D>;
166
+ export { CollectionDef as C, DefineCollectionsOptions as D, InsertBinding as I, LunoraDb as L, WriteRejectedEvent as W, defineCollections as d };
@@ -0,0 +1,235 @@
1
+ import { LunoraClient } from '@lunora/client';
2
+ import { Collection, Transaction } from '@tanstack/db';
3
+ import { C as CheckpointRegistry } from "./collection-options.d-B_2IXvdU.mjs";
4
+ /**
5
+ * TanStack DB's "direct transaction" marker.
6
+ *
7
+ * A completed transaction's optimistic rows are discarded as **stale** unless
8
+ * either a synced transaction for the same key is already queued, or the
9
+ * transaction carried this flag (`CollectionStateManager.recomputeOptimisticState`
10
+ * → `pendingOptimisticDirectUpserts`). Marked rows instead survive until a sync
11
+ * operation for that key actually lands, which is precisely the semantics a Lunora
12
+ * custom mutator needs: the server is the linearization point, so the prediction
13
+ * must stay visible until the authoritative row arrives. Without it, a text edit
14
+ * visibly reverts to the last synced value the moment the push is acked.
15
+ *
16
+ * The literal is pinned here because `@tanstack/db` does not re-export the constant
17
+ * from its package root (it lives in the unexported
18
+ * `collection/transaction-metadata` module). `__tests__/define-mutators.test.ts`
19
+ * reads that module off disk and fails if the upstream value ever changes.
20
+ */
21
+ declare const DIRECT_TRANSACTION_METADATA_KEY = "__tanstack_db_direct";
22
+ /**
23
+ * A map of wired collections, keyed by the name the optimistic bodies address them
24
+ * by.
25
+ *
26
+ * The row type is `any` on purpose. `Collection<T, string>` is **invariant** in
27
+ * `T` (its methods both consume and produce rows), so a concrete
28
+ * `Collection<Doc<"nodes"> & Row>` is NOT assignable to `Collection<Row>` — which
29
+ * is why the previous `Record<string, Collection<Row, string>>` forced an
30
+ * `as never` cast on every entry, and left `context.collections.<name>` too
31
+ * untyped to be worth using. Projects recover the concrete types by binding them
32
+ * once through {@link initMutators}.
33
+ */
34
+ type CollectionMap = Record<string, Collection<any, string>>;
35
+ /** The local store a client mutator's optimistic body writes against. */
36
+ interface ClientMutatorContext<TCollections extends CollectionMap = CollectionMap> {
37
+ /** The wired collections, keyed by name — apply optimistic inserts/updates/deletes here. */
38
+ collections: TCollections;
39
+ }
40
+ /**
41
+ * A generated mutator reference (`api.mutators.sendMessage`), accepted by
42
+ * {@link defineMutator} in place of a hand-written path string.
43
+ *
44
+ * Declared structurally rather than imported from `@lunora/client` so this module
45
+ * keeps its narrow dependency surface; the shape matches `FunctionReference`, and
46
+ * the phantom marker carries the server mutator's arg type so the client body's
47
+ * args are **inferred** instead of restated.
48
+ */
49
+ interface MutatorReference<TArgs = unknown> {
50
+ readonly __lunoraPhantom?: {
51
+ args: TArgs;
52
+ kind: unknown;
53
+ returns: unknown;
54
+ };
55
+ readonly __lunoraRef: string;
56
+ }
57
+ /** A client-side custom mutator: an optimistic body plus the path of its authoritative server impl. */
58
+ interface ClientMutatorDef<TArgs, TCollections extends CollectionMap = CollectionMap> {
59
+ /** Brand so codegen / `bindMutators` can recognize a mutator definition. */
60
+ __lunoraClientMutator: true;
61
+ /** The optimistic update applied to the local collections before the server confirms. */
62
+ apply: (context: ClientMutatorContext<TCollections>, args: TArgs) => void;
63
+ /** The Lunora function path of the server-authoritative mutator (`defineMutator` on the server). */
64
+ serverRef: string;
65
+ }
66
+ /**
67
+ * Declare a client-side custom mutator. `apply` runs optimistically against the
68
+ * local TanStack collections; `serverRef` names the authoritative server mutator
69
+ * the write is pushed to over the watermark protocol. The server impl is the
70
+ * linearization point — this body is a prediction the server can override.
71
+ *
72
+ * **Pass a generated reference, not a string.** `serverRef: api.mutators.sendMessage`
73
+ * both binds the path at compile time — a rename, a typo, or a moved file becomes a
74
+ * type error instead of a mutation that silently fails at runtime — and **infers
75
+ * `TArgs` from the server mutator's own validators**, so the arg type is declared
76
+ * once on the server rather than restated in every client body:
77
+ *
78
+ * ```ts
79
+ * // Typed + checked: args inferred from the server mutator.
80
+ * defineMutator({
81
+ * apply: ({ collections }, args) => { … }, // args: { channelId: Id<"channels">; text: string }
82
+ * serverRef: api.mutators.sendMessage,
83
+ * });
84
+ *
85
+ * // Escape hatch: a path string still works, but nothing checks it and you must
86
+ * // restate the args yourself.
87
+ * defineMutator<{ text: string }>({ apply, serverRef: "mutators:sendMessage" });
88
+ * ```
89
+ *
90
+ * `context.collections` is typed by whatever map it is bound to — which for this
91
+ * standalone form is the widest one. Bind the concrete collections once with
92
+ * {@link initMutators} to get `collections.<name>` typed inside `apply`.
93
+ */
94
+ declare const defineMutator: {
95
+ <TArgs = Record<string, unknown>>(definition: {
96
+ apply: (context: ClientMutatorContext, args: TArgs) => void;
97
+ serverRef: string;
98
+ }): ClientMutatorDef<TArgs>;
99
+ <TArgs>(definition: {
100
+ apply: (context: ClientMutatorContext, args: TArgs) => void;
101
+ serverRef: MutatorReference<TArgs>;
102
+ }): ClientMutatorDef<TArgs>;
103
+ };
104
+ /**
105
+ * A mutator map whose defs are bound to `TCollections`, arg types erased — the
106
+ * `TCollections`-pinned counterpart of {@link AnyMutatorMap}, and `any` for the
107
+ * same variance reason.
108
+ */
109
+ type MutatorMapFor<TCollections extends CollectionMap> = Record<string, ClientMutatorDef<any, TCollections>>;
110
+ type AnyMutatorMap = Record<string, ClientMutatorDef<any, any>>;
111
+ /** Args type of a mutator definition, whatever collections map it was bound to. */
112
+ type ArgsOf<M> = M extends ClientMutatorDef<infer A, infer _C> ? A : never;
113
+ /** A mutator write that was permanently rejected, passed to {@link BindMutatorsContext.onWriteRejected}. */
114
+ interface MutatorRejectedEvent {
115
+ /** The args the rejected call was made with. */
116
+ args: unknown;
117
+ /**
118
+ * The machine-readable reason when the server supplied one (e.g. `CONFLICT`,
119
+ * `FORBIDDEN`). Mirrors {@link import("./define-collections").WriteRejectedEvent.code}.
120
+ */
121
+ code?: string;
122
+ /** The error that rejected the write. */
123
+ error: Error;
124
+ /** The bound mutator's key in the map passed to `bindMutators`. */
125
+ mutator: string;
126
+ /** The `namespace:fn` path the push targeted. */
127
+ serverRef: string;
128
+ }
129
+ interface BindMutatorsContext<TCollections extends CollectionMap = CollectionMap> {
130
+ /**
131
+ * Resolves the optimistic-overlay drop against confirmed server watermarks: a
132
+ * mutation's overlay is held until the sync stream echoes
133
+ * `lastMutationId >= clientSeq` (via {@link CheckpointRegistry.resolve}), so the
134
+ * row never flashes out and back.
135
+ *
136
+ * Defaults to the shared per-shard registry for `client` + {@link shardKey}
137
+ * ({@link getShardCheckpoints}) — the same one
138
+ * {@link import("./collection-options").lunoraCollectionOptions} defaults to, so
139
+ * a shard's collections and its mutators gate on one watermark line without the
140
+ * caller wiring them together. Pass `false` to drop the overlay as soon as the
141
+ * server accepts the write (the by-value sync diff then converges the synced row
142
+ * in place).
143
+ */
144
+ checkpoints?: CheckpointRegistry | false;
145
+ /** The wired collections the optimistic bodies write against. */
146
+ collections: TCollections;
147
+ /**
148
+ * Called when a mutator's server push is permanently rejected — the aggregate
149
+ * failure channel for writes, symmetric with
150
+ * {@link import("./define-collections").DefineCollectionsOptions.onWriteRejected}
151
+ * on the outbox path.
152
+ *
153
+ * Supplying it also makes a **fire-and-forget** call safe: a bound handle
154
+ * returns a `Transaction` whose `isPersisted` promise rejects on failure, so a
155
+ * caller that neither awaits it nor attaches a `.catch` leaves an unhandled
156
+ * rejection. With this hook set, `bindMutators` consumes that rejection itself
157
+ * after reporting it — a caller that DOES await still sees the rejection, so
158
+ * per-call handling is unaffected.
159
+ *
160
+ * A throwing listener is swallowed: reporting a failure must not manufacture a
161
+ * second one.
162
+ */
163
+ onWriteRejected?: (event: MutatorRejectedEvent) => void;
164
+ /** Optional shard key the mutator's server push is routed to. */
165
+ shardKey?: string;
166
+ }
167
+ /** Calling a bound mutator runs the optimistic body + pushes the server write; returns the TanStack transaction. */
168
+ type BoundMutators<M extends AnyMutatorMap> = { [K in keyof M]: (args: ArgsOf<M[K]>) => Transaction; };
169
+ /**
170
+ * Bind a set of client mutators to a client + local store. Each returned handle,
171
+ * when called, opens a TanStack optimistic transaction: the mutator's `apply`
172
+ * body writes the predicted rows into the collections, and the transaction's
173
+ * `mutationFn` pushes the authoritative write through
174
+ * {@link LunoraClient.callMutator} under a monotonic per-client `clientSeq`.
175
+ *
176
+ * Rebase-on-poke is free — TanStack DB re-derives every pending optimistic overlay
177
+ * over the latest synced base on each sync tick. The overlay is dropped when the
178
+ * server confirms the write (and, if `checkpoints` is supplied, once it echoes the
179
+ * matching watermark so the synced row has landed).
180
+ *
181
+ * The `clientSeq` generator is seeded from the server's echoed watermark
182
+ * ({@link LunoraClient.confirmedMutationWatermark}) on every issue, so a reload —
183
+ * which resets this in-memory counter while the server keeps a durable per-client
184
+ * watermark — never reissues a sequence the DO has already applied. As a backstop
185
+ * for the very first push of a fresh session (before any ack has taught the client
186
+ * the watermark), a push the DO swallows as a replay (`applied === false`) is
187
+ * reissued above the now-known watermark instead of being mistaken for a confirmed
188
+ * write — closing the silent-drop window without risking a double-apply (a fresh
189
+ * session's first stale push provably cannot be an honest replay).
190
+ *
191
+ * Pushes are **serialized per binding** (a FIFO chain): the DO rejects any push
192
+ * with `clientSeq > watermark + 1` as `OUT_OF_ORDER` and drops the write, so two
193
+ * mutators fired concurrently must not race the network into a gap. Each push
194
+ * waits for the previous one's ack and assigns its `clientSeq` *inside* the
195
+ * critical section — from the live watermark — so the sequence is always exactly
196
+ * `watermark + 1`. Because a failed mutation never advances the server watermark,
197
+ * a permanently-rejected predecessor can't wedge the chain: the next push simply
198
+ * reclaims the same `watermark + 1` instead of leaving a hole the DO waits on.
199
+ */
200
+ declare const bindMutators: <M extends AnyMutatorMap, TCollections extends CollectionMap = CollectionMap>(client: LunoraClient, context: BindMutatorsContext<TCollections>, mutators: M) => BoundMutators<M>;
201
+ /** `defineMutator` + `bindMutators`, both bound to one project's collections map. */
202
+ interface BoundMutatorApi<TCollections extends CollectionMap> {
203
+ /** {@link bindMutators}, with `collections` pinned to `TCollections`. */
204
+ bindMutators: <M extends MutatorMapFor<TCollections>>(client: LunoraClient, context: BindMutatorsContext<TCollections>, mutators: M) => BoundMutators<M>;
205
+ /** {@link defineMutator}, with `context.collections` typed as `TCollections`. */
206
+ defineMutator: {
207
+ <TArgs = Record<string, unknown>>(definition: {
208
+ apply: (context: ClientMutatorContext<TCollections>, args: TArgs) => void;
209
+ serverRef: string;
210
+ }): ClientMutatorDef<TArgs, TCollections>;
211
+ <TArgs>(definition: {
212
+ apply: (context: ClientMutatorContext<TCollections>, args: TArgs) => void;
213
+ serverRef: MutatorReference<TArgs>;
214
+ }): ClientMutatorDef<TArgs, TCollections>;
215
+ };
216
+ }
217
+ /**
218
+ * Bind the mutator surface to **this project's** collections map, once.
219
+ *
220
+ * `Collection` is invariant in its row type, so a shared
221
+ * `Record<string, Collection<Row, string>>` could neither accept a generated
222
+ * collection without an `as never` cast nor hand a usable type back to `apply` —
223
+ * which is why optimistic bodies tended to ignore `context.collections` and close
224
+ * over module-scope collection variables instead. Declaring the map once here
225
+ * fixes both ends: `bindMutators` takes the concrete collections cast-free, and
226
+ * `apply` reads `collections.<name>` at its real row type.
227
+ *
228
+ * Runtime-identical to the standalone {@link defineMutator} / {@link bindMutators}
229
+ * (this returns those very functions); only the types narrow.
230
+ * @example
231
+ * const { bindMutators, defineMutator } = initMutators<{ nodes: typeof wholeOutlineCollection }>();
232
+ * const setText = defineMutator({ apply: ({ collections }, args) => collections.nodes.update(args.id, setter), serverRef: api.mutators.setText });
233
+ */
234
+ declare const initMutators: <TCollections extends CollectionMap>() => BoundMutatorApi<TCollections>;
235
+ export { BindMutatorsContext as B, ClientMutatorContext as C, DIRECT_TRANSACTION_METADATA_KEY as D, MutatorRejectedEvent as M, BoundMutatorApi as a, BoundMutators as b, ClientMutatorDef as c, CollectionMap as d, bindMutators as e, defineMutator as f, MutatorReference as g, initMutators as i };
@@ -0,0 +1,235 @@
1
+ import { LunoraClient } from '@lunora/client';
2
+ import { Collection, Transaction } from '@tanstack/db';
3
+ import { C as CheckpointRegistry } from "./collection-options.d-B_2IXvdU.js";
4
+ /**
5
+ * TanStack DB's "direct transaction" marker.
6
+ *
7
+ * A completed transaction's optimistic rows are discarded as **stale** unless
8
+ * either a synced transaction for the same key is already queued, or the
9
+ * transaction carried this flag (`CollectionStateManager.recomputeOptimisticState`
10
+ * → `pendingOptimisticDirectUpserts`). Marked rows instead survive until a sync
11
+ * operation for that key actually lands, which is precisely the semantics a Lunora
12
+ * custom mutator needs: the server is the linearization point, so the prediction
13
+ * must stay visible until the authoritative row arrives. Without it, a text edit
14
+ * visibly reverts to the last synced value the moment the push is acked.
15
+ *
16
+ * The literal is pinned here because `@tanstack/db` does not re-export the constant
17
+ * from its package root (it lives in the unexported
18
+ * `collection/transaction-metadata` module). `__tests__/define-mutators.test.ts`
19
+ * reads that module off disk and fails if the upstream value ever changes.
20
+ */
21
+ declare const DIRECT_TRANSACTION_METADATA_KEY = "__tanstack_db_direct";
22
+ /**
23
+ * A map of wired collections, keyed by the name the optimistic bodies address them
24
+ * by.
25
+ *
26
+ * The row type is `any` on purpose. `Collection<T, string>` is **invariant** in
27
+ * `T` (its methods both consume and produce rows), so a concrete
28
+ * `Collection<Doc<"nodes"> & Row>` is NOT assignable to `Collection<Row>` — which
29
+ * is why the previous `Record<string, Collection<Row, string>>` forced an
30
+ * `as never` cast on every entry, and left `context.collections.<name>` too
31
+ * untyped to be worth using. Projects recover the concrete types by binding them
32
+ * once through {@link initMutators}.
33
+ */
34
+ type CollectionMap = Record<string, Collection<any, string>>;
35
+ /** The local store a client mutator's optimistic body writes against. */
36
+ interface ClientMutatorContext<TCollections extends CollectionMap = CollectionMap> {
37
+ /** The wired collections, keyed by name — apply optimistic inserts/updates/deletes here. */
38
+ collections: TCollections;
39
+ }
40
+ /**
41
+ * A generated mutator reference (`api.mutators.sendMessage`), accepted by
42
+ * {@link defineMutator} in place of a hand-written path string.
43
+ *
44
+ * Declared structurally rather than imported from `@lunora/client` so this module
45
+ * keeps its narrow dependency surface; the shape matches `FunctionReference`, and
46
+ * the phantom marker carries the server mutator's arg type so the client body's
47
+ * args are **inferred** instead of restated.
48
+ */
49
+ interface MutatorReference<TArgs = unknown> {
50
+ readonly __lunoraPhantom?: {
51
+ args: TArgs;
52
+ kind: unknown;
53
+ returns: unknown;
54
+ };
55
+ readonly __lunoraRef: string;
56
+ }
57
+ /** A client-side custom mutator: an optimistic body plus the path of its authoritative server impl. */
58
+ interface ClientMutatorDef<TArgs, TCollections extends CollectionMap = CollectionMap> {
59
+ /** Brand so codegen / `bindMutators` can recognize a mutator definition. */
60
+ __lunoraClientMutator: true;
61
+ /** The optimistic update applied to the local collections before the server confirms. */
62
+ apply: (context: ClientMutatorContext<TCollections>, args: TArgs) => void;
63
+ /** The Lunora function path of the server-authoritative mutator (`defineMutator` on the server). */
64
+ serverRef: string;
65
+ }
66
+ /**
67
+ * Declare a client-side custom mutator. `apply` runs optimistically against the
68
+ * local TanStack collections; `serverRef` names the authoritative server mutator
69
+ * the write is pushed to over the watermark protocol. The server impl is the
70
+ * linearization point — this body is a prediction the server can override.
71
+ *
72
+ * **Pass a generated reference, not a string.** `serverRef: api.mutators.sendMessage`
73
+ * both binds the path at compile time — a rename, a typo, or a moved file becomes a
74
+ * type error instead of a mutation that silently fails at runtime — and **infers
75
+ * `TArgs` from the server mutator's own validators**, so the arg type is declared
76
+ * once on the server rather than restated in every client body:
77
+ *
78
+ * ```ts
79
+ * // Typed + checked: args inferred from the server mutator.
80
+ * defineMutator({
81
+ * apply: ({ collections }, args) => { … }, // args: { channelId: Id<"channels">; text: string }
82
+ * serverRef: api.mutators.sendMessage,
83
+ * });
84
+ *
85
+ * // Escape hatch: a path string still works, but nothing checks it and you must
86
+ * // restate the args yourself.
87
+ * defineMutator<{ text: string }>({ apply, serverRef: "mutators:sendMessage" });
88
+ * ```
89
+ *
90
+ * `context.collections` is typed by whatever map it is bound to — which for this
91
+ * standalone form is the widest one. Bind the concrete collections once with
92
+ * {@link initMutators} to get `collections.<name>` typed inside `apply`.
93
+ */
94
+ declare const defineMutator: {
95
+ <TArgs = Record<string, unknown>>(definition: {
96
+ apply: (context: ClientMutatorContext, args: TArgs) => void;
97
+ serverRef: string;
98
+ }): ClientMutatorDef<TArgs>;
99
+ <TArgs>(definition: {
100
+ apply: (context: ClientMutatorContext, args: TArgs) => void;
101
+ serverRef: MutatorReference<TArgs>;
102
+ }): ClientMutatorDef<TArgs>;
103
+ };
104
+ /**
105
+ * A mutator map whose defs are bound to `TCollections`, arg types erased — the
106
+ * `TCollections`-pinned counterpart of {@link AnyMutatorMap}, and `any` for the
107
+ * same variance reason.
108
+ */
109
+ type MutatorMapFor<TCollections extends CollectionMap> = Record<string, ClientMutatorDef<any, TCollections>>;
110
+ type AnyMutatorMap = Record<string, ClientMutatorDef<any, any>>;
111
+ /** Args type of a mutator definition, whatever collections map it was bound to. */
112
+ type ArgsOf<M> = M extends ClientMutatorDef<infer A, infer _C> ? A : never;
113
+ /** A mutator write that was permanently rejected, passed to {@link BindMutatorsContext.onWriteRejected}. */
114
+ interface MutatorRejectedEvent {
115
+ /** The args the rejected call was made with. */
116
+ args: unknown;
117
+ /**
118
+ * The machine-readable reason when the server supplied one (e.g. `CONFLICT`,
119
+ * `FORBIDDEN`). Mirrors {@link import("./define-collections").WriteRejectedEvent.code}.
120
+ */
121
+ code?: string;
122
+ /** The error that rejected the write. */
123
+ error: Error;
124
+ /** The bound mutator's key in the map passed to `bindMutators`. */
125
+ mutator: string;
126
+ /** The `namespace:fn` path the push targeted. */
127
+ serverRef: string;
128
+ }
129
+ interface BindMutatorsContext<TCollections extends CollectionMap = CollectionMap> {
130
+ /**
131
+ * Resolves the optimistic-overlay drop against confirmed server watermarks: a
132
+ * mutation's overlay is held until the sync stream echoes
133
+ * `lastMutationId >= clientSeq` (via {@link CheckpointRegistry.resolve}), so the
134
+ * row never flashes out and back.
135
+ *
136
+ * Defaults to the shared per-shard registry for `client` + {@link shardKey}
137
+ * ({@link getShardCheckpoints}) — the same one
138
+ * {@link import("./collection-options").lunoraCollectionOptions} defaults to, so
139
+ * a shard's collections and its mutators gate on one watermark line without the
140
+ * caller wiring them together. Pass `false` to drop the overlay as soon as the
141
+ * server accepts the write (the by-value sync diff then converges the synced row
142
+ * in place).
143
+ */
144
+ checkpoints?: CheckpointRegistry | false;
145
+ /** The wired collections the optimistic bodies write against. */
146
+ collections: TCollections;
147
+ /**
148
+ * Called when a mutator's server push is permanently rejected — the aggregate
149
+ * failure channel for writes, symmetric with
150
+ * {@link import("./define-collections").DefineCollectionsOptions.onWriteRejected}
151
+ * on the outbox path.
152
+ *
153
+ * Supplying it also makes a **fire-and-forget** call safe: a bound handle
154
+ * returns a `Transaction` whose `isPersisted` promise rejects on failure, so a
155
+ * caller that neither awaits it nor attaches a `.catch` leaves an unhandled
156
+ * rejection. With this hook set, `bindMutators` consumes that rejection itself
157
+ * after reporting it — a caller that DOES await still sees the rejection, so
158
+ * per-call handling is unaffected.
159
+ *
160
+ * A throwing listener is swallowed: reporting a failure must not manufacture a
161
+ * second one.
162
+ */
163
+ onWriteRejected?: (event: MutatorRejectedEvent) => void;
164
+ /** Optional shard key the mutator's server push is routed to. */
165
+ shardKey?: string;
166
+ }
167
+ /** Calling a bound mutator runs the optimistic body + pushes the server write; returns the TanStack transaction. */
168
+ type BoundMutators<M extends AnyMutatorMap> = { [K in keyof M]: (args: ArgsOf<M[K]>) => Transaction; };
169
+ /**
170
+ * Bind a set of client mutators to a client + local store. Each returned handle,
171
+ * when called, opens a TanStack optimistic transaction: the mutator's `apply`
172
+ * body writes the predicted rows into the collections, and the transaction's
173
+ * `mutationFn` pushes the authoritative write through
174
+ * {@link LunoraClient.callMutator} under a monotonic per-client `clientSeq`.
175
+ *
176
+ * Rebase-on-poke is free — TanStack DB re-derives every pending optimistic overlay
177
+ * over the latest synced base on each sync tick. The overlay is dropped when the
178
+ * server confirms the write (and, if `checkpoints` is supplied, once it echoes the
179
+ * matching watermark so the synced row has landed).
180
+ *
181
+ * The `clientSeq` generator is seeded from the server's echoed watermark
182
+ * ({@link LunoraClient.confirmedMutationWatermark}) on every issue, so a reload —
183
+ * which resets this in-memory counter while the server keeps a durable per-client
184
+ * watermark — never reissues a sequence the DO has already applied. As a backstop
185
+ * for the very first push of a fresh session (before any ack has taught the client
186
+ * the watermark), a push the DO swallows as a replay (`applied === false`) is
187
+ * reissued above the now-known watermark instead of being mistaken for a confirmed
188
+ * write — closing the silent-drop window without risking a double-apply (a fresh
189
+ * session's first stale push provably cannot be an honest replay).
190
+ *
191
+ * Pushes are **serialized per binding** (a FIFO chain): the DO rejects any push
192
+ * with `clientSeq > watermark + 1` as `OUT_OF_ORDER` and drops the write, so two
193
+ * mutators fired concurrently must not race the network into a gap. Each push
194
+ * waits for the previous one's ack and assigns its `clientSeq` *inside* the
195
+ * critical section — from the live watermark — so the sequence is always exactly
196
+ * `watermark + 1`. Because a failed mutation never advances the server watermark,
197
+ * a permanently-rejected predecessor can't wedge the chain: the next push simply
198
+ * reclaims the same `watermark + 1` instead of leaving a hole the DO waits on.
199
+ */
200
+ declare const bindMutators: <M extends AnyMutatorMap, TCollections extends CollectionMap = CollectionMap>(client: LunoraClient, context: BindMutatorsContext<TCollections>, mutators: M) => BoundMutators<M>;
201
+ /** `defineMutator` + `bindMutators`, both bound to one project's collections map. */
202
+ interface BoundMutatorApi<TCollections extends CollectionMap> {
203
+ /** {@link bindMutators}, with `collections` pinned to `TCollections`. */
204
+ bindMutators: <M extends MutatorMapFor<TCollections>>(client: LunoraClient, context: BindMutatorsContext<TCollections>, mutators: M) => BoundMutators<M>;
205
+ /** {@link defineMutator}, with `context.collections` typed as `TCollections`. */
206
+ defineMutator: {
207
+ <TArgs = Record<string, unknown>>(definition: {
208
+ apply: (context: ClientMutatorContext<TCollections>, args: TArgs) => void;
209
+ serverRef: string;
210
+ }): ClientMutatorDef<TArgs, TCollections>;
211
+ <TArgs>(definition: {
212
+ apply: (context: ClientMutatorContext<TCollections>, args: TArgs) => void;
213
+ serverRef: MutatorReference<TArgs>;
214
+ }): ClientMutatorDef<TArgs, TCollections>;
215
+ };
216
+ }
217
+ /**
218
+ * Bind the mutator surface to **this project's** collections map, once.
219
+ *
220
+ * `Collection` is invariant in its row type, so a shared
221
+ * `Record<string, Collection<Row, string>>` could neither accept a generated
222
+ * collection without an `as never` cast nor hand a usable type back to `apply` —
223
+ * which is why optimistic bodies tended to ignore `context.collections` and close
224
+ * over module-scope collection variables instead. Declaring the map once here
225
+ * fixes both ends: `bindMutators` takes the concrete collections cast-free, and
226
+ * `apply` reads `collections.<name>` at its real row type.
227
+ *
228
+ * Runtime-identical to the standalone {@link defineMutator} / {@link bindMutators}
229
+ * (this returns those very functions); only the types narrow.
230
+ * @example
231
+ * const { bindMutators, defineMutator } = initMutators<{ nodes: typeof wholeOutlineCollection }>();
232
+ * const setText = defineMutator({ apply: ({ collections }, args) => collections.nodes.update(args.id, setter), serverRef: api.mutators.setText });
233
+ */
234
+ declare const initMutators: <TCollections extends CollectionMap>() => BoundMutatorApi<TCollections>;
235
+ export { BindMutatorsContext as B, ClientMutatorContext as C, DIRECT_TRANSACTION_METADATA_KEY as D, MutatorRejectedEvent as M, BoundMutatorApi as a, BoundMutators as b, ClientMutatorDef as c, CollectionMap as d, bindMutators as e, defineMutator as f, MutatorReference as g, initMutators as i };
@@ -0,0 +1 @@
1
+ import{createCollection as I,safeRandomUUID as R}from"@tanstack/db";import{startOfflineExecutor as U,NonRetriableError as w}from"@tanstack/offline-transactions";import{lunoraCollectionOptions as _}from"./CHECKPOINT_FALLBACK_MS-6GmTGWsH.mjs";import{createOutboxCarrier as b,createOptimisticOnlineDetector as E,OUTBOX_MUTATION_FN_NAME as C,registerOutboxCarrier as M,runOutboxMutation as N}from"./OUTBOX_MUTATION_FN_NAME-CebgkYw2.mjs";const S=(a,K,e={})=>{const c={},p={},l={},y=Object.entries(K);for(const[t,o]of y){const n=o.insert,{config:d,scope:m}=_({client:a,getKey:o.getKey,id:t,list:o.list,...o.load===void 0?{}:{load:o.load},onError:o.onError,scopeBy:o.scopeBy,shardKey:o.shardKey});c[t]=I(d),o.scopeBy!==void 0&&(p[t]=m),n&&(l[t]=async({idempotencyKey:r,transaction:i})=>{for(const[f,x]of i.mutations.entries()){const O=x.modified,F=`${r}:${String(f)}`;try{await N(()=>a.mutation(n.mutation,n.toArgs(O),{mutationId:F}))}catch(u){if(u instanceof w&&e.onWriteRejected)try{e.onWriteRejected({code:u.code,collection:t,error:u,row:O})}catch{}throw u}}})}l[C]=async({transaction:t})=>{const o=t.metadata;if(o){if(o.identity!==a.currentIdentity())throw new w("outbox write dropped: identity changed since it was queued");await N(()=>a.mutation({__lunoraRef:o.functionPath},o.args,{mutationId:o.idempotencyKey,shardKey:o.shardKey}))}};const g=b(),s=U({collections:{...c,[C]:g},mutationFns:l,onlineDetector:E(),...e.onLeadershipChange?{onLeadershipChange:e.onLeadershipChange}:{},...e.onStorageFailure?{onStorageFailure:e.onStorageFailure}:{},onUnknownMutationFn:(t,o)=>{try{e.onWriteRejected?.({code:"UNKNOWN_MUTATION_FN",collection:t,error:new Error(`offline write dropped: mutation "${t}" no longer exists (removed or renamed in a deploy?)`),row:o.mutations[0]?.modified})}catch{}}});M(s,g);const h={};for(const[t,o]of y){const n=o.insert,d=c[t];if(!n||!d)continue;const m=s.createOfflineAction({mutationFnName:t,onMutate:({id:r,input:i})=>{d.insert(n.optimistic(i,r))}});h[t]=r=>{const i=R(),f=m({id:i,input:r});return{id:i,transaction:f}}}return{actions:h,collections:c,executor:s,pendingCount:()=>s.getPendingCount(),scope:p}};export{S as defineCollections};