@tallyui/pos 3.6.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ import {
2
+ deepEqual, defaultConflictHandler, getChangedDocumentsSince, getRxReplicationMetaInstanceSchema, getSingleDocument, hasEncryption, newRxError,
3
+ overwritable, rxStorageInstanceToReplicationHandler, type RxCollection, type RxCollectionCreator, type RxDatabase, type RxStorageInstance,
4
+ } from 'rxdb';
5
+ import { getOldCollectionMeta, migrateDocumentData, type RxMigrationStatus } from 'rxdb/plugins/migration-schema';
6
+ import { Subject, takeUntil } from 'rxjs';
7
+ import type { Logger } from '../logging';
8
+
9
+ export interface MigratedCollectionOpener<T> {
10
+ /** The collection's name, e.g. 'pos_orders'. */
11
+ name: string;
12
+ /** Its creator; the opener adds `autoMigrate: false`. */
13
+ creator: () => RxCollectionCreator<T>;
14
+ /** The caller's function name, used in the multiInstance refusal. */
15
+ label: string;
16
+ /** Receives the dropped status read/write warnings. */
17
+ logger: Logger;
18
+ /** The coded error the open rejects with once the database is closing. */
19
+ closedError: (databaseName: string) => Error;
20
+ }
21
+
22
+ /** Every open per database and collection name so far, settled or not: one promise, so a close waits for all of them
23
+ * across DM4 retries with one handler, and a new open's reset waits for every earlier migration. */
24
+ const latestOpens = new WeakMap<RxDatabase, Map<string, Promise<unknown>>>();
25
+ /** Databases whose close stopped waiting (the limit passed), so storage is closing under the open. */
26
+ const closedUnderOpen = new WeakSet<RxDatabase>();
27
+
28
+ /** Resolves true once `promise` settles, or false after `ms`. */
29
+ function waitWithTimeout(promise: Promise<unknown>, ms: number): Promise<boolean> {
30
+ return new Promise((resolve) => {
31
+ const timer = setTimeout(() => resolve(false), ms);
32
+ promise.finally(() => { clearTimeout(timer); resolve(true); });
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Writes each document of the stored older version over its differing copy in the current version,
38
+ * through the same write RxDB's migration makes, and leaves documents without a copy (and any deleted
39
+ * one) to the migration. `from` is whichever older version is stored.
40
+ */
41
+ async function writeOverStaleCopies(collection: RxCollection, from: RxStorageInstance<any, any, any>, to: RxStorageInstance<any, any, any>,
42
+ stopIfClosing: () => void) {
43
+ const primaryKey = collection.schema.primaryPath;
44
+ const handler = rxStorageInstanceToReplicationHandler(to, defaultConflictHandler, collection.database.token, true);
45
+ for (let page = await getChangedDocumentsSince(from, 200); page.documents.length > 0;
46
+ page = await getChangedDocumentsSince(from, 200, page.checkpoint)) {
47
+ stopIfClosing();
48
+ const copies = new Map((await to.findDocumentsById(page.documents.map((doc) => doc[primaryKey]), false)).map((copy) => [copy[primaryKey], copy]));
49
+ const rows = await Promise.all(page.documents.filter((doc) => copies.has(doc[primaryKey]) && !doc._deleted).map(async (doc) =>
50
+ ({ assumedMasterState: copies.get(doc[primaryKey]), newDocumentState: await migrateDocumentData(collection, from.schema.version, doc) })));
51
+ const stale = rows.filter((row) => row.newDocumentState && !deepEqual(row.assumedMasterState, row.newDocumentState));
52
+ // A write error (an older state the current version refuses) stops the run with DM4, as RxDB's own write does.
53
+ if (stale.length > 0) await handler.masterWrite(stale);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Adds the collection to `db` and resolves once no document of an older version is left to migrate.
59
+ * For pos_orders, this is the one sanctioned way to open it (ADR-032 amendment 2). On DM4 it rejects after the migration has
60
+ * stopped and closes the collection, so the app can surface the error and open again; it never
61
+ * deletes a document.
62
+ *
63
+ * RxDB 17.5.0 trusts the status its last migration stored: a leftover `ERROR` rejects
64
+ * `migratePromise` at once while the migration keeps running (a close then interrupts it), and a
65
+ * `DONE` left before a rollback resolves it before the older version's new documents have moved. So the
66
+ * collection is added without `autoMigrate` and the migration is started and awaited directly: RxDB
67
+ * 17.5's `startMigration()` ignores a leftover status (only `migratePromise()` trusts a leftover
68
+ * `DONE`), which lets a rolled-back or interrupted run migrate again and recovers its documents. The
69
+ * status is reset first so its record describes the new run, and a failed run's checkpoint is
70
+ * removed (RxDB bug 1); never a document or its storage.
71
+ *
72
+ * A close waits for the open, up to `closeWaitMs`, and cancels a running
73
+ * migration (RxDB 17.5). Called once the database's close has begun, when the close cancels its
74
+ * migration, or when the close stops waiting, it rejects with `closedError(db.name)`
75
+ * before any further write: reopen and call it again. Any other
76
+ * rejection (DM4, a storage error) is a real failure.
77
+ *
78
+ * `closeWaitMs` is for tests only; the first open for a name on a database sets it for that name's close handler.
79
+ */
80
+ export async function openMigratedCollection<T>(db: RxDatabase, opener: MigratedCollectionOpener<T>, closeWaitMs: number): Promise<RxCollection<T>> {
81
+ const { name, label, closedError } = opener;
82
+ // The reset below runs before RxDB elects which tab migrates, so a second tab could reset a
83
+ // migration another tab is running. TallyUI is single-instance (ADR-061).
84
+ if (db.multiInstance) throw new Error(`${label}: multiInstance databases are not supported (ADR-061)`);
85
+ // RxDB 17.5.0 sets the private `closePromise` as `close()` begins (rx-database.js:447), and that
86
+ // close may already have read `db.onClose` (:473-476), so it would not wait for this open.
87
+ if (db.closed || (db as unknown as { closePromise: unknown }).closePromise) throw closedError(db.name);
88
+ // Registered before the first await. RxDB's close reads `db.onClose` once, when the database is
89
+ // idle (rx-database.js:473-476), and creating or removing a store doesn't keep it busy: a handler
90
+ // added after an await missed that close, which then closed storage under the open (2026-09-27).
91
+ const opens = latestOpens.get(db) ?? new Map<string, Promise<unknown>>();
92
+ latestOpens.set(db, opens);
93
+ const previous = opens.get(name);
94
+ if (!previous) {
95
+ db.onClose.push(() => waitWithTimeout(latestOpens.get(db)!.get(name)!, closeWaitMs)
96
+ .then((settled) => { if (!settled) closedUnderOpen.add(db); }));
97
+ }
98
+ const opening = openCollection(db, opener, previous);
99
+ opens.set(name, Promise.all([previous, opening.catch(() => undefined)]));
100
+ return opening;
101
+ }
102
+
103
+ async function openCollection<T>(db: RxDatabase, opener: MigratedCollectionOpener<T>, previous: Promise<unknown> | undefined): Promise<RxCollection<T>> {
104
+ const { name, creator, logger, closedError } = opener;
105
+ // Set once RxDB cancels this open's migration while it runs (see `startMigration` below).
106
+ let cancelled = false;
107
+ // The backstop, after each await: `closed` is set only once every store is closed (rx-database.js:444).
108
+ const closing = () => cancelled || closedUnderOpen.has(db) || db.closed;
109
+ const stopIfClosing = () => { if (closing()) throw closedError(db.name); };
110
+ const { [name]: collection } = await db.addCollections({ [name]: { ...creator(), autoMigrate: false } });
111
+ const state = collection.getMigrationState();
112
+ try {
113
+ stopIfClosing();
114
+ const mustMigrate = await state.mustMigrate;
115
+ stopIfClosing();
116
+ if (!mustMigrate) {
117
+ // RxDB 17 blocks writes (COL25) from collection creation until its own `migrationNeeded()` read.
118
+ // With nothing to migrate, `startMigration()` sets and clears that block on the same memoised `mustMigrate`,
119
+ // as `migratePromise()` does for `autoMigrate` (rx-collection.js:939-941), so the open resolves only once writes are allowed.
120
+ await state.startMigration();
121
+ stopIfClosing();
122
+ return collection;
123
+ }
124
+ // Never reset a still-settling earlier attempt's checkpoint out from under it: each new
125
+ // migration of this collection on this database starts only once the one before it has fully settled.
126
+ await previous;
127
+ stopIfClosing();
128
+ // Makes the stored status record describe this run (RUNNING, a fresh count, no leftover error):
129
+ // RxDB 17.5's `runMigration` overwrites only `count.total` and counts `handled` on from the stored
130
+ // value. It isn't what recovers a rollback (`startMigration()` ignores the status); "after a
131
+ // rollback to the version-N app" in open.test-helper.ts pins the record after a recovery.
132
+ await state.updateStatus((status) => {
133
+ // RxDB writes only what the handler changes in place.
134
+ delete status.error;
135
+ status.status = 'RUNNING';
136
+ status.count = { total: 0, handled: 0, percent: 0 };
137
+ return status;
138
+ });
139
+ // RxDB bug 1, which still reproduces on 17.5.0 (repro 2026-09-29):
140
+ // A failed run leaves its checkpoint, which can be past a document it never copied (a queued
141
+ // batch that finds its docs taken by a failed one still stores its checkpoint). The next run
142
+ // would skip that document, then remove its storage. So every run starts from the first document,
143
+ // as RxDB's own first run does; a document already copied is found equal and skipped.
144
+ const old = (await state.oldCollectionMeta)!.data.schema;
145
+ stopIfClosing();
146
+ const checkpoint = await db.storage.createStorageInstance({ databaseName: db.name, collectionName: `rx-migration-state-meta-${name}-${old.version}`,
147
+ databaseInstanceToken: db.token, multiInstance: db.multiInstance, options: {}, password: db.password,
148
+ schema: getRxReplicationMetaInstanceSchema(old, hasEncryption(old)), devMode: overwritable.isDevMode() });
149
+ await (closing() ? checkpoint.close() : checkpoint.remove());
150
+ stopIfClosing();
151
+ // RxDB bug 2, which still reproduces on 17.5.0 (repro 2026-09-29: a batch in flight when `cancel()`
152
+ // is called lands after it resolves, 200 of 600 documents): a run's replication can outlive the
153
+ // run. It wakes only on the stored older version's change stream (one
154
+ // shared across instances, as on memory storage), then writes its checkpoint into the store a later
155
+ // run removed (an unhandled `removed already`) and its conflicts into the older version. So that
156
+ // stream ends once the run has settled. And RxDB never overwrites a copy in the current version (it
157
+ // drops the assumed state): a stale copy wins its conflict. 17.5.0 keeps the copy and ignores the
158
+ // conflict (rx-migration-state.js `masterWrite` returns none); 17.4.0 and 16.21 loop on it. Yet a copy is only
159
+ // ever an earlier older-version state (the collection opens only once the older version is gone), so
160
+ // the newer state goes first. For pos_orders, a rare exception: after a rollback, an older build's one-time
161
+ // carry-over (for example medusapos's Dexie import, run again from a leftover Dexie database, after a failed
162
+ // removal or from an old tab) can bulk-insert an older state of order A into an empty older version
163
+ // while the current version holds A as sent. This overwrite then makes A pending again, and it is sent
164
+ // twice; the server's commandId idempotency is what stops a double charge. Also, a deleted
165
+ // older-version document that has a copy is skipped in writeOverStaleCopies, so if anything ever
166
+ // deleted a document in the collection, its stale copy would win (17.5.0) or RxDB would loop on it (earlier).
167
+ const settled = new Subject<void>();
168
+ // Once the close stops waiting it closes this version's store and the internal store while RxDB's
169
+ // migration runs on (rx-database.js:476-485). A write called on a closed SQLite instance throws
170
+ // inside its transaction and poisons every later write on the handle until restart (rxdb-premium
171
+ // bug 6, which still reproduces on 17.5.0, repro 2026-09-29), while its close waits for a write called before it (sqlite-storage-instance.js `close`,
172
+ // openWriteCount$; reproduced). So from the moment the close gives up, before it closes any store,
173
+ // the run's reads and writes of those two stores stop here. A refused one of this version rejects
174
+ // the run's push, which RxDB catches as a replication error (upstream.js:372), so the run ends in
175
+ // ERROR. Status writes are dropped instead: RxDB never awaits the per-document ones
176
+ // (rx-migration-state.js:357-362), so a rejection there would be unhandled.
177
+ // Every call a gate lets through that returns a promise is counted until it settles, so a cancelled
178
+ // open (below) settles only once none of the run's calls can still reach a store the close closes.
179
+ let inFlight = 0;
180
+ let drained: (() => void) | undefined;
181
+ const track = (result: unknown) => {
182
+ if (!(result instanceof Promise)) return result;
183
+ inFlight++;
184
+ return result.finally(() => { if (--inFlight === 0) drained?.(); });
185
+ };
186
+ const gate = <T extends object>(target: T, closed: (key: 'bulkWrite' | 'findDocumentsById', first: any[]) => Promise<unknown>): T =>
187
+ new Proxy(target, { get: (t, key) => {
188
+ const value = Reflect.get(t, key);
189
+ return typeof value !== 'function' ? value : (...args: any[]) =>
190
+ (closing() && (key === 'bulkWrite' || key === 'findDocumentsById') ? closed(key, args[0]) : track(value.apply(t, args)));
191
+ } });
192
+ const internalStore = gate(db.internalStore, async (key, first) => {
193
+ const ids = key === 'bulkWrite' ? first.map((row: { document: { id: string } }) => row.document.id) : first;
194
+ logger.warn(`The database closed during the migration: dropped a ${key === 'bulkWrite' ? 'status write' : 'status read'}`,
195
+ { database: db.name, ids });
196
+ return key === 'bulkWrite' ? { error: [] } : [];
197
+ });
198
+ state.database = new Proxy(db, { get: (target, key) => (key === 'internalStore' ? internalStore : Reflect.get(target, key)) });
199
+ const migrateStorage = state.migrateStorage.bind(state);
200
+ state.migrateStorage = async (from, current, batchSize) => {
201
+ stopIfClosing();
202
+ // RxDB 17.5 sets `canceled` but never reads it, so a cancelled run would otherwise still
203
+ // create a checkpoint store and a replication after `cancel()` returned.
204
+ const to = gate(current, () => Promise.reject(closedError(db.name)));
205
+ if (from.collectionName === collection.name) await writeOverStaleCopies(collection, from, to, stopIfClosing);
206
+ stopIfClosing();
207
+ return migrateStorage(new Proxy(from, { get: (target, key) => {
208
+ const value = key === 'changeStream' ? () => target.changeStream().pipe(takeUntil(settled)) : Reflect.get(target, key);
209
+ return typeof value === 'function' ? value.bind(target) : value;
210
+ } }), to, batchSize);
211
+ };
212
+ // RxDB 17.5 hooks the database's and the collection's close to `cancel()` once the run starts
213
+ // (rx-migration-state.js `startMigration`), and a cancelled run stops for good: its
214
+ // `startMigration()` never settles. So a cancel while the run is pending settles the open:
215
+ // `cancelled` first makes the gates above refuse the run's later reads and writes, the open waits
216
+ // for the calls they already let through (a batch in flight still lands after `cancel()`, bug 2,
217
+ // and one landing on a store the close has closed would poison the handle, bug 6), then it
218
+ // rejects with `closedError(db.name)`. That covers the `db.onClose` path: the close's own
219
+ // handler waits for the open (up to its limit, so a give-up never hangs) before it closes storage;
220
+ // `cancel()`'s return does not wait for the drain. A cancel after the run has settled (the catch
221
+ // below closes the collection) changes nothing.
222
+ // This assumes 17.5's `cancel()` comes only from the close hooks. RxDB 16.x also calls it at the
223
+ // end of a successful run, while `running` is still true: the gates would then drop its last
224
+ // writes and the collection would never open. The wrapper depends on 17.5's `cancel()` and `startMigration()`
225
+ // behaviour, which changed within a minor release before, so @tallyui/pos requires rxdb ~17.5.0.
226
+ let running = true;
227
+ let onCancel!: () => void;
228
+ const cancelledRun = new Promise<void>((resolve) => { onCancel = resolve; });
229
+ const cancel = state.cancel.bind(state);
230
+ state.cancel = () => {
231
+ if (running) {
232
+ cancelled = true;
233
+ if (inFlight === 0) onCancel();
234
+ else drained = onCancel;
235
+ }
236
+ return cancel();
237
+ };
238
+ // Settles once the migration has: DONE, ERROR with its old storage closed, or cancelled by a close.
239
+ await Promise.race([state.startMigration(), cancelledRun]).finally(() => { running = false; settled.next(); });
240
+ stopIfClosing();
241
+ const status = (await getSingleDocument(db.internalStore, state.statusDocId))?.data as RxMigrationStatus | undefined;
242
+ stopIfClosing();
243
+ // RxDB deletes the older version's collection record only after every document has moved.
244
+ const oldMeta = status?.status === 'DONE' ? await getOldCollectionMeta(state) : undefined;
245
+ stopIfClosing();
246
+ if (status?.status === 'DONE' && !oldMeta) return collection;
247
+ throw newRxError('DM4', { collection: collection.name, error: status?.error });
248
+ } catch (error) {
249
+ await collection.close();
250
+ // A backstop: a read of a store the close has closed fails on SQLite with rxdb-premium bug 5's (still on 17.5.0) raw
251
+ // `ReferenceError: context is not defined`. The internal store's reads are locked runs, which the
252
+ // close's idle waits cover (rx-storage-helper.js:486), and no test reaches this; but once the close
253
+ // has given up, any failure means the open was closed under, so it gets the coded error.
254
+ throw closing() ? closedError(db.name) : error;
255
+ }
256
+ }