@tallyui/pos 3.5.3 → 3.7.0
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/dist/index.d.ts +113 -32
- package/dist/index.js +179 -38
- package/package.json +6 -6
- package/src/index.ts +6 -5
- package/src/outbox/register-outbox.ts +9 -4
- package/src/pos-order/needs-attention.ts +2 -1
- package/src/pos-order/open.ts +10 -234
- package/src/receipt/build-receipt-data.ts +9 -0
- package/src/receipt/index.ts +2 -1
- package/src/receipt/types.ts +22 -0
- package/src/register/index.ts +2 -1
- package/src/register/open.ts +30 -0
- package/src/register/register-commands.ts +5 -3
- package/src/register/register-document.ts +1 -1
- package/src/register/schemas.ts +27 -9
- package/src/register/session-store.ts +20 -1
- package/src/register/use-register-session.ts +7 -2
- package/src/rxdb/open-migrated-collection.ts +256 -0
- package/src/tender/index.ts +2 -0
- package/src/tender/tender-void.ts +66 -0
package/src/pos-order/open.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
deepEqual, defaultConflictHandler, getChangedDocumentsSince, getRxReplicationMetaInstanceSchema, getSingleDocument, hasEncryption, newRxError,
|
|
3
|
-
overwritable, rxStorageInstanceToReplicationHandler, type RxCollection, type RxDatabase, type RxStorageInstance,
|
|
4
|
-
} from 'rxdb';
|
|
5
|
-
import { getOldCollectionMeta, migrateDocumentData, type RxMigrationStatus } from 'rxdb/plugins/migration-schema';
|
|
6
|
-
import { Subject, takeUntil } from 'rxjs';
|
|
1
|
+
import type { RxCollection, RxDatabase } from 'rxdb';
|
|
7
2
|
import { createLogger } from '../logging';
|
|
3
|
+
import { openMigratedCollection } from '../rxdb/open-migrated-collection';
|
|
8
4
|
import { posOrderCollection } from './schema';
|
|
9
5
|
import type { PosOrder } from './types';
|
|
10
6
|
|
|
@@ -15,7 +11,7 @@ export const posOrdersLogger = createLogger('pos-orders');
|
|
|
15
11
|
* After this long waiting for the open, a close gives up rather than hang forever. RxDB 17.5 cancels
|
|
16
12
|
* a running migration as the database closes, and the open then rejects with
|
|
17
13
|
* `PosOrderOpenClosedError`; the run reads or writes nothing more in a store the close closes (see
|
|
18
|
-
* `
|
|
14
|
+
* `openMigratedCollection`). The next open adds the collection without `autoMigrate` and starts and awaits the
|
|
19
15
|
* migration directly, so it migrates again: RxDB 17.5's `startMigration()` ignores the leftover status
|
|
20
16
|
* (only `migratePromise()` trusts a leftover `DONE`). It also resets that status, so the record
|
|
21
17
|
* describes the new run, and removes the run's checkpoint (RxDB bug 1). An order that run already
|
|
@@ -37,233 +33,13 @@ export class PosOrderOpenClosedError extends Error {
|
|
|
37
33
|
}
|
|
38
34
|
}
|
|
39
35
|
|
|
40
|
-
/** Every open per database so far, settled or not: one promise, so a close waits for all of them
|
|
41
|
-
* across DM4 retries with one handler, and a new open's reset waits for every earlier migration. */
|
|
42
|
-
const latestOpens = new WeakMap<RxDatabase, Promise<unknown>>();
|
|
43
|
-
/** Databases whose close stopped waiting (the limit passed), so storage is closing under the open. */
|
|
44
|
-
const closedUnderOpen = new WeakSet<RxDatabase>();
|
|
45
|
-
|
|
46
|
-
/** Resolves true once `promise` settles, or false after `ms`. */
|
|
47
|
-
function waitWithTimeout(promise: Promise<unknown>, ms: number): Promise<boolean> {
|
|
48
|
-
return new Promise((resolve) => {
|
|
49
|
-
const timer = setTimeout(() => resolve(false), ms);
|
|
50
|
-
promise.finally(() => { clearTimeout(timer); resolve(true); });
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Writes each order of the stored older version over its differing copy in the current version,
|
|
56
|
-
* through the same write RxDB's migration makes, and leaves orders without a copy (and any deleted
|
|
57
|
-
* one) to the migration. `from` is whichever older version is stored, 0, 1 or 2.
|
|
58
|
-
*/
|
|
59
|
-
async function writeOverStaleCopies(collection: RxCollection, from: RxStorageInstance<any, any, any>, to: RxStorageInstance<any, any, any>,
|
|
60
|
-
stopIfClosing: () => void) {
|
|
61
|
-
const handler = rxStorageInstanceToReplicationHandler(to, defaultConflictHandler, collection.database.token, true);
|
|
62
|
-
for (let page = await getChangedDocumentsSince(from, 200); page.documents.length > 0;
|
|
63
|
-
page = await getChangedDocumentsSince(from, 200, page.checkpoint)) {
|
|
64
|
-
stopIfClosing();
|
|
65
|
-
const copies = new Map((await to.findDocumentsById(page.documents.map((doc) => doc.id), false)).map((copy) => [copy.id, copy]));
|
|
66
|
-
const rows = await Promise.all(page.documents.filter((doc) => copies.has(doc.id) && !doc._deleted).map(async (doc) =>
|
|
67
|
-
({ assumedMasterState: copies.get(doc.id), newDocumentState: await migrateDocumentData(collection, from.schema.version, doc) })));
|
|
68
|
-
const stale = rows.filter((row) => row.newDocumentState && !deepEqual(row.assumedMasterState, row.newDocumentState));
|
|
69
|
-
// A write error (an older state the current version refuses) stops the run with DM4, as RxDB's own write does.
|
|
70
|
-
if (stale.length > 0) await handler.masterWrite(stale);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
36
|
/**
|
|
75
|
-
* Adds `pos_orders` to `db` and resolves once no order of an older version is left to migrate.
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* RxDB 17.5.0 trusts the status its last migration stored: a leftover `ERROR` rejects
|
|
81
|
-
* `migratePromise` at once while the migration keeps running (a close then interrupts it), and a
|
|
82
|
-
* `DONE` left before a rollback resolves it before the older version's new orders have moved. So the
|
|
83
|
-
* collection is added without `autoMigrate` and the migration is started and awaited directly: RxDB
|
|
84
|
-
* 17.5's `startMigration()` ignores a leftover status (only `migratePromise()` trusts a leftover
|
|
85
|
-
* `DONE`), which lets a rolled-back or interrupted run migrate again and recovers its orders. The
|
|
86
|
-
* status is reset first so its record describes the new run, and a failed run's checkpoint is
|
|
87
|
-
* removed (RxDB bug 1); never an order or its storage.
|
|
88
|
-
*
|
|
89
|
-
* A close waits for the open, up to `POS_ORDER_MIGRATION_CLOSE_WAIT_MS`, and cancels a running
|
|
90
|
-
* migration (RxDB 17.5). Called once the database's close has begun, when the close cancels its
|
|
91
|
-
* migration, or when the close stops waiting, it rejects with `PosOrderOpenClosedError`
|
|
92
|
-
* (`code: 'POS_ORDER_OPEN_CLOSED'`) before any further write: reopen and call it again. Any other
|
|
93
|
-
* rejection (DM4, a storage error) is a real failure.
|
|
94
|
-
*
|
|
95
|
-
* `closeWaitMs` is for tests only; the first open on a database sets it for that database's close.
|
|
37
|
+
* Adds `pos_orders` to `db` and resolves once no order of an older version is left to migrate.
|
|
38
|
+
* On DM4 it rejects after the migration has stopped and closes the collection. It rejects with
|
|
39
|
+
* `PosOrderOpenClosedError` when the database is closing. `closeWaitMs` is for tests only.
|
|
40
|
+
* See `openMigratedCollection` for the migration and close mechanism.
|
|
96
41
|
*/
|
|
97
|
-
export
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (db.multiInstance) throw new Error('addPosOrderCollection: multiInstance databases are not supported (ADR-061)');
|
|
101
|
-
// RxDB 17.5.0 sets the private `closePromise` as `close()` begins (rx-database.js:447), and that
|
|
102
|
-
// close may already have read `db.onClose` (:473-476), so it would not wait for this open.
|
|
103
|
-
if (db.closed || (db as unknown as { closePromise: unknown }).closePromise) throw new PosOrderOpenClosedError(db.name);
|
|
104
|
-
// Registered before the first await. RxDB's close reads `db.onClose` once, when the database is
|
|
105
|
-
// idle (rx-database.js:473-476), and creating or removing a store doesn't keep it busy: a handler
|
|
106
|
-
// added after an await missed that close, which then closed storage under the open (2026-09-27).
|
|
107
|
-
const previous = latestOpens.get(db);
|
|
108
|
-
if (!previous) {
|
|
109
|
-
db.onClose.push(() => waitWithTimeout(latestOpens.get(db)!, closeWaitMs)
|
|
110
|
-
.then((settled) => { if (!settled) closedUnderOpen.add(db); }));
|
|
111
|
-
}
|
|
112
|
-
const opening = openPosOrders(db, previous);
|
|
113
|
-
latestOpens.set(db, Promise.all([previous, opening.catch(() => undefined)]));
|
|
114
|
-
return opening;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function openPosOrders(db: RxDatabase, previous: Promise<unknown> | undefined): Promise<RxCollection<PosOrder>> {
|
|
118
|
-
// Set once RxDB cancels this open's migration while it runs (see `startMigration` below).
|
|
119
|
-
let cancelled = false;
|
|
120
|
-
// The backstop, after each await: `closed` is set only once every store is closed (rx-database.js:444).
|
|
121
|
-
const closing = () => cancelled || closedUnderOpen.has(db) || db.closed;
|
|
122
|
-
const stopIfClosing = () => { if (closing()) throw new PosOrderOpenClosedError(db.name); };
|
|
123
|
-
const { pos_orders: collection } = await db.addCollections({ pos_orders: { ...posOrderCollection(), autoMigrate: false } });
|
|
124
|
-
const state = collection.getMigrationState();
|
|
125
|
-
try {
|
|
126
|
-
stopIfClosing();
|
|
127
|
-
const mustMigrate = await state.mustMigrate;
|
|
128
|
-
stopIfClosing();
|
|
129
|
-
if (!mustMigrate) {
|
|
130
|
-
// RxDB 17 blocks writes (COL25) from collection creation until its own `migrationNeeded()` read.
|
|
131
|
-
// With nothing to migrate, `startMigration()` sets and clears that block on the same memoised `mustMigrate`,
|
|
132
|
-
// as `migratePromise()` does for `autoMigrate` (rx-collection.js:939-941), so the open resolves only once writes are allowed.
|
|
133
|
-
await state.startMigration();
|
|
134
|
-
stopIfClosing();
|
|
135
|
-
return collection;
|
|
136
|
-
}
|
|
137
|
-
// Never reset a still-settling earlier attempt's checkpoint out from under it: each new
|
|
138
|
-
// migration on this database starts only once the one before it has fully settled.
|
|
139
|
-
await previous;
|
|
140
|
-
stopIfClosing();
|
|
141
|
-
// Makes the stored status record describe this run (RUNNING, a fresh count, no leftover error):
|
|
142
|
-
// RxDB 17.5's `runMigration` overwrites only `count.total` and counts `handled` on from the stored
|
|
143
|
-
// value. It isn't what recovers a rollback (`startMigration()` ignores the status); "after a
|
|
144
|
-
// rollback to the version-N app" in open.test-helper.ts pins the record after a recovery.
|
|
145
|
-
await state.updateStatus((status) => {
|
|
146
|
-
// RxDB writes only what the handler changes in place.
|
|
147
|
-
delete status.error;
|
|
148
|
-
status.status = 'RUNNING';
|
|
149
|
-
status.count = { total: 0, handled: 0, percent: 0 };
|
|
150
|
-
return status;
|
|
151
|
-
});
|
|
152
|
-
// RxDB bug 1, which still reproduces on 17.5.0 (repro 2026-09-29):
|
|
153
|
-
// A failed run leaves its checkpoint, which can be past an order it never copied (a queued
|
|
154
|
-
// batch that finds its docs taken by a failed one still stores its checkpoint). The next run
|
|
155
|
-
// would skip that order, then remove its storage. So every run starts from the first order,
|
|
156
|
-
// as RxDB's own first run does; an order already copied is found equal and skipped.
|
|
157
|
-
const old = (await state.oldCollectionMeta)!.data.schema;
|
|
158
|
-
stopIfClosing();
|
|
159
|
-
const checkpoint = await db.storage.createStorageInstance({ databaseName: db.name, collectionName: `rx-migration-state-meta-pos_orders-${old.version}`,
|
|
160
|
-
databaseInstanceToken: db.token, multiInstance: db.multiInstance, options: {}, password: db.password,
|
|
161
|
-
schema: getRxReplicationMetaInstanceSchema(old, hasEncryption(old)), devMode: overwritable.isDevMode() });
|
|
162
|
-
await (closing() ? checkpoint.close() : checkpoint.remove());
|
|
163
|
-
stopIfClosing();
|
|
164
|
-
// RxDB bug 2, which still reproduces on 17.5.0 (repro 2026-09-29: a batch in flight when `cancel()`
|
|
165
|
-
// is called lands after it resolves, 200 of 600 documents): a run's replication can outlive the
|
|
166
|
-
// run. It wakes only on the stored older version's change stream (one
|
|
167
|
-
// shared across instances, as on memory storage), then writes its checkpoint into the store a later
|
|
168
|
-
// run removed (an unhandled `removed already`) and its conflicts into the older version. So that
|
|
169
|
-
// stream ends once the run has settled. And RxDB never overwrites a copy in the current version (it
|
|
170
|
-
// drops the assumed state): a stale copy wins its conflict. 17.5.0 keeps the copy and ignores the
|
|
171
|
-
// conflict (rx-migration-state.js `masterWrite` returns none); 17.4.0 and 16.21 loop on it. Yet a copy is only
|
|
172
|
-
// ever an earlier older-version state (the collection opens only once the older version is gone), so
|
|
173
|
-
// the newer state goes first. A rare exception: after a rollback, an older build's one-time carry-over
|
|
174
|
-
// (for example medusapos's Dexie import, run again from a leftover Dexie database, after a failed
|
|
175
|
-
// removal or from an old tab) can bulk-insert an older state of order A into an empty older version
|
|
176
|
-
// while the current version holds A as sent. This overwrite then makes A pending again, and it is sent
|
|
177
|
-
// twice; the server's commandId idempotency is what stops a double charge. Also, a deleted
|
|
178
|
-
// older-version order that has a copy is skipped in writeOverStaleCopies, so if anything ever
|
|
179
|
-
// deleted a pos_orders document, its stale copy would win (17.5.0) or RxDB would loop on it (earlier).
|
|
180
|
-
const settled = new Subject<void>();
|
|
181
|
-
// Once the close stops waiting it closes this version's store and the internal store while RxDB's
|
|
182
|
-
// migration runs on (rx-database.js:476-485). A write called on a closed SQLite instance throws
|
|
183
|
-
// inside its transaction and poisons every later write on the handle until restart (rxdb-premium
|
|
184
|
-
// 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`,
|
|
185
|
-
// openWriteCount$; reproduced). So from the moment the close gives up, before it closes any store,
|
|
186
|
-
// the run's reads and writes of those two stores stop here. A refused one of this version rejects
|
|
187
|
-
// the run's push, which RxDB catches as a replication error (upstream.js:372), so the run ends in
|
|
188
|
-
// ERROR. Status writes are dropped instead: RxDB never awaits the per-order ones
|
|
189
|
-
// (rx-migration-state.js:357-362), so a rejection there would be unhandled.
|
|
190
|
-
// Every call a gate lets through that returns a promise is counted until it settles, so a cancelled
|
|
191
|
-
// open (below) settles only once none of the run's calls can still reach a store the close closes.
|
|
192
|
-
let inFlight = 0;
|
|
193
|
-
let drained: (() => void) | undefined;
|
|
194
|
-
const track = (result: unknown) => {
|
|
195
|
-
if (!(result instanceof Promise)) return result;
|
|
196
|
-
inFlight++;
|
|
197
|
-
return result.finally(() => { if (--inFlight === 0) drained?.(); });
|
|
198
|
-
};
|
|
199
|
-
const gate = <T extends object>(target: T, closed: (key: 'bulkWrite' | 'findDocumentsById', first: any[]) => Promise<unknown>): T =>
|
|
200
|
-
new Proxy(target, { get: (t, key) => {
|
|
201
|
-
const value = Reflect.get(t, key);
|
|
202
|
-
return typeof value !== 'function' ? value : (...args: any[]) =>
|
|
203
|
-
(closing() && (key === 'bulkWrite' || key === 'findDocumentsById') ? closed(key, args[0]) : track(value.apply(t, args)));
|
|
204
|
-
} });
|
|
205
|
-
const internalStore = gate(db.internalStore, async (key, first) => {
|
|
206
|
-
const ids = key === 'bulkWrite' ? first.map((row: { document: { id: string } }) => row.document.id) : first;
|
|
207
|
-
posOrdersLogger.warn(`The database closed during the migration: dropped a ${key === 'bulkWrite' ? 'status write' : 'status read'}`,
|
|
208
|
-
{ database: db.name, ids });
|
|
209
|
-
return key === 'bulkWrite' ? { error: [] } : [];
|
|
210
|
-
});
|
|
211
|
-
state.database = new Proxy(db, { get: (target, key) => (key === 'internalStore' ? internalStore : Reflect.get(target, key)) });
|
|
212
|
-
const migrateStorage = state.migrateStorage.bind(state);
|
|
213
|
-
state.migrateStorage = async (from, current, batchSize) => {
|
|
214
|
-
stopIfClosing();
|
|
215
|
-
// RxDB 17.5 sets `canceled` but never reads it, so a cancelled run would otherwise still
|
|
216
|
-
// create a checkpoint store and a replication after `cancel()` returned.
|
|
217
|
-
const to = gate(current, () => Promise.reject(new PosOrderOpenClosedError(db.name)));
|
|
218
|
-
if (from.collectionName === collection.name) await writeOverStaleCopies(collection, from, to, stopIfClosing);
|
|
219
|
-
stopIfClosing();
|
|
220
|
-
return migrateStorage(new Proxy(from, { get: (target, key) => {
|
|
221
|
-
const value = key === 'changeStream' ? () => target.changeStream().pipe(takeUntil(settled)) : Reflect.get(target, key);
|
|
222
|
-
return typeof value === 'function' ? value.bind(target) : value;
|
|
223
|
-
} }), to, batchSize);
|
|
224
|
-
};
|
|
225
|
-
// RxDB 17.5 hooks the database's and the collection's close to `cancel()` once the run starts
|
|
226
|
-
// (rx-migration-state.js `startMigration`), and a cancelled run stops for good: its
|
|
227
|
-
// `startMigration()` never settles. So a cancel while the run is pending settles the open:
|
|
228
|
-
// `cancelled` first makes the gates above refuse the run's later reads and writes, the open waits
|
|
229
|
-
// for the calls they already let through (a batch in flight still lands after `cancel()`, bug 2,
|
|
230
|
-
// and one landing on a store the close has closed would poison the handle, bug 6), then it
|
|
231
|
-
// rejects with `PosOrderOpenClosedError`. That covers the `db.onClose` path: the close's own
|
|
232
|
-
// handler waits for the open (up to its limit, so a give-up never hangs) before it closes storage;
|
|
233
|
-
// `cancel()`'s return does not wait for the drain. A cancel after the run has settled (the catch
|
|
234
|
-
// below closes the collection) changes nothing.
|
|
235
|
-
// This assumes 17.5's `cancel()` comes only from the close hooks. RxDB 16.x also calls it at the
|
|
236
|
-
// end of a successful run, while `running` is still true: the gates would then drop its last
|
|
237
|
-
// writes and pos_orders would never open. The wrapper depends on 17.5's `cancel()` and `startMigration()`
|
|
238
|
-
// behaviour, which changed within a minor release before, so @tallyui/pos requires rxdb ~17.5.0.
|
|
239
|
-
let running = true;
|
|
240
|
-
let onCancel!: () => void;
|
|
241
|
-
const cancelledRun = new Promise<void>((resolve) => { onCancel = resolve; });
|
|
242
|
-
const cancel = state.cancel.bind(state);
|
|
243
|
-
state.cancel = () => {
|
|
244
|
-
if (running) {
|
|
245
|
-
cancelled = true;
|
|
246
|
-
if (inFlight === 0) onCancel();
|
|
247
|
-
else drained = onCancel;
|
|
248
|
-
}
|
|
249
|
-
return cancel();
|
|
250
|
-
};
|
|
251
|
-
// Settles once the migration has: DONE, ERROR with its old storage closed, or cancelled by a close.
|
|
252
|
-
await Promise.race([state.startMigration(), cancelledRun]).finally(() => { running = false; settled.next(); });
|
|
253
|
-
stopIfClosing();
|
|
254
|
-
const status = (await getSingleDocument(db.internalStore, state.statusDocId))?.data as RxMigrationStatus | undefined;
|
|
255
|
-
stopIfClosing();
|
|
256
|
-
// RxDB deletes the older version's collection record only after every order has moved.
|
|
257
|
-
const oldMeta = status?.status === 'DONE' ? await getOldCollectionMeta(state) : undefined;
|
|
258
|
-
stopIfClosing();
|
|
259
|
-
if (status?.status === 'DONE' && !oldMeta) return collection;
|
|
260
|
-
throw newRxError('DM4', { collection: collection.name, error: status?.error });
|
|
261
|
-
} catch (error) {
|
|
262
|
-
await collection.close();
|
|
263
|
-
// 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
|
|
264
|
-
// `ReferenceError: context is not defined`. The internal store's reads are locked runs, which the
|
|
265
|
-
// close's idle waits cover (rx-storage-helper.js:486), and no test reaches this; but once the close
|
|
266
|
-
// has given up, any failure means the open was closed under, so it gets the coded error.
|
|
267
|
-
throw closing() ? new PosOrderOpenClosedError(db.name) : error;
|
|
268
|
-
}
|
|
42
|
+
export function addPosOrderCollection(db: RxDatabase, closeWaitMs = POS_ORDER_MIGRATION_CLOSE_WAIT_MS): Promise<RxCollection<PosOrder>> {
|
|
43
|
+
return openMigratedCollection<PosOrder>(db, { name: 'pos_orders', creator: posOrderCollection, label: 'addPosOrderCollection',
|
|
44
|
+
logger: posOrdersLogger, closedError: (name) => new PosOrderOpenClosedError(name) }, closeWaitMs);
|
|
269
45
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Discount, SentOrder } from '../order/types';
|
|
2
2
|
import { roundMicrosToMinor, taxLinesByRate } from '../tax/exact';
|
|
3
3
|
import type { ReceiptConfig, ReceiptData } from './types';
|
|
4
|
+
import { RECEIPT_SCHEMA_VERSION } from './types';
|
|
4
5
|
|
|
5
6
|
export function buildReceiptData(order: SentOrder, config: ReceiptConfig): ReceiptData {
|
|
6
7
|
// Drops the shared helper's `netMinor` (the Z report's own use): the receipt's tax summary
|
|
@@ -29,6 +30,14 @@ export function buildReceiptData(order: SentOrder, config: ReceiptConfig): Recei
|
|
|
29
30
|
const customer = order.customer?.name || order.customer?.email;
|
|
30
31
|
|
|
31
32
|
return {
|
|
33
|
+
schemaVersion: RECEIPT_SCHEMA_VERSION,
|
|
34
|
+
software: {
|
|
35
|
+
name: 'WCPOS', plugin_version: '', app_version: '', app_build: '', platform: '', ...config.software,
|
|
36
|
+
},
|
|
37
|
+
register: { id: config.register ?? '', name: config.registerName ?? '' },
|
|
38
|
+
fiscal: {
|
|
39
|
+
document_type: 'sale', is_reprint: false, reprint_count: 0, qr_payload: '', ...config.fiscal,
|
|
40
|
+
},
|
|
32
41
|
header: {
|
|
33
42
|
storeName: config.storeName,
|
|
34
43
|
storeAddress: config.storeAddress,
|
package/src/receipt/index.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { buildReceiptData } from './build-receipt-data';
|
|
2
|
-
export
|
|
2
|
+
export { RECEIPT_SCHEMA_VERSION } from './types';
|
|
3
|
+
export type { ReceiptData, ReceiptLineItem, ReceiptConfig, ReceiptSoftware, ReceiptRegister, ReceiptFiscal } from './types';
|
package/src/receipt/types.ts
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/** WCPOS receipt data schema version these blocks follow (wiki: fiscal groundwork, receipt identity and QR §2). */
|
|
2
|
+
export const RECEIPT_SCHEMA_VERSION = '1.4.0';
|
|
3
|
+
|
|
4
|
+
/** 1.4 `software`: which software produced the receipt. */
|
|
5
|
+
export interface ReceiptSoftware { name: string; plugin_version: string; app_version: string; app_build: string; platform: string }
|
|
6
|
+
/** 1.4 `register`: `{ id: '', name: '' }` when there is none. */
|
|
7
|
+
export interface ReceiptRegister { id: string; name: string }
|
|
8
|
+
/** 1.4 `fiscal` identity fields a till can know. Sale time, zone and counter come with the per-register sale counter (#475). */
|
|
9
|
+
export interface ReceiptFiscal {
|
|
10
|
+
document_type: 'sale' | 'refund' | 'void' | 'cancellation' | 'closure' | 'x_report';
|
|
11
|
+
is_reprint: boolean;
|
|
12
|
+
reprint_count: number;
|
|
13
|
+
qr_payload: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
1
16
|
export interface ReceiptLineItem {
|
|
2
17
|
name: string;
|
|
3
18
|
sku: string;
|
|
@@ -11,6 +26,10 @@ export interface ReceiptLineItem {
|
|
|
11
26
|
}
|
|
12
27
|
|
|
13
28
|
export interface ReceiptData {
|
|
29
|
+
schemaVersion: string;
|
|
30
|
+
software: ReceiptSoftware;
|
|
31
|
+
register: ReceiptRegister;
|
|
32
|
+
fiscal: ReceiptFiscal;
|
|
14
33
|
header: {
|
|
15
34
|
storeName: string;
|
|
16
35
|
storeAddress?: string;
|
|
@@ -47,5 +66,8 @@ export interface ReceiptConfig {
|
|
|
47
66
|
storeAddress?: string;
|
|
48
67
|
cashier?: string;
|
|
49
68
|
register?: string;
|
|
69
|
+
registerName?: string;
|
|
70
|
+
software?: Partial<ReceiptSoftware>;
|
|
71
|
+
fiscal?: Partial<ReceiptFiscal>;
|
|
50
72
|
taxLabels?: Record<number, string>;
|
|
51
73
|
}
|
package/src/register/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export type { LedgerRow, Movement } from './expected';
|
|
|
5
5
|
export { parseMinor, validAmount, countVariance, overThreshold, closeNeedsApproval, denominationTotal, varianceText } from './register-count.helpers';
|
|
6
6
|
export { denominations } from './register-count.denominations';
|
|
7
7
|
export { registerSessionSchema, registerSessionCollection, cashMovementSchema, closureSchema } from './schemas';
|
|
8
|
+
export { addRegisterSessionCollection, RegisterSessionOpenClosedError, registerSessionsLogger, REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS } from './open';
|
|
8
9
|
export type { RegisterSession, CashMovement, Closure } from './schemas';
|
|
9
10
|
export {
|
|
10
11
|
mintUuid, readRegister, ensureRegister, observeRegister$, getBoundRegisterId, readBoundRegister, bindRegister,
|
|
@@ -12,7 +13,7 @@ export {
|
|
|
12
13
|
} from './register-document';
|
|
13
14
|
export type { RegisterHost, RegisterCounters, RegisterBucket, RegisterStore, RegisterDocument } from './register-document';
|
|
14
15
|
export {
|
|
15
|
-
RegisterSessionRequiredError, RegisterSessionClosedError, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector,
|
|
16
|
+
RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector,
|
|
16
17
|
requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure,
|
|
17
18
|
} from './session-store';
|
|
18
19
|
export type { RegisterSessionCollection, CashMovementCollection, ClosureCollection } from './session-store';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RxCollection, RxDatabase } from 'rxdb';
|
|
2
|
+
import { createLogger } from '../logging';
|
|
3
|
+
import { openMigratedCollection } from '../rxdb/open-migrated-collection';
|
|
4
|
+
import { registerSessionCreator, type RegisterSession } from './schemas';
|
|
5
|
+
|
|
6
|
+
/** `addRegisterSessionCollection`'s logger: attach a sink to see dropped status writes during close. */
|
|
7
|
+
export const registerSessionsLogger = createLogger('register-sessions');
|
|
8
|
+
|
|
9
|
+
/** How long a close waits for the open; see `openMigratedCollection` for the cancellation mechanism. */
|
|
10
|
+
export const REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS = 10_000;
|
|
11
|
+
|
|
12
|
+
/** The database is closing; no session is lost. Reopen the database and call the opener again. */
|
|
13
|
+
export class RegisterSessionOpenClosedError extends Error {
|
|
14
|
+
readonly code = 'REGISTER_SESSION_OPEN_CLOSED';
|
|
15
|
+
constructor(readonly databaseName: string) {
|
|
16
|
+
super(`addRegisterSessionCollection: database ${databaseName} closed during the open; reopen it and open register_sessions again`);
|
|
17
|
+
this.name = 'RegisterSessionOpenClosedError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Adds `register_sessions` and resolves once no older session is left to migrate; the register
|
|
23
|
+
* document, a local document, is kept. On DM4 it rejects after migration stops and closes the collection.
|
|
24
|
+
* A closing database rejects with `RegisterSessionOpenClosedError`; `closeWaitMs` is for tests only.
|
|
25
|
+
* See `openMigratedCollection` for the migration and close mechanism.
|
|
26
|
+
*/
|
|
27
|
+
export function addRegisterSessionCollection(db: RxDatabase, closeWaitMs = REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS): Promise<RxCollection<RegisterSession>> {
|
|
28
|
+
return openMigratedCollection<RegisterSession>(db, { name: 'register_sessions', creator: registerSessionCreator,
|
|
29
|
+
label: 'addRegisterSessionCollection', logger: registerSessionsLogger, closedError: (name) => new RegisterSessionOpenClosedError(name) }, closeWaitMs);
|
|
30
|
+
}
|
|
@@ -51,7 +51,7 @@ export function sessionOpenCommand(s: RegisterSession): BuiltCommand {
|
|
|
51
51
|
} satisfies RegisterSessionOpenPayload };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export function sessionTransitionCommand(s: RegisterSession & { status_at: string }): BuiltCommand {
|
|
54
|
+
export function sessionTransitionCommand(s: RegisterSession & { status: RegisterSessionTransitionPayload['status']; status_at: string }): BuiltCommand {
|
|
55
55
|
return { key: `session.transition:${s.id}:${s.status}:${s.status_at}`, type: 'register.session.transition', version: 1, payload: {
|
|
56
56
|
sessionId: s.id, status: s.status, at: s.status_at,
|
|
57
57
|
...(s.status !== 'closed' || s.counted == null ? {} : { counted: s.counted }),
|
|
@@ -92,6 +92,8 @@ export function reconcileRegisterCommands({ commands, sessions, movements, closu
|
|
|
92
92
|
commands: RegisterCommandCollection; sessions: RegisterSessionCollection; movements: CashMovementCollection;
|
|
93
93
|
closures: ClosureCollection; host: RegisterHost; storeKey: string; registerId: string; now?: string; observed?: RegisterSession[];
|
|
94
94
|
}): Promise<string[]> {
|
|
95
|
+
// `conflict` and `superseded` are local states derived from the store's answers (ADR-078 decision 9), never sent as a transition.
|
|
96
|
+
const sentStatus = (s: RegisterSession): s is RegisterSession & { status: RegisterSessionTransitionPayload['status'] } => s.status === 'open' || s.status === 'counting' || s.status === 'closed';
|
|
95
97
|
let registers = chains.get(commands);
|
|
96
98
|
if (!registers) chains.set(commands, registers = new Map());
|
|
97
99
|
const run = (registers.get(registerId) ?? Promise.resolve()).then(async () => {
|
|
@@ -113,10 +115,10 @@ export function reconcileRegisterCommands({ commands, sessions, movements, closu
|
|
|
113
115
|
]);
|
|
114
116
|
const events = (observed ?? []).filter((row) => row.id === session.id && !transitioned.has(session.id)
|
|
115
117
|
&& row.status !== session.status && row.status !== 'closed')
|
|
116
|
-
.flatMap((row) => row.status_at == null ? [] : [sessionTransitionCommand({ ...row, status_at: row.status_at })]);
|
|
118
|
+
.flatMap((row) => row.status_at == null || !sentStatus(row) ? [] : [sessionTransitionCommand({ ...row, status_at: row.status_at })]);
|
|
117
119
|
events.push(...entries.filter((entry) => entry.type !== 'void').map(movementCommand),
|
|
118
120
|
...entries.filter((entry) => entry.type === 'void').map(movementCommand));
|
|
119
|
-
if (session.status_at != null) events.push(sessionTransitionCommand({ ...session, status_at: session.status_at }));
|
|
121
|
+
if (session.status_at != null && sentStatus(session)) events.push(sessionTransitionCommand({ ...session, status_at: session.status_at }));
|
|
120
122
|
return { session, number: closureRows[0]?.number, built: [sessionOpenCommand(session), ...events,
|
|
121
123
|
...closureRows.map((closure) => closureCommand(closure))] };
|
|
122
124
|
}));
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`.
|
|
4
4
|
*
|
|
5
5
|
* The host is whatever carries local documents: `register_sessions`, created with
|
|
6
|
-
* `
|
|
6
|
+
* `addRegisterSessionCollection` (a Tally database has no database-level local documents). WCPOS
|
|
7
7
|
* keys its buckets by site UUID plus a numeric WooCommerce store id; TallyUI keys them by one
|
|
8
8
|
* neutral `storeKey` the app derives (for example the connector id plus the base URL). WCPOS's
|
|
9
9
|
* module-level snapshots (`getRegisterSnapshot`, `getRegisterId`, `getCurrentBoundRegisterId`)
|
package/src/register/schemas.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The register's local collections (ADR-032): sessions, cash movements and closures. Port
|
|
3
3
|
* provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`, where they are at versions 1, 0
|
|
4
|
-
* and 1;
|
|
4
|
+
* and 1; sessions are at version 1 here (ADR-078), movements and closures at version 0.
|
|
5
5
|
*
|
|
6
6
|
* All three are local only, like `pos_orders`: the app creates them and never replicates them
|
|
7
7
|
* (a replicated collection must never take local writes, #53). WCPOS's outbox fields
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* by method) holds integers. People (`opened_by`, `created_by`, ...) are the backend's user id
|
|
14
14
|
* as a string, since not every backend's is a number.
|
|
15
15
|
*/
|
|
16
|
-
import { addRxPlugin, type JsonSchema, type RxJsonSchema } from 'rxdb';
|
|
16
|
+
import { addRxPlugin, type JsonSchema, type RxCollectionCreator, type RxJsonSchema } from 'rxdb';
|
|
17
17
|
import { RxDBLocalDocumentsPlugin } from 'rxdb/plugins/local-documents';
|
|
18
|
+
import { RxDBMigrationSchemaPlugin } from 'rxdb/plugins/migration-schema';
|
|
18
19
|
|
|
19
20
|
type TenderMap = Record<string, number>;
|
|
20
21
|
|
|
@@ -23,7 +24,10 @@ export interface RegisterSession {
|
|
|
23
24
|
register_id: string;
|
|
24
25
|
/** The app's neutral store key (see `bindRegister`). */
|
|
25
26
|
store_key?: string | null;
|
|
26
|
-
status
|
|
27
|
+
/** Stored status is an open string so a later local state costs no migration (ADR-078 decision 9); the union names today's states. */
|
|
28
|
+
status: 'open' | 'counting' | 'closed' | 'conflict' | 'superseded';
|
|
29
|
+
/** The store session this local id was resumed as (ADR-078 decision 2), never the session that superseded it; null until a resume. */
|
|
30
|
+
server_session_id?: string | null;
|
|
27
31
|
/** The store's day the session opened, `yyyy-MM-dd`. */
|
|
28
32
|
business_day?: string;
|
|
29
33
|
opened_at_gmt: string;
|
|
@@ -103,10 +107,11 @@ const nullableMinor: JsonSchema = { type: ['integer', 'null'] };
|
|
|
103
107
|
const tenders: JsonSchema = { type: 'object', additionalProperties: minor };
|
|
104
108
|
|
|
105
109
|
export const registerSessionSchema: RxJsonSchema<RegisterSession> = {
|
|
106
|
-
title: 'Register sessions', version:
|
|
110
|
+
title: 'Register sessions', version: 1, primaryKey: 'id', type: 'object', additionalProperties: false,
|
|
107
111
|
properties: {
|
|
108
112
|
id, register_id: id, store_key: nullableText,
|
|
109
|
-
status: { type: 'string',
|
|
113
|
+
status: { type: 'string', maxLength: 20 },
|
|
114
|
+
server_session_id: { ...nullableText, default: null },
|
|
110
115
|
business_day: { type: 'string', maxLength: 10 },
|
|
111
116
|
opened_at_gmt: text, opened_by: nullableText,
|
|
112
117
|
expected_float_minor: nullableMinor, counted_float_minor: minor, opening_variance_minor: nullableMinor,
|
|
@@ -126,13 +131,26 @@ export const registerSessionSchema: RxJsonSchema<RegisterSession> = {
|
|
|
126
131
|
};
|
|
127
132
|
|
|
128
133
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
134
|
+
* Internal: used by `addRegisterSessionCollection` and tests on a database that never held an older version.
|
|
135
|
+
* The register document (`register-document.ts`) is a local document on this collection.
|
|
131
136
|
*/
|
|
132
|
-
export function
|
|
137
|
+
export function registerSessionCreator(): RxCollectionCreator<RegisterSession> {
|
|
133
138
|
// addRxPlugin ignores a plugin it already has.
|
|
134
139
|
addRxPlugin(RxDBLocalDocumentsPlugin);
|
|
135
|
-
|
|
140
|
+
addRxPlugin(RxDBMigrationSchemaPlugin);
|
|
141
|
+
return { schema: registerSessionSchema, localDocuments: true, migrationStrategies: { 1: (doc) => {
|
|
142
|
+
// Every migrated row gets the field, the same shape a new row gets from the schema default.
|
|
143
|
+
doc.server_session_id ??= null;
|
|
144
|
+
return doc;
|
|
145
|
+
} } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @deprecated Use `addRegisterSessionCollection` to open register_sessions.
|
|
150
|
+
* Kept so old callers compile but fail at start instead of migrating on RxDB's own path.
|
|
151
|
+
*/
|
|
152
|
+
export function registerSessionCollection(): never {
|
|
153
|
+
throw new Error('registerSessionCollection() is gone: open register_sessions with await addRegisterSessionCollection(db). Adding it with addCollections would run RxDB\'s own migration of schema version 1, which can lose sessions (ADR-078, #371).');
|
|
136
154
|
}
|
|
137
155
|
|
|
138
156
|
export const cashMovementSchema: RxJsonSchema<CashMovement> = {
|
|
@@ -40,6 +40,15 @@ export class RegisterSessionClosedError extends Error {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** A stored session status outside the known set (written by a later build); its message can be shown to a cashier as it is. */
|
|
44
|
+
export class RegisterNeedsUpgradeError extends Error {
|
|
45
|
+
readonly code = 'REGISTER_NEEDS_UPGRADE';
|
|
46
|
+
constructor() {
|
|
47
|
+
super('This till has register data from a newer version of the app. Update the app to continue.');
|
|
48
|
+
this.name = 'RegisterNeedsUpgradeError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
43
52
|
/** ADR-068 6a: movement amounts must be safe integers, positive for paid_in/paid_out and zero for no_sale. */
|
|
44
53
|
export class RegisterMovementAmountError extends Error {
|
|
45
54
|
constructor(type: 'paid_in' | 'paid_out' | 'no_sale', amountMinor: number) {
|
|
@@ -71,13 +80,21 @@ export class RegisterMovementStrandedError extends Error {
|
|
|
71
80
|
}
|
|
72
81
|
}
|
|
73
82
|
|
|
74
|
-
/** Every move a session may make. Nothing leaves `closed`; everything else is refused.
|
|
83
|
+
/** Every move a session may make. Nothing leaves `closed`; everything else is refused.
|
|
84
|
+
* No cashier move leaves `conflict` or `superseded`; the store's answers set them (ADR-078). */
|
|
75
85
|
const TRANSITIONS: Record<RegisterSession['status'], readonly RegisterSession['status'][]> = {
|
|
76
86
|
open: ['counting', 'closed'],
|
|
77
87
|
counting: ['open', 'closed'],
|
|
78
88
|
closed: [],
|
|
89
|
+
conflict: [],
|
|
90
|
+
superseded: [],
|
|
79
91
|
};
|
|
80
92
|
|
|
93
|
+
/** Whether this build knows the stored session status. */
|
|
94
|
+
export function isKnownSessionStatus(status: string): boolean {
|
|
95
|
+
return Object.hasOwn(TRANSITIONS, status);
|
|
96
|
+
}
|
|
97
|
+
|
|
81
98
|
/**
|
|
82
99
|
* The session as stored, by primary key, not a cached `findOne(id)`: a status write that skips that
|
|
83
100
|
* query (a server sync) can't leave a live-session check stale (docs/rxdb/query-cache-reads.md). A
|
|
@@ -97,6 +114,7 @@ async function readClosure(closures: ClosureCollection | undefined, id: string)
|
|
|
97
114
|
async function requireLiveSession(sessions: RegisterSessionCollection, id: string) {
|
|
98
115
|
const session = await readSession(sessions, id);
|
|
99
116
|
if (!session) throw new RegisterSessionRequiredError();
|
|
117
|
+
if (!isKnownSessionStatus(session.status)) throw new RegisterNeedsUpgradeError();
|
|
100
118
|
if (session.status === 'closed') throw new RegisterSessionClosedError();
|
|
101
119
|
return session;
|
|
102
120
|
}
|
|
@@ -247,6 +265,7 @@ async function transition(
|
|
|
247
265
|
const at = new Date().toISOString();
|
|
248
266
|
// The guard runs again on the latest document, so a racing write can't slip past it.
|
|
249
267
|
return row.incrementalModify((doc) => {
|
|
268
|
+
if (!isKnownSessionStatus(doc.status)) throw new RegisterNeedsUpgradeError();
|
|
250
269
|
if (doc.status === 'closed' && status === 'closed') return doc;
|
|
251
270
|
if (doc.status === 'closed') throw new RegisterSessionClosedError();
|
|
252
271
|
if (!TRANSITIONS[doc.status].includes(status)) throw new Error(`invalid_session_transition:${doc.status}->${status}`);
|
|
@@ -75,7 +75,7 @@ const subscribeClosing = (listener: () => void) => {
|
|
|
75
75
|
const closedFacts = new Set<string>();
|
|
76
76
|
|
|
77
77
|
export interface UseRegisterSessionOptions {
|
|
78
|
-
/** `register_sessions`, created with `
|
|
78
|
+
/** `register_sessions`, created with `addRegisterSessionCollection`; `null` while it opens. */
|
|
79
79
|
sessions: RegisterSessionCollection | null;
|
|
80
80
|
/** `cash_movements`; `null` while it opens. */
|
|
81
81
|
movements: CashMovementCollection | null;
|
|
@@ -128,9 +128,10 @@ const unwritten = (row: RegisterSession, closureRows: readonly { id: string }[])
|
|
|
128
128
|
/**
|
|
129
129
|
* The session for this register: first the one an unapplied closure reservation names (its close
|
|
130
130
|
* was interrupted after the number was reserved, perhaps after its row was written), so it can
|
|
131
|
-
* finish; then the open or counting one; then a closed one whose closure row was never written.
|
|
131
|
+
* finish; then the open or counting one; then a closed one whose closure row was never written; null if any status is unknown.
|
|
132
132
|
*/
|
|
133
133
|
function currentSession(rows: RegisterSession[], closureRows: Closure[], reservation: Reservation) {
|
|
134
|
+
if (rows.some((row) => !store.isKnownSessionStatus(row.status))) return null;
|
|
134
135
|
return (reservation && !reservation.applied ? rows.find((row) => row.id === reservation.row.session_id) : undefined)
|
|
135
136
|
?? rows.find((row) => row.status !== 'closed')
|
|
136
137
|
?? rows.find((row) => unwritten(row, closureRows))
|
|
@@ -281,6 +282,8 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
281
282
|
|
|
282
283
|
return {
|
|
283
284
|
session,
|
|
285
|
+
/** Any unknown session status in the current snapshot means the register needs upgrade. */
|
|
286
|
+
needsUpgrade: data?.rows.some((row) => !store.isKnownSessionStatus(row.status)) ?? false,
|
|
284
287
|
movements: entries,
|
|
285
288
|
expected,
|
|
286
289
|
salesCount: sales.length,
|
|
@@ -304,6 +307,7 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
304
307
|
* Refuses with `RegisterSessionAlreadyOpenError` while the register has a live session or
|
|
305
308
|
* another open is running (in any hook instance), and with `RegisterCloseIncompleteError`
|
|
306
309
|
* while an earlier close hasn't finished: a new session would lock the till behind it.
|
|
310
|
+
* An unknown stored status refuses with `RegisterNeedsUpgradeError`.
|
|
307
311
|
*/
|
|
308
312
|
openSession: async (input: { expectedFloatMinor: number | null; countedFloatMinor: number }) => {
|
|
309
313
|
// A double tap: the second call sees the first's promise, set before its first await.
|
|
@@ -313,6 +317,7 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
313
317
|
const { sessions, closures, registerId, register } = live();
|
|
314
318
|
// The storage, not the rendered snapshot or a cached query, which can lag a new write.
|
|
315
319
|
const rows = await readFresh(sessions, { selector: { register_id: registerId } });
|
|
320
|
+
if (rows.some((row) => !store.isKnownSessionStatus(row.status))) throw new store.RegisterNeedsUpgradeError();
|
|
316
321
|
if (rows.some((row) => row.status !== 'closed')) throw new RegisterSessionAlreadyOpenError();
|
|
317
322
|
// Closure rows before the reservation: a close reserves, then inserts its row, then applies the
|
|
318
323
|
// reservation, so a close landing between the two reads is still caught by one of them.
|