@lunora/db 1.0.0-alpha.9 → 1.0.0-alpha.91

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 (33) hide show
  1. package/LICENSE.md +6 -0
  2. package/README.md +7 -1
  3. package/dist/collections/index.d.mts +2 -2
  4. package/dist/collections/index.d.ts +2 -2
  5. package/dist/collections/index.mjs +1 -2
  6. package/dist/index.d.mts +144 -5
  7. package/dist/index.d.ts +144 -5
  8. package/dist/index.mjs +1 -8
  9. package/dist/mutators/index.d.mts +2 -2
  10. package/dist/mutators/index.d.ts +2 -2
  11. package/dist/mutators/index.mjs +1 -1
  12. package/dist/packem_shared/CHECKPOINT_FALLBACK_MS-DVkxHO2j.mjs +1 -0
  13. package/dist/packem_shared/DIRECT_TRANSACTION_METADATA_KEY-O-hmqO42.mjs +1 -0
  14. package/dist/packem_shared/OUTBOX_MUTATION_FN_NAME-BfFCughR.mjs +1 -0
  15. package/dist/packem_shared/applyPlanToCollections-C_eyqNF9.mjs +1 -0
  16. package/dist/packem_shared/collection-options.d-bmH3BTGq.d.mts +364 -0
  17. package/dist/packem_shared/collection-options.d-bmH3BTGq.d.ts +364 -0
  18. package/dist/packem_shared/defineCollections-Cz4-m1Mp.mjs +1 -0
  19. package/dist/packem_shared/index.d-BzXEOiP5.d.mts +174 -0
  20. package/dist/packem_shared/index.d-DQsjGcjP.d.ts +235 -0
  21. package/dist/packem_shared/index.d-DlfHbDas.d.ts +174 -0
  22. package/dist/packem_shared/index.d-DrpzHIoj.d.mts +235 -0
  23. package/package.json +3 -2
  24. package/dist/packem_shared/OUTBOX_MUTATION_FN_NAME-Cf8iP6Wa.mjs +0 -102
  25. package/dist/packem_shared/bindMutators-DNhICkoy.mjs +0 -66
  26. package/dist/packem_shared/collection-options.d-lJBOVJgq.d.mts +0 -216
  27. package/dist/packem_shared/collection-options.d-lJBOVJgq.d.ts +0 -216
  28. package/dist/packem_shared/createCheckpointRegistry-SvwBczzv.mjs +0 -127
  29. package/dist/packem_shared/define-collections.d-CdORnl3S.d.mts +0 -150
  30. package/dist/packem_shared/define-collections.d-X2MfU5Es.d.ts +0 -150
  31. package/dist/packem_shared/define-mutators.d-DhO8mWz0.d.ts +0 -80
  32. package/dist/packem_shared/define-mutators.d-HWj94_nL.d.mts +0 -80
  33. package/dist/packem_shared/defineCollections-BJNtgnWB.mjs +0 -105
@@ -0,0 +1,364 @@
1
+ import { OutboxSink, LunoraClient, FunctionReference, SubscriptionError } from '@lunora/client';
2
+ import { CollectionConfig } from '@tanstack/db';
3
+ import { OnlineDetector } from '@tanstack/offline-transactions';
4
+ /**
5
+ * Reserved `mutationFns` key the unified outbox routes raw `client.mutation`
6
+ * offline writes through. `defineCollections` registers a handler under this
7
+ * name that reads `transaction.metadata` (functionPath + args) and replays the
8
+ * write, so a db app's direct mutations ride the same durable executor as its
9
+ * collection inserts instead of the standalone {@link OutboxSink} fallback.
10
+ */
11
+ declare const OUTBOX_MUTATION_FN_NAME = "__lunora_outbox__";
12
+ /**
13
+ * Provenance stamped on a durable write at enqueue time: the two questions a
14
+ * replay cannot answer from the persisted row once the write outlives the
15
+ * session that made it — WHO queued it, and WHICH shard it belongs to.
16
+ *
17
+ * Shared so both replay paths (`db.actions.*` and the reserved
18
+ * `__lunora_outbox__` handler) check the same fields; they drifted apart once
19
+ * already, and only one of them had the identity guard.
20
+ */
21
+ interface WriteProvenance extends Record<string, unknown> {
22
+ /** Issuing identity fingerprint; a replay drops the write when it no longer matches. */
23
+ identity: string | null;
24
+ /** Captured, not re-read at replay: a queued write follows the shard it was made against even if the app reboots pointed at another. */
25
+ shardKey?: string;
26
+ }
27
+ /** The metadata an outbox-routed transaction carries so its replay can call `client.mutation`. */
28
+ interface OutboxMutationMetadata extends WriteProvenance {
29
+ args: Record<string, unknown>;
30
+ clientId: string;
31
+ functionPath: string;
32
+ /** Stable `${clientId}:${mutationId}` replay key; passed back as the mutation id so a committed-but-unacked replay is server-idempotent. */
33
+ idempotencyKey: string;
34
+ mutationId: number;
35
+ }
36
+ /** A committable outbox transaction handle (the `OfflineTransaction` the executor mints). */
37
+ interface OutboxTransaction {
38
+ commit?: () => Promise<unknown>;
39
+ mutate: (callback: () => void) => unknown;
40
+ }
41
+ /**
42
+ * The slice of the TanStack `OfflineExecutor` the {@link createExecutorOutboxSink}
43
+ * drives. Declared structurally so `@lunora/db`'s outbox glue doesn't widen its
44
+ * coupling to the executor's full surface (and stays unit-testable with a fake).
45
+ */
46
+ interface OutboxExecutor {
47
+ createOfflineTransaction: (options: {
48
+ autoCommit?: boolean;
49
+ idempotencyKey?: string;
50
+ metadata?: Record<string, unknown>;
51
+ mutationFnName: string;
52
+ }) => OutboxTransaction;
53
+ getPendingCount: () => number;
54
+ }
55
+ /** Tuning for {@link createExecutorOutboxSink}. */
56
+ interface ExecutorOutboxSinkOptions {
57
+ /** Max persisted-but-unconfirmed writes before `enqueue` rejects with `OFFLINE_QUEUE_OVERFLOW` (default 1000, matching `OfflineQueue`). */
58
+ maxItems?: number;
59
+ /** `mutationFns` key the replay handler is registered under (default {@link OUTBOX_MUTATION_FN_NAME}). */
60
+ mutationFnName?: string;
61
+ }
62
+ /**
63
+ * The blessed {@link OutboxSink} over the TanStack `OfflineExecutor` — the single
64
+ * durable write path for a `@lunora/db` app. It persists each offline write as an
65
+ * executor transaction carrying the `client.mutation` target in `metadata`, then
66
+ * ports the two semantics the executor lacks vs the built-in `OfflineQueue`.
67
+ *
68
+ * First: a `maxItems` cap that **rejects** a new write at capacity with an
69
+ * `OFFLINE_QUEUE_OVERFLOW`-coded error — the {@link OutboxSink} contract, matching
70
+ * `OfflineQueue`. Rejecting (rather than evicting the oldest) preserves the
71
+ * at-least-once promise: an already-persisted write is never silently dropped, and
72
+ * the caller surfaces back-pressure to the issuing mutation (which rolls its
73
+ * optimistic write back).
74
+ * Second: the identity guard lives in the replay handler (`defineCollections`), which drops a write whose captured `identity` no longer matches — see {@link OutboxMutationMetadata}.
75
+ */
76
+ declare const createExecutorOutboxSink: (executor: OutboxExecutor, options?: ExecutorOutboxSinkOptions) => OutboxSink;
77
+ /** A row carrying the Lunora document id. */
78
+ type Row = Record<string, unknown> & {
79
+ _id: string;
80
+ };
81
+ /** The subset of a TanStack DB sync write channel that {@link makeDiffEmit} drives. */
82
+ interface SyncWriter<T extends object> {
83
+ begin: () => void;
84
+ commit: () => void;
85
+ write: (message: {
86
+ type: "insert" | "update";
87
+ value: T;
88
+ } | {
89
+ key: string;
90
+ type: "delete";
91
+ }) => void;
92
+ }
93
+ /** Index a row list into a keyed map. */
94
+ declare const toMap: <T extends object>(rows: ReadonlyArray<T>, getKey: (row: T) => string) => Map<string, T>;
95
+ /**
96
+ * Build an `emit(next)` that diffs a desired keyed snapshot into a collection's
97
+ * sync channel — only changed rows are written, so a reconnect snapshot or a
98
+ * scope change never churns the synced view out from under a pending optimistic
99
+ * row. The last-synced base is tracked in `syncedJson`.
100
+ *
101
+ * Change detection compares rows by `JSON.stringify`, which is key-order
102
+ * sensitive — safe here because server snapshots have stable column order
103
+ * across reconnects (same query projection). A sync source with unstable key
104
+ * ordering would need a structural compare instead.
105
+ *
106
+ * `syncedJson` holds the JSON-serialized form of each last-synced row, keyed
107
+ * by row id. It is the **sole** synced-state map — no parallel row-object map
108
+ * is kept. Each incoming value is serialized exactly once per tick (for both
109
+ * comparison and cache update), so the previous value is never re-serialized.
110
+ *
111
+ * Lifecycle: `syncedJson` is owned by the caller at the same scope as any other
112
+ * per-collection state (outside the `sync.sync` callback), so one map serves
113
+ * every `makeDiffEmit` closure the collection creates and a *within-session*
114
+ * re-delivery of an unchanged snapshot writes nothing.
115
+ *
116
+ * It does NOT survive a sync **restart**, and must not: TanStack drops its
117
+ * synced store on gc cleanup, so the sole caller
118
+ * ({@link file://./collection-options.ts}'s `sync.sync` teardown) clears the map
119
+ * on the way out. A restart that kept the map would diff the server's
120
+ * re-delivered snapshot against rows the store no longer holds, emit zero
121
+ * writes, and leave the restarted collection permanently empty. The map's job is
122
+ * incremental-diff state for one live session, not a durable cache.
123
+ */
124
+ declare const makeDiffEmit: <T extends object>(syncedJson: Map<string, string>, writer: SyncWriter<T>) => (next: Map<string, T>) => void;
125
+ /**
126
+ * Run a Lunora mutation under the outbox's retry policy.
127
+ *
128
+ * The retryable/permanent split keys on whether the failure carries a server
129
+ * application error `code` (set by `@lunora/client`'s rpc when the server returns
130
+ * a `{ error: { code, … } }` envelope — validation, conflict, etc.). A coded
131
+ * error is a definite verdict: surface it as a `NonRetriableError` so the executor
132
+ * stops and TanStack DB rolls the optimistic insert back. Everything without a
133
+ * code is transient — a `fetch` network failure (`TypeError`) or an HTTP/infra
134
+ * blip the rpc surfaces as a code-less `Error` (a 5xx gateway page, a non-JSON
135
+ * body) — so it's rethrown as-is and the durable outbox replays it. Keying on
136
+ * `error instanceof TypeError` alone would wrongly drop the latter.
137
+ */
138
+ declare const runOutboxMutation: (mutate: () => Promise<unknown>) => Promise<void>;
139
+ /**
140
+ * An "always attempt" online detector. We deliberately don't trust
141
+ * `navigator.onLine`: some environments (and Playwright's `setOffline` under
142
+ * Firefox) leave it stuck, which would freeze the outbox. Instead the executor
143
+ * always tries the send and {@link runOutboxMutation}'s transient-error retry
144
+ * handles real offline; the periodic tick nudges the executor to drain the outbox
145
+ * so a queued write replays promptly once connectivity returns.
146
+ *
147
+ * `isOnline` is therefore intentionally always `true` — it gates the executor's
148
+ * attempts, not a UI signal. A consumer that wants to show real connectivity
149
+ * should read `navigator.onLine` itself, separately from this detector.
150
+ */
151
+ declare const createOptimisticOnlineDetector: () => OnlineDetector;
152
+ /** A watermark pair — the two monotonic lines a checkpoint registry gates on. */
153
+ interface CheckpointWatermark {
154
+ /** Op-log cursor the server has durably applied. */
155
+ checkpoint?: number;
156
+ /** Highest `clientSeq` the server has echoed back for this client. */
157
+ mutationId?: number;
158
+ }
159
+ /** Reported when the fallback releases an overlay the sync stream never confirmed. */
160
+ interface CheckpointFallbackEvent {
161
+ /** Which gate released. */
162
+ kind: "checkpoint" | "mutationId";
163
+ /** How long the release waited past the server acknowledgement, in ms. */
164
+ waitedMs: number;
165
+ /** The watermark that was acknowledged but never confirmed by a sync frame. */
166
+ watermark: number;
167
+ }
168
+ /** Counters for {@link CheckpointRegistry.stats} — feeds a debug/diagnostics surface. */
169
+ interface CheckpointRegistryStats {
170
+ /** How many times the fallback timer released an overlay (a non-zero value means sync frames are being lost). */
171
+ fallbacks: number;
172
+ /** Overlays currently waiting on a checkpoint cursor. */
173
+ pendingCheckpointWaiters: number;
174
+ /** Overlays currently waiting on a mutation id. */
175
+ pendingMutationWaiters: number;
176
+ }
177
+ /** Tuning for {@link createCheckpointRegistry}. */
178
+ interface CheckpointRegistryOptions {
179
+ /**
180
+ * How long an {@link CheckpointRegistry.acknowledge}d watermark waits for the
181
+ * authoritative sync frame before the overlay is released anyway. Default 3000.
182
+ * `0` disables the fallback (an overlay then waits forever for the frame — the
183
+ * pre-fallback behavior, which hangs on a dropped poke).
184
+ */
185
+ fallbackMs?: number;
186
+ /**
187
+ * Notified each time the fallback fires. A fallback is never *correct* — it
188
+ * means a poke or `settled` frame that should have confirmed the write never
189
+ * arrived — so this is the hook for a warning or a metric. Defaults to a
190
+ * one-shot `console.warn`.
191
+ */
192
+ onFallback?: (event: CheckpointFallbackEvent) => void;
193
+ }
194
+ /**
195
+ * Resolves the TanStack optimistic-overlay drop against the server's confirmed
196
+ * watermarks. A mutator's optimistic transaction returns `awaitMutationId(id)`
197
+ * (or `awaitCheckpoint(cursor)`); TanStack keeps the overlay until that promise
198
+ * settles, so the row de-duplicates exactly as the synced server value lands — no
199
+ * flash of the optimistic row disappearing then reappearing.
200
+ *
201
+ * Two inputs, deliberately distinct:
202
+ *
203
+ * - {@link CheckpointRegistry.resolve} is the **authoritative** advance, called by whoever owns
204
+ * the watermark stream — a `data`/`delta` frame's `lastMutationId`, or a shape poke's
205
+ * `checkpoint`. The synced rows have landed, so gates open immediately.
206
+ * - {@link CheckpointRegistry.acknowledge} is the **provisional** advance, called when the server
207
+ * has accepted the write (the mutator RPC ack) but the matching rows have not
208
+ * necessarily been delivered yet. Releasing here would drop the overlay before
209
+ * the synced row exists — a visible flicker — so instead it arms a bounded
210
+ * fallback. If the authoritative frame lands first the fallback is cancelled;
211
+ * if it never lands, the overlay is released after `fallbackMs` and the event is
212
+ * reported rather than hanging forever.
213
+ *
214
+ * That pairing is why a lost poke degrades to a late overlay drop instead of a
215
+ * permanently stuck `isPersisted` promise.
216
+ */
217
+ interface CheckpointRegistry {
218
+ /**
219
+ * Record a server-accepted watermark whose rows may not have synced yet: arms
220
+ * the bounded fallback described on {@link CheckpointRegistry}. Safe to call
221
+ * repeatedly; a watermark already passed is a no-op.
222
+ */
223
+ acknowledge: (watermark: CheckpointWatermark) => void;
224
+ /** Resolve once the server has acknowledged the op-log `cursor`. */
225
+ awaitCheckpoint: (cursor: number) => Promise<void>;
226
+ /** Resolve once the server has echoed a `lastMutationId >= id` for this client. */
227
+ awaitMutationId: (id: number) => Promise<void>;
228
+ /**
229
+ * Tear the registry down: `clearTimeout` every armed fallback timer and empty
230
+ * the armed set. Idempotent. A discarded registry (Vite HMR dispose, sign-out)
231
+ * can otherwise hold up to `fallbackMs` of pending `setTimeout`s alive through
232
+ * their closures — keeping a Node/SSR event loop from draining. Distinct from
233
+ * {@link CheckpointRegistry.resolve}: `resolve` settles parked *waiters* (and disarms the timers it
234
+ * subsumes as a side effect); `dispose` guarantees no armed timer survives,
235
+ * independent of any watermark.
236
+ */
237
+ dispose: () => void;
238
+ /** Advance the gates from a sync frame's watermark; later callers past the mark settle immediately. */
239
+ resolve: (watermark: CheckpointWatermark) => void;
240
+ /** Diagnostics counters — notably how often the fallback had to fire. */
241
+ stats: () => CheckpointRegistryStats;
242
+ }
243
+ /** Default fallback window: long enough that a slow-but-arriving poke wins, short enough that a UI isn't visibly stuck. */
244
+ declare const CHECKPOINT_FALLBACK_MS = 3e3;
245
+ /**
246
+ * A standalone checkpoint/mutation-id registry. Prefer {@link getShardCheckpoints}
247
+ * unless you are wiring a bespoke watermark stream — a registry must be shared by
248
+ * every collection on a shard (see that function for why).
249
+ */
250
+ declare const createCheckpointRegistry: (options?: CheckpointRegistryOptions) => CheckpointRegistry;
251
+ /**
252
+ * Release every pending overlay gate for `client` and drop its shard registries.
253
+ *
254
+ * The hot-reload / teardown escape hatch. When a module that owns collections and
255
+ * mutators is replaced — a Vite HMR update, a sign-out that rebuilds the data layer
256
+ * — the *old* bindings may still have transactions parked in `awaitMutationId`. The
257
+ * subscriptions that would have resolved them are gone with the old module, so
258
+ * without this those promises never settle and every one of their
259
+ * `transaction.isPersisted` waiters hangs forever.
260
+ *
261
+ * Resolving to `Infinity` settles the parked waiters (the writes were already sent;
262
+ * the server is authoritative regardless), and dropping the registries means the
263
+ * replacement module's bindings start from a clean per-shard gate.
264
+ *
265
+ * ```ts
266
+ * // In the module that owns the data layer:
267
+ * import.meta.hot?.dispose(() => releaseShardCheckpoints(client));
268
+ * ```
269
+ */
270
+ declare const releaseShardCheckpoints: (client: LunoraClient) => void;
271
+ /**
272
+ * The shared checkpoint registry for `client` + `shardKey` — created on first use.
273
+ * This is the registry {@link lunoraCollectionOptions} and
274
+ * {@link import("./define-mutators").bindMutators} default to, which is what makes
275
+ * a multi-collection shard work without the caller relaying pokes between
276
+ * registries by hand.
277
+ *
278
+ * `options` applies **only when the registry is created**. Because the point is that
279
+ * every collection and mutator on a shard shares one gate, a later call cannot
280
+ * retune an existing registry — it returns the existing one and `options` is ignored.
281
+ * To control `fallbackMs` / `onFallback`, build the registry yourself with
282
+ * {@link createCheckpointRegistry} and pass it explicitly to every
283
+ * `lunoraCollectionOptions` and `bindMutators` call for that shard.
284
+ */
285
+ declare const getShardCheckpoints: (client: LunoraClient, shardKey?: string, options?: CheckpointRegistryOptions) => CheckpointRegistry;
286
+ /**
287
+ * A replication-shape sync source (the local-first partial-replication path).
288
+ * Mutually exclusive with {@link LunoraCollectionConfig.list}: the collection
289
+ * live-syncs the named shape's rowset via the client's poke protocol
290
+ * (`subscribeShape`) instead of a full-table `list` query subscription.
291
+ */
292
+ interface ShapeSource {
293
+ /** Validated shape parameters (the partition selector — e.g. `{ channelId }`). */
294
+ args?: Record<string, unknown>;
295
+ /** The `defineShape` export name registered in `LUNORA_SHAPES`. */
296
+ name: string;
297
+ /** Routes the subscription to a specific shard's DO when the table is sharded. */
298
+ shardKey?: string;
299
+ }
300
+ /** Declarative inputs for {@link lunoraCollectionOptions}. */
301
+ interface LunoraCollectionConfig<TRow extends Row> {
302
+ /**
303
+ * The registry optimistic overlays are gated on. Defaults to the shared
304
+ * per-shard registry ({@link getShardCheckpoints}), which is what a
305
+ * multi-collection shard needs — pass one explicitly only to isolate a
306
+ * collection's gate (tests) or to supply custom {@link CheckpointRegistryOptions}.
307
+ */
308
+ checkpoints?: CheckpointRegistry;
309
+ /** The Lunora client to subscribe through. */
310
+ client: LunoraClient;
311
+ /** Row key extractor — defaults to `row._id`. */
312
+ getKey?: (row: TRow) => string;
313
+ /** Collection id (TanStack identity) — defaults to the `list` function path (or `shape:` + the shape name). */
314
+ id?: string;
315
+ /** The Lunora query that lists the rows (the full-table sync source). Mutually exclusive with {@link LunoraCollectionConfig.shape}. */
316
+ list?: FunctionReference;
317
+ /**
318
+ * When the collection starts syncing. `"lazy"` (default) starts on the first
319
+ * `useLiveQuery` subscriber; `"eager"` starts at creation (TanStack's
320
+ * `startSync`) — for small "instant" reference data you want warm at boot.
321
+ * No effect on a `scopeBy` collection, which has nothing to sync until scoped.
322
+ * (Even eager, TanStack pauses sync while there are no subscribers, per its
323
+ * `gcTime` lifecycle — "warm while referenced", not pinned forever.)
324
+ */
325
+ load?: "eager" | "lazy";
326
+ /** Notified when the underlying subscription errors; the collection always leaves `loading` regardless. */
327
+ onError?: (error: SubscriptionError) => void;
328
+ /** When set, the collection stays empty until {@link LunoraCollectionOptions.scope} points it at args (sharded). */
329
+ scopeBy?: string;
330
+ /** A replication shape as the sync source (partial replication). Mutually exclusive with {@link LunoraCollectionConfig.list}. */
331
+ shape?: ShapeSource;
332
+ /**
333
+ * Routes the `list` subscription — and the confirmed-mutation watermark its
334
+ * data/`settled` frames advance the checkpoint gate from — to a specific
335
+ * shard's DO. Applies to the `list` source; a `shape` carries its own
336
+ * {@link ShapeSource.shardKey}. Without it the list path falls back to the
337
+ * default ("") watermark bucket, which must not be compared against a
338
+ * per-shard mutator's sequence line (it would drop a sharded overlay early or
339
+ * hang it forever).
340
+ */
341
+ shardKey?: string;
342
+ }
343
+ /** The result of {@link lunoraCollectionOptions}: a TanStack collection config plus its sync controls. */
344
+ interface LunoraCollectionOptions<TRow extends Row> {
345
+ /** Resolves optimistic-overlay drops against the server's confirmed watermarks. */
346
+ checkpoints: CheckpointRegistry;
347
+ /** Pass to TanStack's `createCollection`. */
348
+ config: CollectionConfig<TRow, string>;
349
+ /** Re-point a `scopeBy` collection's subscription (omit `args` to detach). No-op for unscoped collections. */
350
+ scope: (args?: Record<string, unknown>) => void;
351
+ }
352
+ /**
353
+ * Build a TanStack DB collection config (+ sync controls) that live-syncs a
354
+ * Lunora `list` query through the client. This is the reusable core lifted out of
355
+ * {@link import("./define-collections").defineCollections}: the same `makeDiffEmit`
356
+ * diff-into-channel, `autoIndex:"eager"` + B-tree indexes, scoped-resubscribe, and
357
+ * fail-safe `markReady`-on-error behavior, exposed as a standalone
358
+ * collection-options creator so apps (and codegen) can compose it directly.
359
+ *
360
+ * The returned `checkpoints` registry lets a mutator runtime resolve optimistic
361
+ * overlays against confirmed server watermarks (see {@link CheckpointRegistry}).
362
+ */
363
+ declare const lunoraCollectionOptions: <TRow extends Row>(options: LunoraCollectionConfig<TRow>) => LunoraCollectionOptions<TRow>;
364
+ 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, OutboxExecutor as g, OutboxMutationMetadata as h, createCheckpointRegistry as i, createExecutorOutboxSink as j, createOptimisticOnlineDetector as k, getShardCheckpoints as l, lunoraCollectionOptions as m, makeDiffEmit as n, runOutboxMutation as o, releaseShardCheckpoints as r, toMap as t };
@@ -0,0 +1 @@
1
+ import{lunoraCollectionOptions as T}from"./CHECKPOINT_FALLBACK_MS-DVkxHO2j.mjs";import{createCollection as M,safeRandomUUID as U}from"@tanstack/db";import{startOfflineExecutor as _,NonRetriableError as g}from"@tanstack/offline-transactions";import{createOutboxCarrier as B,createOptimisticOnlineDetector as R,OUTBOX_MUTATION_FN_NAME as K,registerOutboxCarrier as A,runOutboxMutation as I}from"./OUTBOX_MUTATION_FN_NAME-BfFCughR.mjs";const N=new WeakMap,D=(n,r)=>{let e=N.get(n);e===void 0&&(e=new Map,N.set(n,e));const a=e.get(r)??0;e.set(r,a+1),a===1&&console.warn(`[@lunora/db] table "${r}" 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).`)},p=(n,r)=>{try{n.onWriteRejected?.(r)}catch{}},v=(n,r)=>{const e=n.replayIdentityVerdict(r?.identity);if(e==="mismatch")throw new g("outbox write dropped: identity changed since it was queued");if(e==="unknown")throw new Error("outbox write deferred: no identity established yet")},k=(n,r,e={})=>{const a={},w={},m={},b=Object.entries(r);for(const[o,t]of b){const i=t.insert;D(n,o);const{config:d,scope:h}=T({client:n,getKey:t.getKey,id:o,list:t.list,...t.load===void 0?{}:{load:t.load},onError:t.onError,scopeBy:t.scopeBy,shardKey:t.shardKey});a[o]=M(d),t.scopeBy!==void 0&&(w[o]=h),i&&(m[o]=async({idempotencyKey:l,transaction:u})=>{const c=u.metadata;for(const[y,F]of u.mutations.entries()){const x=F.modified,E=`${l}:${String(y)}`;try{v(n,c),await I(()=>n.mutation(i.mutation,i.toArgs(x),{mutationId:E,shardKey:c.shardKey}))}catch(f){throw f instanceof g&&p(e,{code:f.code,collection:o,error:f,row:x}),f}}})}m[K]=async({transaction:o})=>{const t=o.metadata;if(t)try{v(n,t),await I(()=>n.mutation({__lunoraRef:t.functionPath},t.args,{mutationId:t.idempotencyKey,shardKey:t.shardKey}))}catch(i){throw i instanceof g&&p(e,{code:i.code,collection:t.functionPath,error:i}),i}};const O=B(),s=_({collections:{...a,[K]:O},mutationFns:m,onlineDetector:R(),...e.onLeadershipChange?{onLeadershipChange:e.onLeadershipChange}:{},...e.onStorageFailure?{onStorageFailure:e.onStorageFailure}:{},onUnknownMutationFn:(o,t)=>{p(e,{code:"UNKNOWN_MUTATION_FN",collection:o,error:new Error(`offline write dropped: mutation "${o}" no longer exists (removed or renamed in a deploy?)`),row:t.mutations[0]?.modified})}});A(s,O);const C={};for(const[o,t]of b){const i=t.insert,d=a[o];!i||!d||(C[o]=h=>{const l=U(),u={identity:n.currentIdentity(),shardKey:t.shardKey},c=s.createOfflineTransaction({autoCommit:!1,metadata:u,mutationFnName:o}),y=c.mutate(()=>{d.insert(i.optimistic(h,l))});return c.commit().catch(()=>{}),{id:l,transaction:y}})}return{actions:C,collections:a,executor:s,pendingCount:()=>s.getPendingCount(),scope:w}};export{k as defineCollections};
@@ -0,0 +1,174 @@
1
+ import { R as Row } from "./collection-options.d-bmH3BTGq.mjs";
2
+ import { FunctionReference, SubscriptionError, LunoraClient } from '@lunora/client';
3
+ import { Transaction, Collection } from '@tanstack/db';
4
+ import { OfflineExecutor, StorageDiagnostic } from '@tanstack/offline-transactions';
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. `insert` writes are
56
+ * routed with it too, and to the shard that was set when the write was
57
+ * queued: subscriptions and the writes they observe have to land on the same
58
+ * Durable Object, and the server derives no shard from a mutation's args.
59
+ */
60
+ shardKey?: string;
61
+ }
62
+ type AnyDef = CollectionDef<any, any>;
63
+ /**
64
+ * The public row type a collection exposes — the element type of its `list`
65
+ * query's return, with no `& Row`: a `Collection<T>` is invariant in `T`, so the
66
+ * exposed type must be exactly the document type, not a subtype.
67
+ */
68
+ type RowOf<C extends AnyDef> = C["list"] extends FunctionReference<infer _K, infer _A, infer R> ? Element<R> : never;
69
+ /** The action input type, inferred structurally from the def's optimistic insert. */
70
+ type InputOf<C> = C extends {
71
+ insert: {
72
+ optimistic: (input: infer I, id: string) => unknown;
73
+ };
74
+ } ? I : never;
75
+ /** A queued write that was permanently dropped, passed to {@link DefineCollectionsOptions.onWriteRejected}. */
76
+ interface WriteRejectedEvent {
77
+ /**
78
+ * The machine-readable reason — the server's error `code` (e.g. `CONFLICT`,
79
+ * `FORBIDDEN`), or `UNKNOWN_MUTATION_FN` when the write referenced a collection
80
+ * that no longer exists (removed in a deploy). Mirrors the client's
81
+ * `MutationSettledEvent.code` so a consumer can branch on the verdict.
82
+ */
83
+ code?: string;
84
+ /** The collection/table name the write targeted. */
85
+ collection: string;
86
+ /** The error that dropped the write (message carried by the underlying `NonRetriableError`). */
87
+ error: Error;
88
+ /**
89
+ * The optimistic row being rolled back (the rollback follows the callback).
90
+ * Absent only if the dropped transaction carried no recoverable row (e.g. some
91
+ * `UNKNOWN_MUTATION_FN` cases).
92
+ */
93
+ row?: Row;
94
+ }
95
+ /** Options for {@link defineCollections}. */
96
+ interface DefineCollectionsOptions {
97
+ /**
98
+ * Invoked when a leadership change occurs across tabs (only the leader tab
99
+ * drains the durable outbox). Informational — useful for diagnostics; the
100
+ * library handles the election itself.
101
+ */
102
+ onLeadershipChange?: (isLeader: boolean) => void;
103
+ /**
104
+ * Invoked when the durable outbox's storage layer fails — IndexedDB
105
+ * unavailable (private mode), blocked, or quota exceeded. The standalone
106
+ * client surfaces this via `offlineQueue.onPersistenceError`; this is the
107
+ * collection-layer counterpart. A storage failure means a write is NOT durable
108
+ * and won't survive a reload, so surface it (e.g. "your change may not be
109
+ * saved if you close this tab").
110
+ */
111
+ onStorageFailure?: (diagnostic: StorageDiagnostic) => void;
112
+ /**
113
+ * Invoked as a queued write is permanently dropped: a coded application error
114
+ * from the server (validation, RLS denial, conflict, surfaced as a
115
+ * `NonRetriableError`), OR a write whose target collection no longer exists —
116
+ * removed/renamed in a deploy (`code: "UNKNOWN_MUTATION_FN"`). This is the
117
+ * aggregate, fire-and-forget-safe channel: unlike awaiting the per-action
118
+ * `transaction` returned by `actions[name](...)`, it fires even when the caller
119
+ * never retained that handle, so a UI can surface "couldn't save" instead of a
120
+ * silently vanishing row. Transient failures (offline, 5xx) are retried by the
121
+ * outbox, not reported here.
122
+ *
123
+ * Timing: the callback runs at the point of rejection; the executor's
124
+ * optimistic-row rollback follows immediately after. The event's `row` is the
125
+ * (about-to-be-removed) optimistic row, so don't depend on the collection
126
+ * already reflecting the removal from inside the handler — use `row`/`error`
127
+ * directly (e.g. for a toast).
128
+ */
129
+ onWriteRejected?: (event: WriteRejectedEvent) => void;
130
+ }
131
+ /** The wired data layer `defineCollections` returns. */
132
+ interface LunoraDb<D extends Record<string, AnyDef>> {
133
+ /** Optimistic, durable, retried write actions — present for `insert` collections. */
134
+ actions: { [K in keyof D]: D[K] extends {
135
+ insert: object;
136
+ } ? (input: InputOf<D[K]>) => {
137
+ id: string;
138
+ transaction: Transaction;
139
+ } : never; };
140
+ /** The live, synced collections — feed these to `useLiveQuery`. */
141
+ collections: { [K in keyof D]: Collection<RowOf<D[K]>, string>; };
142
+ /** The shared offline executor (the outbox). */
143
+ executor: OfflineExecutor;
144
+ /**
145
+ * Number of writes still pending in the durable outbox — the depth for a
146
+ * "N changes waiting to sync" indicator. A convenience over reaching through
147
+ * `executor.getPendingCount()`. **Pull-only** (the underlying TanStack
148
+ * executor exposes no change subscription): read it after a `db.actions.*`
149
+ * call and on connection-status changes, or poll. The standalone
150
+ * `LunoraClient` exposes the reactive `onPendingChange` for its built-in queue.
151
+ */
152
+ pendingCount: () => number;
153
+ /** Re-point a `scopeBy` collection's subscription (omit `args` to detach) — present for scoped collections. */
154
+ scope: { [K in keyof D]: D[K] extends {
155
+ scopeBy: string;
156
+ } ? (args?: Record<string, unknown>) => void : never; };
157
+ }
158
+ /**
159
+ * Wire a set of Lunora tables into a TanStack DB data layer in one declaration:
160
+ * each entry becomes a live, auto-indexed collection synced from its `list` query,
161
+ * and `insert` entries get an optimistic write action backed by the
162
+ * offline-transactions outbox (durable, retried, client-id-keyed). Scoped
163
+ * (`scopeBy`) collections are re-pointable for sharded queries.
164
+ *
165
+ * This is the hand-written form; `@lunora/codegen` can emit a fully-typed call to
166
+ * it from `schema.ts`, so an app writes nothing.
167
+ *
168
+ * Keep a single instance and treat the returned collections as the one source of
169
+ * truth per table — do not mirror rows into a parallel store, or derived indexes
170
+ * built from the copy can silently read stale data (see the `@lunora/db` docs,
171
+ * "One source of truth per table").
172
+ */
173
+ declare const defineCollections: <D extends Record<string, AnyDef>>(client: LunoraClient, defs: D, options?: DefineCollectionsOptions) => LunoraDb<D>;
174
+ export { CollectionDef as C, DefineCollectionsOptions as D, InsertBinding as I, LunoraDb as L, WriteRejectedEvent as W, defineCollections as d };