@lunora/db 0.0.0 → 1.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +105 -0
- package/README.md +134 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/collections/index.d.mts +5 -0
- package/dist/collections/index.d.ts +5 -0
- package/dist/collections/index.mjs +2 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.mjs +8 -0
- package/dist/mutators/index.d.mts +5 -0
- package/dist/mutators/index.d.ts +5 -0
- package/dist/mutators/index.mjs +1 -0
- package/dist/packem_shared/OUTBOX_MUTATION_FN_NAME-Cf8iP6Wa.mjs +102 -0
- package/dist/packem_shared/bindMutators-DNhICkoy.mjs +66 -0
- package/dist/packem_shared/collection-options.d-lJBOVJgq.d.mts +216 -0
- package/dist/packem_shared/collection-options.d-lJBOVJgq.d.ts +216 -0
- package/dist/packem_shared/createCheckpointRegistry-SvwBczzv.mjs +127 -0
- package/dist/packem_shared/define-collections.d-C01DaKJO.d.ts +159 -0
- package/dist/packem_shared/define-collections.d-Cc1p0x3t.d.mts +159 -0
- package/dist/packem_shared/define-mutators.d-DhO8mWz0.d.ts +80 -0
- package/dist/packem_shared/define-mutators.d-HWj94_nL.d.mts +80 -0
- package/dist/packem_shared/defineCollections-BWB7CJzY.mjs +105 -0
- package/package.json +52 -17
|
@@ -0,0 +1,216 @@
|
|
|
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
|
+
/** The metadata an outbox-routed transaction carries so its replay can call `client.mutation`. */
|
|
13
|
+
interface OutboxMutationMetadata extends Record<string, unknown> {
|
|
14
|
+
args: Record<string, unknown>;
|
|
15
|
+
clientId: string;
|
|
16
|
+
functionPath: string;
|
|
17
|
+
/** Stable `${clientId}:${mutationId}` replay key; passed back as the mutation id so a committed-but-unacked replay is server-idempotent. */
|
|
18
|
+
idempotencyKey: string;
|
|
19
|
+
/** Issuing identity fingerprint; the replay handler drops the write when it no longer matches. */
|
|
20
|
+
identity: string | null;
|
|
21
|
+
mutationId: number;
|
|
22
|
+
shardKey?: string;
|
|
23
|
+
}
|
|
24
|
+
/** A committable outbox transaction handle (the `OfflineTransaction` the executor mints). */
|
|
25
|
+
interface OutboxTransaction {
|
|
26
|
+
mutate: (callback: () => void) => unknown;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The slice of the TanStack `OfflineExecutor` the {@link createExecutorOutboxSink}
|
|
30
|
+
* drives. Declared structurally so `@lunora/db`'s outbox glue doesn't widen its
|
|
31
|
+
* coupling to the executor's full surface (and stays unit-testable with a fake).
|
|
32
|
+
*/
|
|
33
|
+
interface OutboxExecutor {
|
|
34
|
+
createOfflineTransaction: (options: {
|
|
35
|
+
autoCommit?: boolean;
|
|
36
|
+
idempotencyKey?: string;
|
|
37
|
+
metadata?: Record<string, unknown>;
|
|
38
|
+
mutationFnName: string;
|
|
39
|
+
}) => OutboxTransaction;
|
|
40
|
+
getPendingCount: () => number;
|
|
41
|
+
}
|
|
42
|
+
/** Tuning for {@link createExecutorOutboxSink}. */
|
|
43
|
+
interface ExecutorOutboxSinkOptions {
|
|
44
|
+
/** Max persisted-but-unconfirmed writes before `enqueue` rejects with `OFFLINE_QUEUE_OVERFLOW` (default 1000, matching `OfflineQueue`). */
|
|
45
|
+
maxItems?: number;
|
|
46
|
+
/** `mutationFns` key the replay handler is registered under (default {@link OUTBOX_MUTATION_FN_NAME}). */
|
|
47
|
+
mutationFnName?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The blessed {@link OutboxSink} over the TanStack `OfflineExecutor` — the single
|
|
51
|
+
* durable write path for a `@lunora/db` app. It persists each offline write as an
|
|
52
|
+
* executor transaction carrying the `client.mutation` target in `metadata`, then
|
|
53
|
+
* ports the two semantics the executor lacks vs the built-in `OfflineQueue`.
|
|
54
|
+
*
|
|
55
|
+
* First: a `maxItems` cap that **rejects** a new write at capacity with an
|
|
56
|
+
* `OFFLINE_QUEUE_OVERFLOW`-coded error — the {@link OutboxSink} contract, matching
|
|
57
|
+
* `OfflineQueue`. Rejecting (rather than evicting the oldest) preserves the
|
|
58
|
+
* at-least-once promise: an already-persisted write is never silently dropped, and
|
|
59
|
+
* the caller surfaces back-pressure to the issuing mutation (which rolls its
|
|
60
|
+
* optimistic write back).
|
|
61
|
+
* Second: the identity guard lives in the replay handler (`defineCollections`), which drops a write whose captured `identity` no longer matches — see {@link OutboxMutationMetadata}.
|
|
62
|
+
*/
|
|
63
|
+
declare const createExecutorOutboxSink: (executor: OutboxExecutor, options?: ExecutorOutboxSinkOptions) => OutboxSink;
|
|
64
|
+
/** A row carrying the Lunora document id. */
|
|
65
|
+
type Row = Record<string, unknown> & {
|
|
66
|
+
_id: string;
|
|
67
|
+
};
|
|
68
|
+
/** The subset of a TanStack DB sync write channel that {@link makeDiffEmit} drives. */
|
|
69
|
+
interface SyncWriter<T extends object> {
|
|
70
|
+
begin: () => void;
|
|
71
|
+
commit: () => void;
|
|
72
|
+
write: (message: {
|
|
73
|
+
type: "insert" | "update";
|
|
74
|
+
value: T;
|
|
75
|
+
} | {
|
|
76
|
+
key: string;
|
|
77
|
+
type: "delete";
|
|
78
|
+
}) => void;
|
|
79
|
+
}
|
|
80
|
+
/** Index a row list into a keyed map. */
|
|
81
|
+
declare const toMap: <T extends object>(rows: ReadonlyArray<T>, getKey: (row: T) => string) => Map<string, T>;
|
|
82
|
+
/**
|
|
83
|
+
* Build an `emit(next)` that diffs a desired keyed snapshot into a collection's
|
|
84
|
+
* sync channel — only changed rows are written, so a reconnect snapshot or a
|
|
85
|
+
* scope change never churns the synced view out from under a pending optimistic
|
|
86
|
+
* row. The last-synced base is tracked in `syncedJson`.
|
|
87
|
+
*
|
|
88
|
+
* Change detection compares rows by `JSON.stringify`, which is key-order
|
|
89
|
+
* sensitive — safe here because server snapshots have stable column order
|
|
90
|
+
* across reconnects (same query projection). A sync source with unstable key
|
|
91
|
+
* ordering would need a structural compare instead.
|
|
92
|
+
*
|
|
93
|
+
* `syncedJson` holds the JSON-serialized form of each last-synced row, keyed
|
|
94
|
+
* by row id. It is the **sole** synced-state map — no parallel row-object map
|
|
95
|
+
* is kept. Each incoming value is serialized exactly once per tick (for both
|
|
96
|
+
* comparison and cache update), so the previous value is never re-serialized.
|
|
97
|
+
*
|
|
98
|
+
* Lifecycle: `syncedJson` must be owned by the caller at the same scope as any
|
|
99
|
+
* other per-collection state (e.g. outside the `sync.sync` callback), so the
|
|
100
|
+
* cache persists correctly across sync restarts. A new `makeDiffEmit` closure
|
|
101
|
+
* created on restart receives the same map reference and starts from the
|
|
102
|
+
* committed synced state — no spurious diffs on reconnect.
|
|
103
|
+
*/
|
|
104
|
+
declare const makeDiffEmit: <T extends object>(syncedJson: Map<string, string>, writer: SyncWriter<T>) => (next: Map<string, T>) => void;
|
|
105
|
+
/**
|
|
106
|
+
* Run a Lunora mutation under the outbox's retry policy.
|
|
107
|
+
*
|
|
108
|
+
* The retryable/permanent split keys on whether the failure carries a server
|
|
109
|
+
* application error `code` (set by `@lunora/client`'s rpc when the server returns
|
|
110
|
+
* a `{ error: { code, … } }` envelope — validation, conflict, etc.). A coded
|
|
111
|
+
* error is a definite verdict: surface it as a `NonRetriableError` so the executor
|
|
112
|
+
* stops and TanStack DB rolls the optimistic insert back. Everything without a
|
|
113
|
+
* code is transient — a `fetch` network failure (`TypeError`) or an HTTP/infra
|
|
114
|
+
* blip the rpc surfaces as a code-less `Error` (a 5xx gateway page, a non-JSON
|
|
115
|
+
* body) — so it's rethrown as-is and the durable outbox replays it. Keying on
|
|
116
|
+
* `error instanceof TypeError` alone would wrongly drop the latter.
|
|
117
|
+
*/
|
|
118
|
+
declare const runOutboxMutation: (mutate: () => Promise<unknown>) => Promise<void>;
|
|
119
|
+
/**
|
|
120
|
+
* An "always attempt" online detector. We deliberately don't trust
|
|
121
|
+
* `navigator.onLine`: some environments (and Playwright's `setOffline` under
|
|
122
|
+
* Firefox) leave it stuck, which would freeze the outbox. Instead the executor
|
|
123
|
+
* always tries the send and {@link runOutboxMutation}'s transient-error retry
|
|
124
|
+
* handles real offline; the periodic tick nudges the executor to drain the outbox
|
|
125
|
+
* so a queued write replays promptly once connectivity returns.
|
|
126
|
+
*
|
|
127
|
+
* `isOnline` is therefore intentionally always `true` — it gates the executor's
|
|
128
|
+
* attempts, not a UI signal. A consumer that wants to show real connectivity
|
|
129
|
+
* should read `navigator.onLine` itself, separately from this detector.
|
|
130
|
+
*/
|
|
131
|
+
declare const createOptimisticOnlineDetector: () => OnlineDetector;
|
|
132
|
+
/**
|
|
133
|
+
* Resolves the TanStack optimistic-overlay drop against the server's confirmed
|
|
134
|
+
* watermarks. A mutator's optimistic transaction returns `awaitMutationId(id)`
|
|
135
|
+
* (or `awaitCheckpoint(cursor)`); TanStack keeps the overlay until that promise
|
|
136
|
+
* settles, so the row de-duplicates exactly as the synced server value lands — no
|
|
137
|
+
* flash of the optimistic row disappearing then reappearing.
|
|
138
|
+
*
|
|
139
|
+
* `resolve` is called by whoever owns the watermark stream — a `data`/`delta`
|
|
140
|
+
* frame's `lastMutationId`, or a shape poke's `checkpoint` — to advance the gates.
|
|
141
|
+
*/
|
|
142
|
+
interface CheckpointRegistry {
|
|
143
|
+
/** Resolve once the server has acknowledged the op-log `cursor`. */
|
|
144
|
+
awaitCheckpoint: (cursor: number) => Promise<void>;
|
|
145
|
+
/** Resolve once the server has echoed a `lastMutationId >= id` for this client. */
|
|
146
|
+
awaitMutationId: (id: number) => Promise<void>;
|
|
147
|
+
/** Advance the gates from a frame's watermark; later callers past the mark settle immediately. */
|
|
148
|
+
resolve: (watermark: {
|
|
149
|
+
checkpoint?: number;
|
|
150
|
+
mutationId?: number;
|
|
151
|
+
}) => void;
|
|
152
|
+
}
|
|
153
|
+
/** A standalone checkpoint/mutation-id registry (also embedded in {@link lunoraCollectionOptions}). */
|
|
154
|
+
declare const createCheckpointRegistry: () => CheckpointRegistry;
|
|
155
|
+
/**
|
|
156
|
+
* A replication-shape sync source (the local-first partial-replication path).
|
|
157
|
+
* Mutually exclusive with {@link LunoraCollectionConfig.list}: the collection
|
|
158
|
+
* live-syncs the named shape's rowset via the client's poke protocol
|
|
159
|
+
* (`subscribeShape`) instead of a full-table `list` query subscription.
|
|
160
|
+
*/
|
|
161
|
+
interface ShapeSource {
|
|
162
|
+
/** Validated shape parameters (the partition selector — e.g. `{ channelId }`). */
|
|
163
|
+
args?: Record<string, unknown>;
|
|
164
|
+
/** The `defineShape` export name registered in `LUNORA_SHAPES`. */
|
|
165
|
+
name: string;
|
|
166
|
+
/** Routes the subscription to a specific shard's DO when the table is sharded. */
|
|
167
|
+
shardKey?: string;
|
|
168
|
+
}
|
|
169
|
+
/** Declarative inputs for {@link lunoraCollectionOptions}. */
|
|
170
|
+
interface LunoraCollectionConfig<TRow extends Row> {
|
|
171
|
+
/** The Lunora client to subscribe through. */
|
|
172
|
+
client: LunoraClient;
|
|
173
|
+
/** Row key extractor — defaults to `row._id`. */
|
|
174
|
+
getKey?: (row: TRow) => string;
|
|
175
|
+
/** Collection id (TanStack identity) — defaults to the `list` function path (or `shape:` + the shape name). */
|
|
176
|
+
id?: string;
|
|
177
|
+
/** The Lunora query that lists the rows (the full-table sync source). Mutually exclusive with {@link LunoraCollectionConfig.shape}. */
|
|
178
|
+
list?: FunctionReference;
|
|
179
|
+
/**
|
|
180
|
+
* When the collection starts syncing. `"lazy"` (default) starts on the first
|
|
181
|
+
* `useLiveQuery` subscriber; `"eager"` starts at creation (TanStack's
|
|
182
|
+
* `startSync`) — for small "instant" reference data you want warm at boot.
|
|
183
|
+
* No effect on a `scopeBy` collection, which has nothing to sync until scoped.
|
|
184
|
+
* (Even eager, TanStack pauses sync while there are no subscribers, per its
|
|
185
|
+
* `gcTime` lifecycle — "warm while referenced", not pinned forever.)
|
|
186
|
+
*/
|
|
187
|
+
load?: "eager" | "lazy";
|
|
188
|
+
/** Notified when the underlying subscription errors; the collection always leaves `loading` regardless. */
|
|
189
|
+
onError?: (error: SubscriptionError) => void;
|
|
190
|
+
/** When set, the collection stays empty until {@link LunoraCollectionOptions.scope} points it at args (sharded). */
|
|
191
|
+
scopeBy?: string;
|
|
192
|
+
/** A replication shape as the sync source (partial replication). Mutually exclusive with {@link LunoraCollectionConfig.list}. */
|
|
193
|
+
shape?: ShapeSource;
|
|
194
|
+
}
|
|
195
|
+
/** The result of {@link lunoraCollectionOptions}: a TanStack collection config plus its sync controls. */
|
|
196
|
+
interface LunoraCollectionOptions<TRow extends Row> {
|
|
197
|
+
/** Resolves optimistic-overlay drops against the server's confirmed watermarks. */
|
|
198
|
+
checkpoints: CheckpointRegistry;
|
|
199
|
+
/** Pass to TanStack's `createCollection`. */
|
|
200
|
+
config: CollectionConfig<TRow, string>;
|
|
201
|
+
/** Re-point a `scopeBy` collection's subscription (omit `args` to detach). No-op for unscoped collections. */
|
|
202
|
+
scope: (args?: Record<string, unknown>) => void;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Build a TanStack DB collection config (+ sync controls) that live-syncs a
|
|
206
|
+
* Lunora `list` query through the client. This is the reusable core lifted out of
|
|
207
|
+
* {@link import("./define-collections").defineCollections}: the same `makeDiffEmit`
|
|
208
|
+
* diff-into-channel, `autoIndex:"eager"` + B-tree indexes, scoped-resubscribe, and
|
|
209
|
+
* fail-safe `markReady`-on-error behavior, exposed as a standalone
|
|
210
|
+
* collection-options creator so apps (and codegen) can compose it directly.
|
|
211
|
+
*
|
|
212
|
+
* The returned `checkpoints` registry lets a mutator runtime resolve optimistic
|
|
213
|
+
* overlays against confirmed server watermarks (see {@link CheckpointRegistry}).
|
|
214
|
+
*/
|
|
215
|
+
declare const lunoraCollectionOptions: <TRow extends Row>(options: LunoraCollectionConfig<TRow>) => LunoraCollectionOptions<TRow>;
|
|
216
|
+
export { CheckpointRegistry as C, ExecutorOutboxSinkOptions as E, LunoraCollectionConfig as L, OUTBOX_MUTATION_FN_NAME as O, Row as R, SyncWriter as S, LunoraCollectionOptions as a, OutboxExecutor as b, createCheckpointRegistry as c, OutboxMutationMetadata as d, createExecutorOutboxSink as e, createOptimisticOnlineDetector as f, lunoraCollectionOptions as l, makeDiffEmit as m, runOutboxMutation as r, toMap as t };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { BTreeIndex } from '@tanstack/db';
|
|
2
|
+
import { toMap, makeDiffEmit } from './OUTBOX_MUTATION_FN_NAME-Cf8iP6Wa.mjs';
|
|
3
|
+
|
|
4
|
+
const createGate = () => {
|
|
5
|
+
let highest = Number.NEGATIVE_INFINITY;
|
|
6
|
+
const waiters = [];
|
|
7
|
+
return {
|
|
8
|
+
advance: (value) => {
|
|
9
|
+
if (value <= highest) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
highest = value;
|
|
13
|
+
for (let index = waiters.length - 1; index >= 0; index -= 1) {
|
|
14
|
+
const waiter = waiters[index];
|
|
15
|
+
if (waiter && waiter.threshold <= highest) {
|
|
16
|
+
waiter.resolve();
|
|
17
|
+
waiters.splice(index, 1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
await: (threshold) => {
|
|
22
|
+
if (threshold <= highest) {
|
|
23
|
+
return Promise.resolve();
|
|
24
|
+
}
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
waiters.push({ resolve, threshold });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
const createCheckpointRegistry = () => {
|
|
32
|
+
const checkpointGate = createGate();
|
|
33
|
+
const mutationGate = createGate();
|
|
34
|
+
return {
|
|
35
|
+
awaitCheckpoint: (cursor) => checkpointGate.await(cursor),
|
|
36
|
+
awaitMutationId: (id) => mutationGate.await(id),
|
|
37
|
+
resolve: ({ checkpoint, mutationId }) => {
|
|
38
|
+
if (checkpoint !== void 0) {
|
|
39
|
+
checkpointGate.advance(checkpoint);
|
|
40
|
+
}
|
|
41
|
+
if (mutationId !== void 0) {
|
|
42
|
+
mutationGate.advance(mutationId);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const lunoraCollectionOptions = (options) => {
|
|
48
|
+
if (options.list === void 0 === (options.shape === void 0)) {
|
|
49
|
+
throw new Error("lunoraCollectionOptions: pass exactly one of `list` or `shape`");
|
|
50
|
+
}
|
|
51
|
+
const getKey = options.getKey ?? ((row) => row._id);
|
|
52
|
+
const checkpoints = createCheckpointRegistry();
|
|
53
|
+
const syncedJson = /* @__PURE__ */ new Map();
|
|
54
|
+
let emit;
|
|
55
|
+
let unsubscribe;
|
|
56
|
+
let onErrorHandler;
|
|
57
|
+
const openSubscription = (args, onReady) => {
|
|
58
|
+
const onRows = (data) => {
|
|
59
|
+
emit?.(toMap(data, getKey));
|
|
60
|
+
onReady?.();
|
|
61
|
+
if (options.shape === void 0) {
|
|
62
|
+
checkpoints.resolve({ mutationId: options.client.confirmedMutationWatermark() });
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const onError = (error) => onErrorHandler?.(error);
|
|
66
|
+
const onCheckpoint = (watermark) => {
|
|
67
|
+
checkpoints.resolve(watermark);
|
|
68
|
+
};
|
|
69
|
+
if (options.shape !== void 0) {
|
|
70
|
+
return options.client.subscribeShape({ args, name: options.shape.name }, onRows, {
|
|
71
|
+
onCheckpoint,
|
|
72
|
+
onError,
|
|
73
|
+
shardKey: options.shape.shardKey
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return options.client.subscribe(options.list, args, onRows, { onCheckpoint, onError });
|
|
77
|
+
};
|
|
78
|
+
const config = {
|
|
79
|
+
// Auto-build ordered (B-tree) indexes for whatever the app's live queries
|
|
80
|
+
// join / filter / sort on, so they stay fast as the dataset grows.
|
|
81
|
+
autoIndex: "eager",
|
|
82
|
+
defaultIndexType: BTreeIndex,
|
|
83
|
+
getKey,
|
|
84
|
+
id: options.id ?? options.list?.__lunoraRef ?? `shape:${options.shape?.name ?? ""}`,
|
|
85
|
+
// `"eager"` syncs at creation; omitted otherwise so the wire stays
|
|
86
|
+
// byte-identical to the lazy default (sync on first subscriber).
|
|
87
|
+
...options.load === "eager" ? { startSync: true } : {},
|
|
88
|
+
sync: {
|
|
89
|
+
sync: (writer) => {
|
|
90
|
+
emit = makeDiffEmit(syncedJson, writer);
|
|
91
|
+
const onError = (error) => {
|
|
92
|
+
writer.markReady();
|
|
93
|
+
options.onError?.(error);
|
|
94
|
+
};
|
|
95
|
+
onErrorHandler = onError;
|
|
96
|
+
if (options.scopeBy === void 0) {
|
|
97
|
+
unsubscribe = openSubscription(options.shape?.args ?? {}, () => {
|
|
98
|
+
writer.markReady();
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
writer.markReady();
|
|
102
|
+
}
|
|
103
|
+
return () => {
|
|
104
|
+
emit = void 0;
|
|
105
|
+
onErrorHandler = void 0;
|
|
106
|
+
unsubscribe?.();
|
|
107
|
+
unsubscribe = void 0;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const scope = (args) => {
|
|
113
|
+
if (options.scopeBy === void 0) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
unsubscribe?.();
|
|
117
|
+
unsubscribe = void 0;
|
|
118
|
+
emit?.(/* @__PURE__ */ new Map());
|
|
119
|
+
if (args === void 0) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
unsubscribe = openSubscription(args, void 0);
|
|
123
|
+
};
|
|
124
|
+
return { checkpoints, config, scope };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export { createCheckpointRegistry, lunoraCollectionOptions };
|
|
@@ -0,0 +1,159 @@
|
|
|
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-lJBOVJgq.js";
|
|
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
|
+
type AnyDef = CollectionDef<any, any>;
|
|
53
|
+
/**
|
|
54
|
+
* The public row type a collection exposes — the element type of its `list`
|
|
55
|
+
* query's return, with no `& Row`: a `Collection<T>` is invariant in `T`, so the
|
|
56
|
+
* exposed type must be exactly the document type, not a subtype.
|
|
57
|
+
*/
|
|
58
|
+
type RowOf<C extends AnyDef> = C["list"] extends FunctionReference<infer _K, infer _A, infer R> ? Element<R> : never;
|
|
59
|
+
/** The action input type, inferred structurally from the def's optimistic insert. */
|
|
60
|
+
type InputOf<C> = C extends {
|
|
61
|
+
insert: {
|
|
62
|
+
optimistic: (input: infer I, id: string) => unknown;
|
|
63
|
+
};
|
|
64
|
+
} ? I : never;
|
|
65
|
+
/** A queued write that was permanently dropped, passed to {@link DefineCollectionsOptions.onWriteRejected}. */
|
|
66
|
+
interface WriteRejectedEvent {
|
|
67
|
+
/**
|
|
68
|
+
* The machine-readable reason — the server's error `code` (e.g. `CONFLICT`,
|
|
69
|
+
* `FORBIDDEN`), or `UNKNOWN_MUTATION_FN` when the write referenced a collection
|
|
70
|
+
* that no longer exists (removed in a deploy). Mirrors the client's
|
|
71
|
+
* `MutationSettledEvent.code` so a consumer can branch on the verdict.
|
|
72
|
+
*/
|
|
73
|
+
code?: string;
|
|
74
|
+
/** The collection/table name the write targeted. */
|
|
75
|
+
collection: string;
|
|
76
|
+
/** The error that dropped the write (message carried by the underlying `NonRetriableError`). */
|
|
77
|
+
error: Error;
|
|
78
|
+
/**
|
|
79
|
+
* The optimistic row being rolled back (the rollback follows the callback).
|
|
80
|
+
* Absent only if the dropped transaction carried no recoverable row (e.g. some
|
|
81
|
+
* `UNKNOWN_MUTATION_FN` cases).
|
|
82
|
+
*/
|
|
83
|
+
row?: Row;
|
|
84
|
+
}
|
|
85
|
+
/** Options for {@link defineCollections}. */
|
|
86
|
+
interface DefineCollectionsOptions {
|
|
87
|
+
/**
|
|
88
|
+
* Invoked when a leadership change occurs across tabs (only the leader tab
|
|
89
|
+
* drains the durable outbox). Informational — useful for diagnostics; the
|
|
90
|
+
* library handles the election itself.
|
|
91
|
+
*/
|
|
92
|
+
onLeadershipChange?: (isLeader: boolean) => void;
|
|
93
|
+
/**
|
|
94
|
+
* Invoked when the durable outbox's storage layer fails — IndexedDB
|
|
95
|
+
* unavailable (private mode), blocked, or quota exceeded. The standalone
|
|
96
|
+
* client surfaces this via `offlineQueue.onPersistenceError`; this is the
|
|
97
|
+
* collection-layer counterpart. A storage failure means a write is NOT durable
|
|
98
|
+
* and won't survive a reload, so surface it (e.g. "your change may not be
|
|
99
|
+
* saved if you close this tab").
|
|
100
|
+
*/
|
|
101
|
+
onStorageFailure?: (diagnostic: StorageDiagnostic) => void;
|
|
102
|
+
/**
|
|
103
|
+
* Invoked as a queued write is permanently dropped: a coded application error
|
|
104
|
+
* from the server (validation, RLS denial, conflict, surfaced as a
|
|
105
|
+
* `NonRetriableError`), OR a write whose target collection no longer exists —
|
|
106
|
+
* removed/renamed in a deploy (`code: "UNKNOWN_MUTATION_FN"`). This is the
|
|
107
|
+
* aggregate, fire-and-forget-safe channel: unlike awaiting the per-action
|
|
108
|
+
* `transaction` returned by `actions[name](...)`, it fires even when the caller
|
|
109
|
+
* never retained that handle, so a UI can surface "couldn't save" instead of a
|
|
110
|
+
* silently vanishing row. Transient failures (offline, 5xx) are retried by the
|
|
111
|
+
* outbox, not reported here.
|
|
112
|
+
*
|
|
113
|
+
* Timing: the callback runs at the point of rejection; the executor's
|
|
114
|
+
* optimistic-row rollback follows immediately after. The event's `row` is the
|
|
115
|
+
* (about-to-be-removed) optimistic row, so don't depend on the collection
|
|
116
|
+
* already reflecting the removal from inside the handler — use `row`/`error`
|
|
117
|
+
* directly (e.g. for a toast).
|
|
118
|
+
*/
|
|
119
|
+
onWriteRejected?: (event: WriteRejectedEvent) => void;
|
|
120
|
+
}
|
|
121
|
+
/** The wired data layer `defineCollections` returns. */
|
|
122
|
+
interface LunoraDb<D extends Record<string, AnyDef>> {
|
|
123
|
+
/** Optimistic, durable, retried write actions — present for `insert` collections. */
|
|
124
|
+
actions: { [K in keyof D]: D[K] extends {
|
|
125
|
+
insert: object;
|
|
126
|
+
} ? (input: InputOf<D[K]>) => {
|
|
127
|
+
id: string;
|
|
128
|
+
transaction: Transaction;
|
|
129
|
+
} : never };
|
|
130
|
+
/** The live, synced collections — feed these to `useLiveQuery`. */
|
|
131
|
+
collections: { [K in keyof D]: Collection<RowOf<D[K]>, string> };
|
|
132
|
+
/** The shared offline executor (the outbox). */
|
|
133
|
+
executor: OfflineExecutor;
|
|
134
|
+
/**
|
|
135
|
+
* Number of writes still pending in the durable outbox — the depth for a
|
|
136
|
+
* "N changes waiting to sync" indicator. A convenience over reaching through
|
|
137
|
+
* `executor.getPendingCount()`. **Pull-only** (the underlying TanStack
|
|
138
|
+
* executor exposes no change subscription): read it after a `db.actions.*`
|
|
139
|
+
* call and on connection-status changes, or poll. The standalone
|
|
140
|
+
* `LunoraClient` exposes the reactive `onPendingChange` for its built-in queue.
|
|
141
|
+
*/
|
|
142
|
+
pendingCount: () => number;
|
|
143
|
+
/** Re-point a `scopeBy` collection's subscription (omit `args` to detach) — present for scoped collections. */
|
|
144
|
+
scope: { [K in keyof D]: D[K] extends {
|
|
145
|
+
scopeBy: string;
|
|
146
|
+
} ? (args?: Record<string, unknown>) => void : never };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Wire a set of Lunora tables into a TanStack DB data layer in one declaration:
|
|
150
|
+
* each entry becomes a live, auto-indexed collection synced from its `list` query,
|
|
151
|
+
* and `insert` entries get an optimistic write action backed by the
|
|
152
|
+
* offline-transactions outbox (durable, retried, client-id-keyed). Scoped
|
|
153
|
+
* (`scopeBy`) collections are re-pointable for sharded queries.
|
|
154
|
+
*
|
|
155
|
+
* This is the hand-written form; `@lunora/codegen` can emit a fully-typed call to
|
|
156
|
+
* it from `schema.ts`, so an app writes nothing.
|
|
157
|
+
*/
|
|
158
|
+
declare const defineCollections: <D extends Record<string, AnyDef>>(client: LunoraClient, defs: D, options?: DefineCollectionsOptions) => LunoraDb<D>;
|
|
159
|
+
export { CollectionDef as C, DefineCollectionsOptions as D, InsertBinding as I, LunoraDb as L, WriteRejectedEvent as W, defineCollections as d };
|
|
@@ -0,0 +1,159 @@
|
|
|
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-lJBOVJgq.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
|
+
type AnyDef = CollectionDef<any, any>;
|
|
53
|
+
/**
|
|
54
|
+
* The public row type a collection exposes — the element type of its `list`
|
|
55
|
+
* query's return, with no `& Row`: a `Collection<T>` is invariant in `T`, so the
|
|
56
|
+
* exposed type must be exactly the document type, not a subtype.
|
|
57
|
+
*/
|
|
58
|
+
type RowOf<C extends AnyDef> = C["list"] extends FunctionReference<infer _K, infer _A, infer R> ? Element<R> : never;
|
|
59
|
+
/** The action input type, inferred structurally from the def's optimistic insert. */
|
|
60
|
+
type InputOf<C> = C extends {
|
|
61
|
+
insert: {
|
|
62
|
+
optimistic: (input: infer I, id: string) => unknown;
|
|
63
|
+
};
|
|
64
|
+
} ? I : never;
|
|
65
|
+
/** A queued write that was permanently dropped, passed to {@link DefineCollectionsOptions.onWriteRejected}. */
|
|
66
|
+
interface WriteRejectedEvent {
|
|
67
|
+
/**
|
|
68
|
+
* The machine-readable reason — the server's error `code` (e.g. `CONFLICT`,
|
|
69
|
+
* `FORBIDDEN`), or `UNKNOWN_MUTATION_FN` when the write referenced a collection
|
|
70
|
+
* that no longer exists (removed in a deploy). Mirrors the client's
|
|
71
|
+
* `MutationSettledEvent.code` so a consumer can branch on the verdict.
|
|
72
|
+
*/
|
|
73
|
+
code?: string;
|
|
74
|
+
/** The collection/table name the write targeted. */
|
|
75
|
+
collection: string;
|
|
76
|
+
/** The error that dropped the write (message carried by the underlying `NonRetriableError`). */
|
|
77
|
+
error: Error;
|
|
78
|
+
/**
|
|
79
|
+
* The optimistic row being rolled back (the rollback follows the callback).
|
|
80
|
+
* Absent only if the dropped transaction carried no recoverable row (e.g. some
|
|
81
|
+
* `UNKNOWN_MUTATION_FN` cases).
|
|
82
|
+
*/
|
|
83
|
+
row?: Row;
|
|
84
|
+
}
|
|
85
|
+
/** Options for {@link defineCollections}. */
|
|
86
|
+
interface DefineCollectionsOptions {
|
|
87
|
+
/**
|
|
88
|
+
* Invoked when a leadership change occurs across tabs (only the leader tab
|
|
89
|
+
* drains the durable outbox). Informational — useful for diagnostics; the
|
|
90
|
+
* library handles the election itself.
|
|
91
|
+
*/
|
|
92
|
+
onLeadershipChange?: (isLeader: boolean) => void;
|
|
93
|
+
/**
|
|
94
|
+
* Invoked when the durable outbox's storage layer fails — IndexedDB
|
|
95
|
+
* unavailable (private mode), blocked, or quota exceeded. The standalone
|
|
96
|
+
* client surfaces this via `offlineQueue.onPersistenceError`; this is the
|
|
97
|
+
* collection-layer counterpart. A storage failure means a write is NOT durable
|
|
98
|
+
* and won't survive a reload, so surface it (e.g. "your change may not be
|
|
99
|
+
* saved if you close this tab").
|
|
100
|
+
*/
|
|
101
|
+
onStorageFailure?: (diagnostic: StorageDiagnostic) => void;
|
|
102
|
+
/**
|
|
103
|
+
* Invoked as a queued write is permanently dropped: a coded application error
|
|
104
|
+
* from the server (validation, RLS denial, conflict, surfaced as a
|
|
105
|
+
* `NonRetriableError`), OR a write whose target collection no longer exists —
|
|
106
|
+
* removed/renamed in a deploy (`code: "UNKNOWN_MUTATION_FN"`). This is the
|
|
107
|
+
* aggregate, fire-and-forget-safe channel: unlike awaiting the per-action
|
|
108
|
+
* `transaction` returned by `actions[name](...)`, it fires even when the caller
|
|
109
|
+
* never retained that handle, so a UI can surface "couldn't save" instead of a
|
|
110
|
+
* silently vanishing row. Transient failures (offline, 5xx) are retried by the
|
|
111
|
+
* outbox, not reported here.
|
|
112
|
+
*
|
|
113
|
+
* Timing: the callback runs at the point of rejection; the executor's
|
|
114
|
+
* optimistic-row rollback follows immediately after. The event's `row` is the
|
|
115
|
+
* (about-to-be-removed) optimistic row, so don't depend on the collection
|
|
116
|
+
* already reflecting the removal from inside the handler — use `row`/`error`
|
|
117
|
+
* directly (e.g. for a toast).
|
|
118
|
+
*/
|
|
119
|
+
onWriteRejected?: (event: WriteRejectedEvent) => void;
|
|
120
|
+
}
|
|
121
|
+
/** The wired data layer `defineCollections` returns. */
|
|
122
|
+
interface LunoraDb<D extends Record<string, AnyDef>> {
|
|
123
|
+
/** Optimistic, durable, retried write actions — present for `insert` collections. */
|
|
124
|
+
actions: { [K in keyof D]: D[K] extends {
|
|
125
|
+
insert: object;
|
|
126
|
+
} ? (input: InputOf<D[K]>) => {
|
|
127
|
+
id: string;
|
|
128
|
+
transaction: Transaction;
|
|
129
|
+
} : never };
|
|
130
|
+
/** The live, synced collections — feed these to `useLiveQuery`. */
|
|
131
|
+
collections: { [K in keyof D]: Collection<RowOf<D[K]>, string> };
|
|
132
|
+
/** The shared offline executor (the outbox). */
|
|
133
|
+
executor: OfflineExecutor;
|
|
134
|
+
/**
|
|
135
|
+
* Number of writes still pending in the durable outbox — the depth for a
|
|
136
|
+
* "N changes waiting to sync" indicator. A convenience over reaching through
|
|
137
|
+
* `executor.getPendingCount()`. **Pull-only** (the underlying TanStack
|
|
138
|
+
* executor exposes no change subscription): read it after a `db.actions.*`
|
|
139
|
+
* call and on connection-status changes, or poll. The standalone
|
|
140
|
+
* `LunoraClient` exposes the reactive `onPendingChange` for its built-in queue.
|
|
141
|
+
*/
|
|
142
|
+
pendingCount: () => number;
|
|
143
|
+
/** Re-point a `scopeBy` collection's subscription (omit `args` to detach) — present for scoped collections. */
|
|
144
|
+
scope: { [K in keyof D]: D[K] extends {
|
|
145
|
+
scopeBy: string;
|
|
146
|
+
} ? (args?: Record<string, unknown>) => void : never };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Wire a set of Lunora tables into a TanStack DB data layer in one declaration:
|
|
150
|
+
* each entry becomes a live, auto-indexed collection synced from its `list` query,
|
|
151
|
+
* and `insert` entries get an optimistic write action backed by the
|
|
152
|
+
* offline-transactions outbox (durable, retried, client-id-keyed). Scoped
|
|
153
|
+
* (`scopeBy`) collections are re-pointable for sharded queries.
|
|
154
|
+
*
|
|
155
|
+
* This is the hand-written form; `@lunora/codegen` can emit a fully-typed call to
|
|
156
|
+
* it from `schema.ts`, so an app writes nothing.
|
|
157
|
+
*/
|
|
158
|
+
declare const defineCollections: <D extends Record<string, AnyDef>>(client: LunoraClient, defs: D, options?: DefineCollectionsOptions) => LunoraDb<D>;
|
|
159
|
+
export { CollectionDef as C, DefineCollectionsOptions as D, InsertBinding as I, LunoraDb as L, WriteRejectedEvent as W, defineCollections as d };
|