@lunora/db 1.0.0-alpha.26 → 1.0.0-alpha.261

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.
Files changed (30) hide show
  1. package/README.md +38 -2
  2. package/dist/collections/index.d.mts +2 -2
  3. package/dist/collections/index.d.ts +2 -2
  4. package/dist/collections/index.mjs +1 -2
  5. package/dist/index.d.mts +144 -5
  6. package/dist/index.d.ts +144 -5
  7. package/dist/index.mjs +1 -8
  8. package/dist/mutators/index.d.mts +2 -2
  9. package/dist/mutators/index.d.ts +2 -2
  10. package/dist/mutators/index.mjs +1 -1
  11. package/dist/packem_shared/CHECKPOINT_FALLBACK_MS-Dnkx44kO.mjs +1 -0
  12. package/dist/packem_shared/DIRECT_TRANSACTION_METADATA_KEY-BGf7W7d4.mjs +1 -0
  13. package/dist/packem_shared/OUTBOX_MUTATION_FN_NAME-rGyegnLw.mjs +1 -0
  14. package/dist/packem_shared/applyPlanToCollections-DRwloPsc.mjs +1 -0
  15. package/dist/packem_shared/collection-options.d-C2FdJw06.d.mts +411 -0
  16. package/dist/packem_shared/collection-options.d-C2FdJw06.d.ts +411 -0
  17. package/dist/packem_shared/defineCollections-D4Y0GiBS.mjs +1 -0
  18. package/dist/packem_shared/index.d-9usNsVAH.d.mts +245 -0
  19. package/dist/packem_shared/{define-collections.d-Dkzt5xPY.d.ts → index.d-B_2dXw1Z.d.mts} +23 -7
  20. package/dist/packem_shared/index.d-t0PhXOHV.d.ts +245 -0
  21. package/dist/packem_shared/{define-collections.d-C9paBiXl.d.mts → index.d-ypscb9Qp.d.ts} +23 -7
  22. package/package.json +3 -3
  23. package/dist/packem_shared/OUTBOX_MUTATION_FN_NAME-9fqK7rND.mjs +0 -131
  24. package/dist/packem_shared/bindMutators-B_RaNgel.mjs +0 -70
  25. package/dist/packem_shared/collection-options.d-BPVkj5FW.d.mts +0 -227
  26. package/dist/packem_shared/collection-options.d-BPVkj5FW.d.ts +0 -227
  27. package/dist/packem_shared/createCheckpointRegistry-BtIEm2Kh.mjs +0 -136
  28. package/dist/packem_shared/define-mutators.d-DTU85KL6.d.mts +0 -80
  29. package/dist/packem_shared/define-mutators.d-DukVT0Y3.d.ts +0 -80
  30. package/dist/packem_shared/defineCollections-C_F-42rS.mjs +0 -109
@@ -0,0 +1,411 @@
1
+ import { SubscriptionError, OutboxSink, LunoraClient, FunctionReference } from '@lunora/client';
2
+ import { CollectionConfig } from '@tanstack/db';
3
+ import { OnlineDetector } from '@tanstack/offline-transactions';
4
+ /**
5
+ * What a collection exposes about its subscription's health, as `collection.utils`.
6
+ *
7
+ * A rejected subscription still moves the collection out of `loading` (so it never
8
+ * hangs there), which on its own is indistinguishable from a successful load that
9
+ * found no rows. This is the difference: `isError()` stays true until rows arrive.
10
+ */
11
+ interface LunoraCollectionUtils {
12
+ /** The last subscription error, or `undefined` once rows have arrived since (or before any error). */
13
+ error: () => SubscriptionError | undefined;
14
+ /** Whether the subscription is currently failing. */
15
+ isError: () => boolean;
16
+ /** Listen for the error state changing; returns the unsubscribe. Shaped for `useSyncExternalStore`. */
17
+ subscribeError: (listener: () => void) => () => void;
18
+ }
19
+ /**
20
+ * Reserved `mutationFns` key the unified outbox routes raw `client.mutation`
21
+ * offline writes through. `defineCollections` registers a handler under this
22
+ * name that reads `transaction.metadata` (functionPath + args) and replays the
23
+ * write, so a db app's direct mutations ride the same durable executor as its
24
+ * collection inserts instead of the standalone {@link OutboxSink} fallback.
25
+ */
26
+ declare const OUTBOX_MUTATION_FN_NAME = "__lunora_outbox__";
27
+ /**
28
+ * Provenance stamped on a durable write at enqueue time: the two questions a
29
+ * replay cannot answer from the persisted row once the write outlives the
30
+ * session that made it — WHO queued it, and WHICH shard it belongs to.
31
+ *
32
+ * Shared so both replay paths (`db.actions.*` and the reserved
33
+ * `__lunora_outbox__` handler) check the same fields; they drifted apart once
34
+ * already, and only one of them had the identity guard.
35
+ */
36
+ interface WriteProvenance extends Record<string, unknown> {
37
+ /**
38
+ * The CDC cursor the write was composed against, persisted so the replay can
39
+ * hand it straight back to `client.mutation`.
40
+ *
41
+ * Persisting it is the whole point: the replay runs after a reconnect (or a
42
+ * reload, days later), by which time this client has advanced to a NEWER
43
+ * cursor — exactly the state a `.dropStalePatches()` table must judge the
44
+ * write against. Letting `client.mutation` sample its own there makes every
45
+ * stale write look fresh and clobber. Lives on the SHARED provenance type
46
+ * because both replay paths need it, and they have drifted apart once before.
47
+ *
48
+ * Absent on transactions persisted by older versions, and on a client with no
49
+ * live subscription to take a cursor from; both replay unchanged.
50
+ */
51
+ baselineSeq?: number;
52
+ /** Issuing identity fingerprint; a replay drops the write when it no longer matches. */
53
+ identity: string | null;
54
+ /** Captured, not re-read at replay: a queued write follows the shard it was made against even if the app reboots pointed at another. */
55
+ shardKey?: string;
56
+ }
57
+ /** The metadata an outbox-routed transaction carries so its replay can call `client.mutation`. */
58
+ interface OutboxMutationMetadata extends WriteProvenance {
59
+ args: Record<string, unknown>;
60
+ clientId: string;
61
+ functionPath: string;
62
+ /** Stable `${clientId}:${mutationId}` replay key; passed back as the mutation id so a committed-but-unacked replay is server-idempotent. */
63
+ idempotencyKey: string;
64
+ mutationId: number;
65
+ }
66
+ /** A committable outbox transaction handle (the `OfflineTransaction` the executor mints). */
67
+ interface OutboxTransaction {
68
+ commit?: () => Promise<unknown>;
69
+ mutate: (callback: () => void) => unknown;
70
+ }
71
+ /**
72
+ * The slice of the TanStack `OfflineExecutor` the {@link createExecutorOutboxSink}
73
+ * drives. Declared structurally so `@lunora/db`'s outbox glue doesn't widen its
74
+ * coupling to the executor's full surface (and stays unit-testable with a fake).
75
+ */
76
+ interface OutboxExecutor {
77
+ createOfflineTransaction: (options: {
78
+ autoCommit?: boolean;
79
+ idempotencyKey?: string;
80
+ metadata?: Record<string, unknown>;
81
+ mutationFnName: string;
82
+ }) => OutboxTransaction;
83
+ getPendingCount: () => number;
84
+ }
85
+ /** Tuning for {@link createExecutorOutboxSink}. */
86
+ interface ExecutorOutboxSinkOptions {
87
+ /** Max persisted-but-unconfirmed writes before `enqueue` rejects with `OFFLINE_QUEUE_OVERFLOW` (default 1000, matching `OfflineQueue`). */
88
+ maxItems?: number;
89
+ /** `mutationFns` key the replay handler is registered under (default {@link OUTBOX_MUTATION_FN_NAME}). */
90
+ mutationFnName?: string;
91
+ }
92
+ /**
93
+ * The blessed {@link OutboxSink} over the TanStack `OfflineExecutor` — the single
94
+ * durable write path for a `@lunora/db` app. It persists each offline write as an
95
+ * executor transaction carrying the `client.mutation` target in `metadata`, then
96
+ * ports the two semantics the executor lacks vs the built-in `OfflineQueue`.
97
+ *
98
+ * First: a `maxItems` cap that **rejects** a new write at capacity with an
99
+ * `OFFLINE_QUEUE_OVERFLOW`-coded error — the {@link OutboxSink} contract, matching
100
+ * `OfflineQueue`. Rejecting (rather than evicting the oldest) preserves the
101
+ * at-least-once promise: an already-persisted write is never silently dropped, and
102
+ * the caller surfaces back-pressure to the issuing mutation (which rolls its
103
+ * optimistic write back).
104
+ * Second: the identity guard lives in the replay handler (`defineCollections`), which drops a write whose captured `identity` no longer matches — see {@link OutboxMutationMetadata}.
105
+ */
106
+ declare const createExecutorOutboxSink: (executor: OutboxExecutor, options?: ExecutorOutboxSinkOptions) => OutboxSink;
107
+ /** A row carrying the Lunora document id. */
108
+ type Row = Record<string, unknown> & {
109
+ _id: string;
110
+ };
111
+ /** The subset of a TanStack DB sync write channel that {@link makeDiffEmit} drives. */
112
+ interface SyncWriter<T extends object> {
113
+ begin: () => void;
114
+ commit: () => void;
115
+ write: (message: {
116
+ type: "insert" | "update";
117
+ value: T;
118
+ } | {
119
+ key: string;
120
+ type: "delete";
121
+ }) => void;
122
+ }
123
+ /** Index a row list into a keyed map. */
124
+ declare const toMap: <T extends object>(rows: ReadonlyArray<T>, getKey: (row: T) => string) => Map<string, T>;
125
+ /**
126
+ * Build an `emit(next)` that diffs a desired keyed snapshot into a collection's
127
+ * sync channel — only changed rows are written, so a reconnect snapshot or a
128
+ * scope change never churns the synced view out from under a pending optimistic
129
+ * row. The last-synced base is tracked in `syncedJson`.
130
+ *
131
+ * Change detection compares rows by `JSON.stringify`, which is key-order
132
+ * sensitive — safe here because server snapshots have stable column order
133
+ * across reconnects (same query projection). A sync source with unstable key
134
+ * ordering would need a structural compare instead.
135
+ *
136
+ * `syncedJson` holds the JSON-serialized form of each last-synced row, keyed
137
+ * by row id. It is the **sole** synced-state map — no parallel row-object map
138
+ * is kept. Each incoming value is serialized exactly once per tick (for both
139
+ * comparison and cache update), so the previous value is never re-serialized.
140
+ *
141
+ * Lifecycle: `syncedJson` is owned by the caller at the same scope as any other
142
+ * per-collection state (outside the `sync.sync` callback), so one map serves
143
+ * every `makeDiffEmit` closure the collection creates and a *within-session*
144
+ * re-delivery of an unchanged snapshot writes nothing.
145
+ *
146
+ * It does NOT survive a sync **restart**, and must not: TanStack drops its
147
+ * synced store on gc cleanup, so the sole caller
148
+ * ({@link file://./collection-options.ts}'s `sync.sync` teardown) clears the map
149
+ * on the way out. A restart that kept the map would diff the server's
150
+ * re-delivered snapshot against rows the store no longer holds, emit zero
151
+ * writes, and leave the restarted collection permanently empty. The map's job is
152
+ * incremental-diff state for one live session, not a durable cache.
153
+ */
154
+ declare const makeDiffEmit: <T extends object>(syncedJson: Map<string, string>, writer: SyncWriter<T>) => (next: Map<string, T>) => void;
155
+ /**
156
+ * Run a Lunora mutation under the outbox's retry policy.
157
+ *
158
+ * The retryable/permanent split keys on whether the failure carries a server
159
+ * application error `code` (set by `@lunora/client`'s rpc when the server returns
160
+ * a `{ error: { code, … } }` envelope — validation, conflict, etc.). A coded
161
+ * error is a definite verdict: surface it as a `NonRetriableError` so the executor
162
+ * stops and TanStack DB rolls the optimistic insert back. Everything without a
163
+ * code is transient — a `fetch` network failure (`TypeError`) or an HTTP/infra
164
+ * blip the rpc surfaces as a code-less `Error` (a 5xx gateway page, a non-JSON
165
+ * body) — so it's rethrown as-is and the durable outbox replays it. Keying on
166
+ * `error instanceof TypeError` alone would wrongly drop the latter. Two coded
167
+ * failures are not verdicts on the write and are rethrown as-is too: the worker's
168
+ * `IDENTITY_MISMATCH`, and a refused credential ({@link isAuthReplayFailure}).
169
+ * The caller decides what those mean: `defineCollections` holds the write for a
170
+ * fresh credential, and a bound mutator (never retried) settles it.
171
+ */
172
+ declare const runOutboxMutation: (mutate: () => Promise<unknown>) => Promise<void>;
173
+ /**
174
+ * An "always attempt" online detector. We deliberately don't trust
175
+ * `navigator.onLine`: some environments (and Playwright's `setOffline` under
176
+ * Firefox) leave it stuck, which would freeze the outbox. Instead the executor
177
+ * always tries the send and {@link runOutboxMutation}'s transient-error retry
178
+ * handles real offline; the periodic tick nudges the executor to drain the outbox
179
+ * so a queued write replays promptly once connectivity returns.
180
+ *
181
+ * `isOnline` is therefore intentionally always `true` — it gates the executor's
182
+ * attempts, not a UI signal. A consumer that wants to show real connectivity
183
+ * should read `navigator.onLine` itself, separately from this detector.
184
+ */
185
+ declare const createOptimisticOnlineDetector: () => OnlineDetector;
186
+ /** A watermark pair — the two monotonic lines a checkpoint registry gates on. */
187
+ interface CheckpointWatermark {
188
+ /** Op-log cursor the server has durably applied. */
189
+ checkpoint?: number;
190
+ /** Highest `clientSeq` the server has echoed back for this client. */
191
+ mutationId?: number;
192
+ }
193
+ /** Reported when the fallback releases an overlay the sync stream never confirmed. */
194
+ interface CheckpointFallbackEvent {
195
+ /** Which gate released. */
196
+ kind: "checkpoint" | "mutationId";
197
+ /** How long the release waited past the server acknowledgement, in ms. */
198
+ waitedMs: number;
199
+ /** The watermark that was acknowledged but never confirmed by a sync frame. */
200
+ watermark: number;
201
+ }
202
+ /** Counters for {@link CheckpointRegistry.stats} — feeds a debug/diagnostics surface. */
203
+ interface CheckpointRegistryStats {
204
+ /** How many times the fallback timer released an overlay (a non-zero value means sync frames are being lost). */
205
+ fallbacks: number;
206
+ /** Overlays currently waiting on a checkpoint cursor. */
207
+ pendingCheckpointWaiters: number;
208
+ /** Overlays currently waiting on a mutation id. */
209
+ pendingMutationWaiters: number;
210
+ }
211
+ /** Tuning for {@link createCheckpointRegistry}. */
212
+ interface CheckpointRegistryOptions {
213
+ /**
214
+ * How long an {@link CheckpointRegistry.acknowledge}d watermark waits for the
215
+ * authoritative sync frame before the overlay is released anyway. Default 3000.
216
+ * `0` disables the fallback (an overlay then waits forever for the frame — the
217
+ * pre-fallback behavior, which hangs on a dropped poke).
218
+ */
219
+ fallbackMs?: number;
220
+ /**
221
+ * Notified each time the fallback fires. A fallback is never *correct* — it
222
+ * means a poke or `settled` frame that should have confirmed the write never
223
+ * arrived — so this is the hook for a warning or a metric. Defaults to a
224
+ * one-shot `console.warn`.
225
+ */
226
+ onFallback?: (event: CheckpointFallbackEvent) => void;
227
+ }
228
+ /**
229
+ * Resolves the TanStack optimistic-overlay drop against the server's confirmed
230
+ * watermarks. A mutator's optimistic transaction returns `awaitMutationId(id)`
231
+ * (or `awaitCheckpoint(cursor)`); TanStack keeps the overlay until that promise
232
+ * settles, so the row de-duplicates exactly as the synced server value lands — no
233
+ * flash of the optimistic row disappearing then reappearing.
234
+ *
235
+ * Two inputs, deliberately distinct:
236
+ *
237
+ * - {@link CheckpointRegistry.resolve} is the **authoritative** advance, called by whoever owns
238
+ * the watermark stream — a `data`/`delta` frame's `lastMutationId`, or a shape poke's
239
+ * `checkpoint`. The synced rows have landed, so gates open immediately.
240
+ * - {@link CheckpointRegistry.acknowledge} is the **provisional** advance, called when the server
241
+ * has accepted the write (the mutator RPC ack) but the matching rows have not
242
+ * necessarily been delivered yet. Releasing here would drop the overlay before
243
+ * the synced row exists — a visible flicker — so instead it arms a bounded
244
+ * fallback. If the authoritative frame lands first the fallback is cancelled;
245
+ * if it never lands, the overlay is released after `fallbackMs` and the event is
246
+ * reported rather than hanging forever.
247
+ *
248
+ * That pairing is why a lost poke degrades to a late overlay drop instead of a
249
+ * permanently stuck `isPersisted` promise.
250
+ */
251
+ interface CheckpointRegistry {
252
+ /**
253
+ * Record a server-accepted watermark whose rows may not have synced yet: arms
254
+ * the bounded fallback described on {@link CheckpointRegistry}. Safe to call
255
+ * repeatedly; a watermark already passed is a no-op.
256
+ */
257
+ acknowledge: (watermark: CheckpointWatermark) => void;
258
+ /** Resolve once the server has acknowledged the op-log `cursor`. */
259
+ awaitCheckpoint: (cursor: number) => Promise<void>;
260
+ /** Resolve once the server has echoed a `lastMutationId >= id` for this client. */
261
+ awaitMutationId: (id: number) => Promise<void>;
262
+ /**
263
+ * Tear the registry down: `clearTimeout` every armed fallback timer and empty
264
+ * the armed set. Idempotent. A discarded registry (Vite HMR dispose, sign-out)
265
+ * can otherwise hold up to `fallbackMs` of pending `setTimeout`s alive through
266
+ * their closures — keeping a Node/SSR event loop from draining. Distinct from
267
+ * {@link CheckpointRegistry.resolve}: `resolve` settles parked *waiters* (and disarms the timers it
268
+ * subsumes as a side effect); `dispose` guarantees no armed timer survives,
269
+ * independent of any watermark.
270
+ */
271
+ dispose: () => void;
272
+ /** Advance the gates from a sync frame's watermark; later callers past the mark settle immediately. */
273
+ resolve: (watermark: CheckpointWatermark) => void;
274
+ /** Diagnostics counters — notably how often the fallback had to fire. */
275
+ stats: () => CheckpointRegistryStats;
276
+ }
277
+ /** Default fallback window: long enough that a slow-but-arriving poke wins, short enough that a UI isn't visibly stuck. */
278
+ declare const CHECKPOINT_FALLBACK_MS = 3e3;
279
+ /**
280
+ * A standalone checkpoint/mutation-id registry. Prefer {@link getShardCheckpoints}
281
+ * unless you are wiring a bespoke watermark stream — a registry must be shared by
282
+ * every collection on a shard (see that function for why).
283
+ */
284
+ declare const createCheckpointRegistry: (options?: CheckpointRegistryOptions) => CheckpointRegistry;
285
+ /**
286
+ * Release every pending overlay gate for `client` and drop its shard registries.
287
+ *
288
+ * The hot-reload / teardown escape hatch. When a module that owns collections and
289
+ * mutators is replaced — a Vite HMR update, a sign-out that rebuilds the data layer
290
+ * — the *old* bindings may still have transactions parked in `awaitMutationId`. The
291
+ * subscriptions that would have resolved them are gone with the old module, so
292
+ * without this those promises never settle and every one of their
293
+ * `transaction.isPersisted` waiters hangs forever.
294
+ *
295
+ * Resolving to `Infinity` settles the parked waiters (the writes were already sent;
296
+ * the server is authoritative regardless), and dropping the registries means the
297
+ * replacement module's bindings start from a clean per-shard gate.
298
+ *
299
+ * ```ts
300
+ * // In the module that owns the data layer:
301
+ * import.meta.hot?.dispose(() => releaseShardCheckpoints(client));
302
+ * ```
303
+ */
304
+ declare const releaseShardCheckpoints: (client: LunoraClient) => void;
305
+ /**
306
+ * The shared checkpoint registry for `client` + `shardKey` — created on first use.
307
+ * This is the registry {@link lunoraCollectionOptions} and
308
+ * {@link import("./define-mutators").bindMutators} default to, which is what makes
309
+ * a multi-collection shard work without the caller relaying pokes between
310
+ * registries by hand.
311
+ *
312
+ * `options` applies **only when the registry is created**. Because the point is that
313
+ * every collection and mutator on a shard shares one gate, a later call cannot
314
+ * retune an existing registry — it returns the existing one and `options` is ignored.
315
+ * To control `fallbackMs` / `onFallback`, build the registry yourself with
316
+ * {@link createCheckpointRegistry} and pass it explicitly to every
317
+ * `lunoraCollectionOptions` and `bindMutators` call for that shard.
318
+ */
319
+ declare const getShardCheckpoints: (client: LunoraClient, shardKey?: string, options?: CheckpointRegistryOptions) => CheckpointRegistry;
320
+ /**
321
+ * A replication-shape sync source (the local-first partial-replication path).
322
+ * Mutually exclusive with {@link LunoraCollectionConfig.list}: the collection
323
+ * live-syncs the named shape's rowset via the client's poke protocol
324
+ * (`subscribeShape`) instead of a full-table `list` query subscription.
325
+ */
326
+ interface ShapeSource {
327
+ /** Validated shape parameters (the partition selector — e.g. `{ channelId }`). */
328
+ args?: Record<string, unknown>;
329
+ /** The `defineShape` export name registered in `LUNORA_SHAPES`. */
330
+ name: string;
331
+ /** Routes the subscription to a specific shard's DO when the table is sharded. */
332
+ shardKey?: string;
333
+ }
334
+ /** Declarative inputs for {@link lunoraCollectionOptions}. */
335
+ interface LunoraCollectionConfig<TRow extends Row> {
336
+ /**
337
+ * The registry optimistic overlays are gated on. Defaults to the shared
338
+ * per-shard registry ({@link getShardCheckpoints}), which is what a
339
+ * multi-collection shard needs — pass one explicitly only to isolate a
340
+ * collection's gate (tests) or to supply custom {@link CheckpointRegistryOptions}.
341
+ */
342
+ checkpoints?: CheckpointRegistry;
343
+ /** The Lunora client to subscribe through. */
344
+ client: LunoraClient;
345
+ /** Row key extractor — defaults to `row._id`. */
346
+ getKey?: (row: TRow) => string;
347
+ /** Collection id (TanStack identity) — defaults to the `list` function path (or `shape:` + the shape name). */
348
+ id?: string;
349
+ /** The Lunora query that lists the rows (the full-table sync source). Mutually exclusive with {@link LunoraCollectionConfig.shape}. */
350
+ list?: FunctionReference;
351
+ /**
352
+ * When the collection starts syncing. `"lazy"` (default) starts on the first
353
+ * `useLiveQuery` subscriber; `"eager"` starts at creation (TanStack's
354
+ * `startSync`) — for small "instant" reference data you want warm at boot.
355
+ * No effect on a `scopeBy` collection, which has nothing to sync until scoped.
356
+ * (Even eager, TanStack pauses sync while there are no subscribers, per its
357
+ * `gcTime` lifecycle — "warm while referenced", not pinned forever.)
358
+ */
359
+ load?: "eager" | "lazy";
360
+ /** Notified when the underlying subscription errors; the collection always leaves `loading` regardless. */
361
+ onError?: (error: SubscriptionError) => void;
362
+ /**
363
+ * When set, the collection stays empty until {@link LunoraCollectionOptions.scope}
364
+ * points it at args. Name the table's `.shardBy(...)` column: unless
365
+ * {@link LunoraCollectionConfig.shardKey} pins a shard, the scoped value IS the
366
+ * shard key, so `scope({ channelId })` subscribes to that channel's shard and
367
+ * re-scoping moves the subscription. Scope args without the field throw.
368
+ */
369
+ scopeBy?: string;
370
+ /** A replication shape as the sync source (partial replication). Mutually exclusive with {@link LunoraCollectionConfig.list}. */
371
+ shape?: ShapeSource;
372
+ /**
373
+ * Routes the `list` subscription — and the confirmed-mutation watermark its
374
+ * data/`settled` frames advance the checkpoint gate from — to a specific
375
+ * shard's DO. Applies to the `list` source; a `shape` carries its own
376
+ * {@link ShapeSource.shardKey}. Without it the list path falls back to the
377
+ * default ("") watermark bucket, which must not be compared against a
378
+ * per-shard mutator's sequence line (it would drop a sharded overlay early or
379
+ * hang it forever).
380
+ *
381
+ * Takes precedence over {@link LunoraCollectionConfig.scopeBy}: set both only
382
+ * when every scope lives on one shard (e.g. a per-tenant shard scoped by a
383
+ * non-shard column). Leave it unset on a `scopeBy` collection over a
384
+ * `.shardBy(...)` table so the shard follows the scope.
385
+ */
386
+ shardKey?: string;
387
+ }
388
+ /** The result of {@link lunoraCollectionOptions}: a TanStack collection config plus its sync controls. */
389
+ interface LunoraCollectionOptions<TRow extends Row> {
390
+ /** Resolves optimistic-overlay drops against the server's confirmed watermarks — the current scope's shard for a `scopeBy` collection. */
391
+ checkpoints: CheckpointRegistry;
392
+ /** Pass to TanStack's `createCollection`. */
393
+ config: CollectionConfig<TRow, string>;
394
+ /** Re-point a `scopeBy` collection's subscription (omit `args` to detach). No-op for unscoped collections. */
395
+ scope: (args?: Record<string, unknown>) => void;
396
+ /** Subscription health; the same object is on the created collection as `collection.utils`. */
397
+ utils: LunoraCollectionUtils;
398
+ }
399
+ /**
400
+ * Build a TanStack DB collection config (+ sync controls) that live-syncs a
401
+ * Lunora `list` query through the client. This is the reusable core lifted out of
402
+ * {@link import("./define-collections").defineCollections}: the same `makeDiffEmit`
403
+ * diff-into-channel, `autoIndex:"eager"` + B-tree indexes, scoped-resubscribe, and
404
+ * fail-safe `markReady`-on-error behavior, exposed as a standalone
405
+ * collection-options creator so apps (and codegen) can compose it directly.
406
+ *
407
+ * The returned `checkpoints` registry lets a mutator runtime resolve optimistic
408
+ * overlays against confirmed server watermarks (see {@link CheckpointRegistry}).
409
+ */
410
+ declare const lunoraCollectionOptions: <TRow extends Row>(options: LunoraCollectionConfig<TRow>) => LunoraCollectionOptions<TRow>;
411
+ export { CHECKPOINT_FALLBACK_MS as C, ExecutorOutboxSinkOptions as E, LunoraCollectionConfig as L, OUTBOX_MUTATION_FN_NAME as O, Row as R, SyncWriter as S, CheckpointFallbackEvent as a, CheckpointRegistry as b, CheckpointRegistryOptions as c, CheckpointRegistryStats as d, CheckpointWatermark as e, LunoraCollectionOptions as f, LunoraCollectionUtils as g, OutboxExecutor as h, OutboxMutationMetadata as i, createCheckpointRegistry as j, createExecutorOutboxSink as k, createOptimisticOnlineDetector as l, getShardCheckpoints as m, lunoraCollectionOptions as n, makeDiffEmit as o, runOutboxMutation as p, releaseShardCheckpoints as r, toMap as t };
@@ -0,0 +1 @@
1
+ import{lunoraCollectionOptions as I,scopedShardKey as R}from"./CHECKPOINT_FALLBACK_MS-Dnkx44kO.mjs";import{isAuthReplayFailure as M}from"@lunora/client";import{createCollection as D,safeRandomUUID as U}from"@tanstack/db";import{startOfflineExecutor as j,NonRetriableError as q}from"@tanstack/offline-transactions";import{assertSecureRandom as L,createOutboxCarrier as k,createOptimisticOnlineDetector as W,OUTBOX_MUTATION_FN_NAME as T,registerOutboxCarrier as $,takeOutboxRejection as S,toNonRetriable as _,runOutboxMutation as H,terminalVerdict as P}from"./OUTBOX_MUTATION_FN_NAME-rGyegnLw.mjs";const A=new WeakMap,V=(o,a)=>{let r=A.get(o);r===void 0&&(r=new Map,A.set(o,r));const c=r.get(a)??0;r.set(a,c+1),c===1&&console.warn(`[@lunora/db] table "${a}" is bound by more than one defineCollections call on this client. Each call creates its own live collection and outbox for the table, so the two can drift and derived indexes built from one can silently read stale rows. Bind every table in a single defineCollections call (see the "One source of truth per table" docs section).`)},K=(o,a)=>{try{o.onWriteRejected?.(a)}catch{}},G=(o,a)=>{const r=o.replayIdentityVerdict(a?.identity);if(r.verdict==="mismatch")throw new q("outbox write dropped: identity changed since it was queued");if(r.verdict==="unknown")throw new Error("outbox write deferred: no identity established yet");return r.credential},X=6e4,z=[2e3,5e3,1e4,2e4,4e4],J=(o,a,r)=>{if(a!==o.refused)return"send";const c=r-o.since;if(c>=X)return"expired";const d=o.refused===null?z[o.resends]:void 0;return d!==void 0&&c>=d?"send":"wait"},oe=(o,a,r={})=>{L("defineCollections");const c={},d=new Map,O=async(t,e,i)=>{const n=d.get(t),f=n===void 0?"send":J(n,o.getAuthToken(),Date.now());if(f==="expired")throw d.delete(t),_(n?.error);if(f==="wait")throw new Error("outbox write deferred: waiting for a fresh credential");const l=o.getAuthToken(),u=G(o,e);try{await H(async()=>i(u))}catch(s){if(!M(s))throw s;const y=n?.refused===null&&l===null;throw n!==void 0&&!y?(d.delete(t),_(s)):(d.set(t,{error:s,refused:l,resends:n===void 0?0:n.resends+1,since:n?.since??Date.now()}),new Error("outbox write deferred: its credential was refused",{cause:s}))}d.delete(t)},x=(t,e)=>{const i=P(e);return i!==void 0&&d.delete(t),i},E={},p={},B=Object.entries(a);for(const[t,e]of B){const i=e.insert;V(o,t);const{config:n,scope:f}=I({client:o,getKey:e.getKey,id:t,list:e.list,...e.load===void 0?{}:{load:e.load},onError:e.onError,scopeBy:e.scopeBy,shardKey:e.shardKey});c[t]=D(n),e.scopeBy!==void 0&&(E[t]=f),i&&(p[t]=async({idempotencyKey:l,transaction:u})=>{const s=u.metadata,y=s!==void 0&&s.shardKey===void 0&&e.shardKey===void 0&&e.scopeBy!==void 0;for(const[w,g]of u.mutations.entries()){const b=g.modified,v=`${l}:${String(w)}`;try{await O(v,s,async m=>o.mutation(i.mutation,i.toArgs(b),{mutationId:v,replayBaseline:y?null:s?.baselineSeq??null,replayCredential:m,shardKey:y?R(e.scopeBy,b,"the queued row"):s?.shardKey}))}catch(C){const m=x(v,C);throw m!==void 0?(K(r,{code:m.code,collection:t,error:m,row:b}),m):C}}})}p[T]=async({transaction:t})=>{const e=t.metadata;if(e)try{const i=async n=>o.mutation({__lunoraRef:e.functionPath},e.args,{mutationId:e.idempotencyKey,replayBaseline:e.baselineSeq??null,replayCredential:n,shardKey:e.shardKey});await O(e.idempotencyKey,e,i),S(e.idempotencyKey)}catch(i){const n=x(e.idempotencyKey,i);throw n!==void 0?(S(e.idempotencyKey)?.(),K(r,{code:n.code,collection:e.functionPath,error:n}),n):i}};const F=k(),h=j({collections:{...c,[T]:F},mutationFns:p,onlineDetector:W(),...r.onLeadershipChange?{onLeadershipChange:r.onLeadershipChange}:{},...r.onStorageFailure?{onStorageFailure:r.onStorageFailure}:{},onUnknownMutationFn:(t,e)=>{K(r,{code:"UNKNOWN_MUTATION_FN",collection:t,error:new Error(`offline write dropped: mutation "${t}" no longer exists (removed or renamed in a deploy?)`),row:e.mutations[0]?.modified})}});$(h,F);const N={};for(const[t,e]of B){const i=e.insert,n=c[t];!i||!n||(N[t]=f=>{const l=U(),u=i.optimistic(f,l),s=e.shardKey??(e.scopeBy===void 0?void 0:R(e.scopeBy,u,"the inserted row")),y={baselineSeq:o.currentBaseline(s),identity:o.currentIdentity(),shardKey:s},w=h.createOfflineTransaction({autoCommit:!1,metadata:y,mutationFnName:t}),g=w.mutate(()=>{n.insert(u)});return w.commit().catch(()=>{}),{id:l,transaction:g}})}return{actions:N,collections:c,executor:h,pendingCount:()=>h.getPendingCount(),scope:E}};export{oe as defineCollections};
@@ -0,0 +1,245 @@
1
+ import { LunoraClient } from '@lunora/client';
2
+ import { Collection, Transaction } from '@tanstack/db';
3
+ import { b as CheckpointRegistry } from "./collection-options.d-C2FdJw06.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 BindMutatorsContext.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
+ /**
165
+ * Route each call to the shard its own args name: the value of this field
166
+ * (the table's `.shardBy(...)` column) is the shard key, exactly as for a
167
+ * `scopeBy` collection's inserts — so `sendMessage({ channelId, … })` lands
168
+ * on that channel's shard. Required when `checkpoints` comes from a
169
+ * `scopeBy` collection, unless `shardKey` pins one shard; a call whose args
170
+ * lack the field throws.
171
+ * {@link BindMutatorsContext.shardKey} takes precedence.
172
+ */
173
+ scopeBy?: string;
174
+ /** Pins every push to one shard. Takes precedence over {@link BindMutatorsContext.scopeBy}. */
175
+ shardKey?: string;
176
+ }
177
+ /** Calling a bound mutator runs the optimistic body + pushes the server write; returns the TanStack transaction. */
178
+ type BoundMutators<M extends AnyMutatorMap> = { [K in keyof M]: (args: ArgsOf<M[K]>) => Transaction; };
179
+ /**
180
+ * Bind a set of client mutators to a client + local store. Each returned handle,
181
+ * when called, opens a TanStack optimistic transaction: the mutator's `apply`
182
+ * body writes the predicted rows into the collections, and the transaction's
183
+ * `mutationFn` pushes the authoritative write through
184
+ * {@link LunoraClient.callMutator} under a monotonic per-client `clientSeq`.
185
+ *
186
+ * Rebase-on-poke is free — TanStack DB re-derives every pending optimistic overlay
187
+ * over the latest synced base on each sync tick. The overlay is dropped when the
188
+ * server confirms the write (and, if `checkpoints` is supplied, once it echoes the
189
+ * matching watermark so the synced row has landed).
190
+ *
191
+ * The `clientSeq` generator is seeded from the server's echoed watermark
192
+ * ({@link LunoraClient.confirmedMutationWatermark}) on every issue, so a reload —
193
+ * which resets this in-memory counter while the server keeps a durable per-client
194
+ * watermark — never reissues a sequence the DO has already applied. As a backstop
195
+ * for the very first push of a fresh session (before any ack has taught the client
196
+ * the watermark), a push the DO swallows as a replay (`applied === false`) is
197
+ * reissued above the now-known watermark instead of being mistaken for a confirmed
198
+ * write — closing the silent-drop window without risking a double-apply (a fresh
199
+ * session's first stale push provably cannot be an honest replay).
200
+ *
201
+ * Pushes are **serialized per binding** (a FIFO chain): the DO rejects any push
202
+ * with `clientSeq > watermark + 1` as `OUT_OF_ORDER` and drops the write, so two
203
+ * mutators fired concurrently must not race the network into a gap. Each push
204
+ * waits for the previous one's ack and assigns its `clientSeq` *inside* the
205
+ * critical section — from the live watermark — so the sequence is always exactly
206
+ * `watermark + 1`. Because a failed mutation never advances the server watermark,
207
+ * a permanently-rejected predecessor can't wedge the chain: the next push simply
208
+ * reclaims the same `watermark + 1` instead of leaving a hole the DO waits on.
209
+ */
210
+ declare const bindMutators: <M extends AnyMutatorMap, TCollections extends CollectionMap = CollectionMap>(client: LunoraClient, context: BindMutatorsContext<TCollections>, mutators: M) => BoundMutators<M>;
211
+ /** `defineMutator` + `bindMutators`, both bound to one project's collections map. */
212
+ interface BoundMutatorApi<TCollections extends CollectionMap> {
213
+ /** {@link bindMutators}, with `collections` pinned to `TCollections`. */
214
+ bindMutators: <M extends MutatorMapFor<TCollections>>(client: LunoraClient, context: BindMutatorsContext<TCollections>, mutators: M) => BoundMutators<M>;
215
+ /** {@link defineMutator}, with `context.collections` typed as `TCollections`. */
216
+ defineMutator: {
217
+ <TArgs = Record<string, unknown>>(definition: {
218
+ apply: (context: ClientMutatorContext<TCollections>, args: TArgs) => void;
219
+ serverRef: string;
220
+ }): ClientMutatorDef<TArgs, TCollections>;
221
+ <TArgs>(definition: {
222
+ apply: (context: ClientMutatorContext<TCollections>, args: TArgs) => void;
223
+ serverRef: MutatorReference<TArgs>;
224
+ }): ClientMutatorDef<TArgs, TCollections>;
225
+ };
226
+ }
227
+ /**
228
+ * Bind the mutator surface to **this project's** collections map, once.
229
+ *
230
+ * `Collection` is invariant in its row type, so a shared
231
+ * `Record<string, Collection<Row, string>>` could neither accept a generated
232
+ * collection without an `as never` cast nor hand a usable type back to `apply` —
233
+ * which is why optimistic bodies tended to ignore `context.collections` and close
234
+ * over module-scope collection variables instead. Declaring the map once here
235
+ * fixes both ends: `bindMutators` takes the concrete collections cast-free, and
236
+ * `apply` reads `collections.<name>` at its real row type.
237
+ *
238
+ * Runtime-identical to the standalone {@link defineMutator} / {@link bindMutators}
239
+ * (this returns those very functions); only the types narrow.
240
+ * @example
241
+ * const { bindMutators, defineMutator } = initMutators<{ nodes: typeof wholeOutlineCollection }>();
242
+ * const setText = defineMutator({ apply: ({ collections }, args) => collections.nodes.update(args.id, setter), serverRef: api.mutators.setText });
243
+ */
244
+ declare const initMutators: <TCollections extends CollectionMap>() => BoundMutatorApi<TCollections>;
245
+ export { BindMutatorsContext as B, ClientMutatorContext as C, DIRECT_TRANSACTION_METADATA_KEY as D, MutatorReference as M, BoundMutatorApi as a, BoundMutators as b, ClientMutatorDef as c, CollectionMap as d, MutatorRejectedEvent as e, bindMutators as f, defineMutator as g, initMutators as i };