@teincfood/core 0.10.27 → 0.10.28
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/catalog/hooks.d.ts +23 -7
- package/dist/catalog/hooks.js +141 -10
- package/dist/db/migrations.js +25 -0
- package/dist/db/operations.js +10 -0
- package/dist/reference/menu-apply.d.ts +1 -1
- package/dist/reference/menu-apply.js +8 -0
- package/dist/reference/operations.js +6 -2
- package/dist/reference/tombstones.d.ts +26 -0
- package/dist/reference/tombstones.js +97 -0
- package/dist/test-helpers/memory-driver.js +41 -0
- package/package.json +1 -1
package/dist/catalog/hooks.d.ts
CHANGED
|
@@ -17,20 +17,36 @@ export declare function useMenuItemsQuery(filters?: {
|
|
|
17
17
|
data: unknown[] | undefined;
|
|
18
18
|
};
|
|
19
19
|
export declare function useMenuCategoriesQuery(): ReturnType<typeof useQuery>;
|
|
20
|
-
|
|
20
|
+
type QuerySnapshot = Array<[readonly unknown[], unknown]>;
|
|
21
|
+
export declare function useCreateMenuItemMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, Record<string, unknown>, {
|
|
22
|
+
prev: QuerySnapshot;
|
|
23
|
+
}>;
|
|
21
24
|
export declare function useUpdateMenuItemMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, {
|
|
22
25
|
id: string;
|
|
23
26
|
payload: Record<string, unknown>;
|
|
24
|
-
},
|
|
25
|
-
|
|
27
|
+
}, {
|
|
28
|
+
prev: QuerySnapshot;
|
|
29
|
+
}>;
|
|
30
|
+
export declare function useDeleteMenuItemMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, string, {
|
|
31
|
+
prev: QuerySnapshot;
|
|
32
|
+
}>;
|
|
26
33
|
export declare function useToggleItemAvailabilityMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, {
|
|
27
34
|
id: string;
|
|
28
35
|
available: boolean;
|
|
29
36
|
cacheRecord?: unknown;
|
|
30
|
-
},
|
|
31
|
-
|
|
37
|
+
}, {
|
|
38
|
+
prev: QuerySnapshot;
|
|
39
|
+
}>;
|
|
40
|
+
export declare function useCreateMenuCategoryMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, Record<string, unknown>, {
|
|
41
|
+
prev: QuerySnapshot;
|
|
42
|
+
}>;
|
|
32
43
|
export declare function useUpdateMenuCategoryMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, {
|
|
33
44
|
id: string;
|
|
34
45
|
payload: Record<string, unknown>;
|
|
35
|
-
},
|
|
36
|
-
|
|
46
|
+
}, {
|
|
47
|
+
prev: QuerySnapshot;
|
|
48
|
+
}>;
|
|
49
|
+
export declare function useDeleteMenuCategoryMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, string, {
|
|
50
|
+
prev: QuerySnapshot;
|
|
51
|
+
}>;
|
|
52
|
+
export {};
|
package/dist/catalog/hooks.js
CHANGED
|
@@ -71,10 +71,21 @@ export function useMenuItemsQuery(filters, enabled = true) {
|
|
|
71
71
|
const networkData = networkQuery.data?.pages
|
|
72
72
|
? networkQuery.data.pages.flatMap((p) => p.data)
|
|
73
73
|
: undefined;
|
|
74
|
-
const data = isOnline && networkData !== undefined ? networkData : enabled ? filtered : undefined;
|
|
75
74
|
// A local snapshot (even an empty list) means the POS has something to
|
|
76
75
|
// render: never gate the whole screen on the in-flight network refresh.
|
|
77
76
|
const hasLocalSnapshot = cachedQuery.data != null;
|
|
77
|
+
// Local SQLite is the read model whenever it has a snapshot. The network
|
|
78
|
+
// query is only a cold-start fallback; it must never resurrect a row that
|
|
79
|
+
// was deleted/updated locally and is still waiting to sync.
|
|
80
|
+
const data = hasLocalSnapshot
|
|
81
|
+
? enabled
|
|
82
|
+
? filtered
|
|
83
|
+
: undefined
|
|
84
|
+
: isOnline && networkData !== undefined
|
|
85
|
+
? networkData
|
|
86
|
+
: enabled
|
|
87
|
+
? filtered
|
|
88
|
+
: undefined;
|
|
78
89
|
const pending = cachedQuery.isPending || (!hasLocalSnapshot && networkQuery.isFetching);
|
|
79
90
|
return {
|
|
80
91
|
data,
|
|
@@ -118,9 +129,14 @@ export function useMenuCategoriesQuery() {
|
|
|
118
129
|
retry: false,
|
|
119
130
|
networkMode: "offlineFirst",
|
|
120
131
|
});
|
|
121
|
-
const usingNetwork = isOnline && networkQuery.data;
|
|
122
|
-
const data = usingNetwork ? networkQuery.data : cachedQuery.data;
|
|
123
132
|
const hasCache = cachedQuery.data != null;
|
|
133
|
+
// Local categories are authoritative once a snapshot exists. The network
|
|
134
|
+
// response must not re-show a category that was just deleted offline.
|
|
135
|
+
const data = hasCache
|
|
136
|
+
? cachedQuery.data
|
|
137
|
+
: isOnline
|
|
138
|
+
? networkQuery.data
|
|
139
|
+
: cachedQuery.data;
|
|
124
140
|
// Only report "loading" when there is nothing local to show; the network
|
|
125
141
|
// refresh must never hide an already-cached category list.
|
|
126
142
|
const pending = cachedQuery.isPending || (!hasCache && networkQuery.isFetching);
|
|
@@ -133,6 +149,62 @@ export function useMenuCategoriesQuery() {
|
|
|
133
149
|
error: hasCache ? null : networkQuery.error,
|
|
134
150
|
};
|
|
135
151
|
}
|
|
152
|
+
function restoreQuerySnapshots(qc, snapshot) {
|
|
153
|
+
if (!snapshot)
|
|
154
|
+
return;
|
|
155
|
+
for (const [key, data] of snapshot) {
|
|
156
|
+
qc.setQueryData(key, data);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function recordId(record) {
|
|
160
|
+
const id = record?.id;
|
|
161
|
+
return id == null ? undefined : String(id);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Patch a menu query payload regardless of shape: the SQLite-backed query is
|
|
165
|
+
* `{ data: [...] }`, the network query is `{ pages: [{ data: [...] }] }`.
|
|
166
|
+
* This lets one optimistic patch update every mounted variant at once.
|
|
167
|
+
*/
|
|
168
|
+
function patchMenuRows(old, patch) {
|
|
169
|
+
if (Array.isArray(old))
|
|
170
|
+
return patch(old);
|
|
171
|
+
if (!old || typeof old !== "object")
|
|
172
|
+
return old;
|
|
173
|
+
const obj = old;
|
|
174
|
+
if (Array.isArray(obj.data)) {
|
|
175
|
+
return { ...obj, data: patch(obj.data) };
|
|
176
|
+
}
|
|
177
|
+
if (Array.isArray(obj.pages)) {
|
|
178
|
+
return {
|
|
179
|
+
...obj,
|
|
180
|
+
pages: obj.pages.map((page) => {
|
|
181
|
+
if (!page || typeof page !== "object")
|
|
182
|
+
return page;
|
|
183
|
+
const p = page;
|
|
184
|
+
return Array.isArray(p.data) ? { ...p, data: patch(p.data) } : page;
|
|
185
|
+
}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return old;
|
|
189
|
+
}
|
|
190
|
+
function upsertMenuRow(rows, record) {
|
|
191
|
+
const id = recordId(record);
|
|
192
|
+
if (!id)
|
|
193
|
+
return rows;
|
|
194
|
+
const index = rows.findIndex((row) => recordId(row) === id);
|
|
195
|
+
if (index >= 0) {
|
|
196
|
+
return rows.map((row, i) => i === index ? { ...row, ...record } : row);
|
|
197
|
+
}
|
|
198
|
+
return [record, ...rows];
|
|
199
|
+
}
|
|
200
|
+
function removeMenuRow(rows, id) {
|
|
201
|
+
return rows.filter((row) => recordId(row) !== id);
|
|
202
|
+
}
|
|
203
|
+
function useOptimisticMenuPatch() {
|
|
204
|
+
const qc = useQueryClient();
|
|
205
|
+
const snapshot = (queryKey) => qc.getQueriesData({ queryKey });
|
|
206
|
+
return { qc, snapshot, restore: (s) => restoreQuerySnapshots(qc, s) };
|
|
207
|
+
}
|
|
136
208
|
// Mutations — local-first.
|
|
137
209
|
//
|
|
138
210
|
// The only work on the mutation path is the local SQLite commit. Reference
|
|
@@ -141,7 +213,7 @@ export function useMenuCategoriesQuery() {
|
|
|
141
213
|
// Invalidating the cached query key makes the committed row render immediately.
|
|
142
214
|
export function useCreateMenuItemMutation() {
|
|
143
215
|
const businessId = useBusinessId();
|
|
144
|
-
const qc =
|
|
216
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
145
217
|
return useMutation({
|
|
146
218
|
mutationFn: (payload) => commitMenuMutationLocal({
|
|
147
219
|
commandType: "menu:create_item",
|
|
@@ -149,6 +221,15 @@ export function useCreateMenuItemMutation() {
|
|
|
149
221
|
entityId: payload.id,
|
|
150
222
|
payload,
|
|
151
223
|
}),
|
|
224
|
+
onMutate: (payload) => {
|
|
225
|
+
const record = payload.cacheRecord;
|
|
226
|
+
const prev = snapshot(CATALOG_KEYS.items(businessId));
|
|
227
|
+
if (record) {
|
|
228
|
+
qc.setQueriesData({ queryKey: CATALOG_KEYS.items(businessId) }, (old) => patchMenuRows(old, (rows) => upsertMenuRow(rows, record)));
|
|
229
|
+
}
|
|
230
|
+
return { prev };
|
|
231
|
+
},
|
|
232
|
+
onError: (_error, _vars, ctx) => restore(ctx?.prev),
|
|
152
233
|
onSuccess: () => {
|
|
153
234
|
void qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
|
|
154
235
|
},
|
|
@@ -156,7 +237,7 @@ export function useCreateMenuItemMutation() {
|
|
|
156
237
|
}
|
|
157
238
|
export function useUpdateMenuItemMutation() {
|
|
158
239
|
const businessId = useBusinessId();
|
|
159
|
-
const qc =
|
|
240
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
160
241
|
return useMutation({
|
|
161
242
|
mutationFn: ({ id, payload }) => commitMenuMutationLocal({
|
|
162
243
|
commandType: "menu:update_item",
|
|
@@ -164,6 +245,15 @@ export function useUpdateMenuItemMutation() {
|
|
|
164
245
|
entityId: id,
|
|
165
246
|
payload,
|
|
166
247
|
}),
|
|
248
|
+
onMutate: ({ payload }) => {
|
|
249
|
+
const record = payload.cacheRecord;
|
|
250
|
+
const prev = snapshot(CATALOG_KEYS.items(businessId));
|
|
251
|
+
if (record) {
|
|
252
|
+
qc.setQueriesData({ queryKey: CATALOG_KEYS.items(businessId) }, (old) => patchMenuRows(old, (rows) => upsertMenuRow(rows, record)));
|
|
253
|
+
}
|
|
254
|
+
return { prev };
|
|
255
|
+
},
|
|
256
|
+
onError: (_error, _vars, ctx) => restore(ctx?.prev),
|
|
167
257
|
onSuccess: () => {
|
|
168
258
|
void qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
|
|
169
259
|
},
|
|
@@ -171,7 +261,7 @@ export function useUpdateMenuItemMutation() {
|
|
|
171
261
|
}
|
|
172
262
|
export function useDeleteMenuItemMutation() {
|
|
173
263
|
const businessId = useBusinessId();
|
|
174
|
-
const qc =
|
|
264
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
175
265
|
return useMutation({
|
|
176
266
|
mutationFn: (id) => commitMenuMutationLocal({
|
|
177
267
|
commandType: "menu:delete_item",
|
|
@@ -179,6 +269,12 @@ export function useDeleteMenuItemMutation() {
|
|
|
179
269
|
entityId: id,
|
|
180
270
|
payload: { id },
|
|
181
271
|
}),
|
|
272
|
+
onMutate: (id) => {
|
|
273
|
+
const prev = snapshot(CATALOG_KEYS.items(businessId));
|
|
274
|
+
qc.setQueriesData({ queryKey: CATALOG_KEYS.items(businessId) }, (old) => patchMenuRows(old, (rows) => removeMenuRow(rows, id)));
|
|
275
|
+
return { prev };
|
|
276
|
+
},
|
|
277
|
+
onError: (_error, _vars, ctx) => restore(ctx?.prev),
|
|
182
278
|
onSuccess: () => {
|
|
183
279
|
void qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
|
|
184
280
|
},
|
|
@@ -186,7 +282,7 @@ export function useDeleteMenuItemMutation() {
|
|
|
186
282
|
}
|
|
187
283
|
export function useToggleItemAvailabilityMutation() {
|
|
188
284
|
const businessId = useBusinessId();
|
|
189
|
-
const qc =
|
|
285
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
190
286
|
return useMutation({
|
|
191
287
|
mutationFn: ({ id, available, cacheRecord }) => commitMenuMutationLocal({
|
|
192
288
|
commandType: "menu:update_item",
|
|
@@ -194,6 +290,17 @@ export function useToggleItemAvailabilityMutation() {
|
|
|
194
290
|
entityId: id,
|
|
195
291
|
payload: cacheRecord ? { id, available, cacheRecord } : { id, available },
|
|
196
292
|
}),
|
|
293
|
+
onMutate: ({ id, available, cacheRecord }) => {
|
|
294
|
+
const prev = snapshot(CATALOG_KEYS.items(businessId));
|
|
295
|
+
const record = cacheRecord && typeof cacheRecord === "object"
|
|
296
|
+
? { ...cacheRecord, id, available }
|
|
297
|
+
: undefined;
|
|
298
|
+
qc.setQueriesData({ queryKey: CATALOG_KEYS.items(businessId) }, (old) => patchMenuRows(old, (rows) => rows.map((row) => recordId(row) === id
|
|
299
|
+
? record ?? { ...row, available }
|
|
300
|
+
: row)));
|
|
301
|
+
return { prev };
|
|
302
|
+
},
|
|
303
|
+
onError: (_error, _vars, ctx) => restore(ctx?.prev),
|
|
197
304
|
onSuccess: () => {
|
|
198
305
|
void qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
|
|
199
306
|
},
|
|
@@ -201,7 +308,7 @@ export function useToggleItemAvailabilityMutation() {
|
|
|
201
308
|
}
|
|
202
309
|
export function useCreateMenuCategoryMutation() {
|
|
203
310
|
const businessId = useBusinessId();
|
|
204
|
-
const qc =
|
|
311
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
205
312
|
return useMutation({
|
|
206
313
|
mutationFn: (payload) => commitMenuMutationLocal({
|
|
207
314
|
commandType: "menu:create_category",
|
|
@@ -209,6 +316,15 @@ export function useCreateMenuCategoryMutation() {
|
|
|
209
316
|
entityId: payload.id,
|
|
210
317
|
payload,
|
|
211
318
|
}),
|
|
319
|
+
onMutate: (payload) => {
|
|
320
|
+
const record = payload.cacheRecord;
|
|
321
|
+
const prev = snapshot(CATALOG_KEYS.categories(businessId));
|
|
322
|
+
if (record) {
|
|
323
|
+
qc.setQueriesData({ queryKey: CATALOG_KEYS.categories(businessId) }, (old) => patchMenuRows(old, (rows) => upsertMenuRow(rows, record)));
|
|
324
|
+
}
|
|
325
|
+
return { prev };
|
|
326
|
+
},
|
|
327
|
+
onError: (_error, _vars, ctx) => restore(ctx?.prev),
|
|
212
328
|
onSuccess: () => {
|
|
213
329
|
void qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
|
|
214
330
|
},
|
|
@@ -216,7 +332,7 @@ export function useCreateMenuCategoryMutation() {
|
|
|
216
332
|
}
|
|
217
333
|
export function useUpdateMenuCategoryMutation() {
|
|
218
334
|
const businessId = useBusinessId();
|
|
219
|
-
const qc =
|
|
335
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
220
336
|
return useMutation({
|
|
221
337
|
mutationFn: ({ id, payload }) => commitMenuMutationLocal({
|
|
222
338
|
commandType: "menu:update_category",
|
|
@@ -224,6 +340,15 @@ export function useUpdateMenuCategoryMutation() {
|
|
|
224
340
|
entityId: id,
|
|
225
341
|
payload: { ...payload, id },
|
|
226
342
|
}),
|
|
343
|
+
onMutate: ({ payload }) => {
|
|
344
|
+
const record = payload.cacheRecord;
|
|
345
|
+
const prev = snapshot(CATALOG_KEYS.categories(businessId));
|
|
346
|
+
if (record) {
|
|
347
|
+
qc.setQueriesData({ queryKey: CATALOG_KEYS.categories(businessId) }, (old) => patchMenuRows(old, (rows) => upsertMenuRow(rows, record)));
|
|
348
|
+
}
|
|
349
|
+
return { prev };
|
|
350
|
+
},
|
|
351
|
+
onError: (_error, _vars, ctx) => restore(ctx?.prev),
|
|
227
352
|
onSuccess: () => {
|
|
228
353
|
void qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
|
|
229
354
|
},
|
|
@@ -231,7 +356,7 @@ export function useUpdateMenuCategoryMutation() {
|
|
|
231
356
|
}
|
|
232
357
|
export function useDeleteMenuCategoryMutation() {
|
|
233
358
|
const businessId = useBusinessId();
|
|
234
|
-
const qc =
|
|
359
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
235
360
|
return useMutation({
|
|
236
361
|
mutationFn: (id) => commitMenuMutationLocal({
|
|
237
362
|
commandType: "menu:delete_category",
|
|
@@ -239,6 +364,12 @@ export function useDeleteMenuCategoryMutation() {
|
|
|
239
364
|
entityId: id,
|
|
240
365
|
payload: { id },
|
|
241
366
|
}),
|
|
367
|
+
onMutate: (id) => {
|
|
368
|
+
const prev = snapshot(CATALOG_KEYS.categories(businessId));
|
|
369
|
+
qc.setQueriesData({ queryKey: CATALOG_KEYS.categories(businessId) }, (old) => patchMenuRows(old, (rows) => removeMenuRow(rows, id)));
|
|
370
|
+
return { prev };
|
|
371
|
+
},
|
|
372
|
+
onError: (_error, _vars, ctx) => restore(ctx?.prev),
|
|
242
373
|
onSuccess: () => {
|
|
243
374
|
void qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
|
|
244
375
|
},
|
package/dist/db/migrations.js
CHANGED
|
@@ -497,4 +497,29 @@ export const MIGRATIONS = [
|
|
|
497
497
|
`);
|
|
498
498
|
},
|
|
499
499
|
},
|
|
500
|
+
{
|
|
501
|
+
version: 15,
|
|
502
|
+
up: async (db) => {
|
|
503
|
+
// Delete tombstones for mutable reference collections.
|
|
504
|
+
//
|
|
505
|
+
// A full-array reference pull from the cloud/LAN must never resurrect a
|
|
506
|
+
// row that was deleted locally while offline. Tombstones are retained
|
|
507
|
+
// until a newer record with the same id arrives or the row is recreated
|
|
508
|
+
// locally. This is the delete half of LWW; mutable-record versioning
|
|
509
|
+
// will build on the same table.
|
|
510
|
+
await db.execAsync(`
|
|
511
|
+
CREATE TABLE IF NOT EXISTS record_tombstones (
|
|
512
|
+
business_id TEXT NOT NULL,
|
|
513
|
+
collection TEXT NOT NULL,
|
|
514
|
+
entity_id TEXT NOT NULL,
|
|
515
|
+
deleted_at TEXT NOT NULL,
|
|
516
|
+
device_id TEXT,
|
|
517
|
+
PRIMARY KEY (business_id, collection, entity_id)
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
CREATE INDEX IF NOT EXISTS idx_record_tombstones_collection
|
|
521
|
+
ON record_tombstones(business_id, collection);
|
|
522
|
+
`);
|
|
523
|
+
},
|
|
524
|
+
},
|
|
500
525
|
];
|
package/dist/db/operations.js
CHANGED
|
@@ -516,6 +516,10 @@ export async function wipeForNewInstall(db) {
|
|
|
516
516
|
await db.execAsync(`DELETE FROM reference_data;`);
|
|
517
517
|
}
|
|
518
518
|
catch { }
|
|
519
|
+
try {
|
|
520
|
+
await db.execAsync(`DELETE FROM record_tombstones;`);
|
|
521
|
+
}
|
|
522
|
+
catch { }
|
|
519
523
|
try {
|
|
520
524
|
await db.execAsync(`DELETE FROM commands;`);
|
|
521
525
|
}
|
|
@@ -531,6 +535,12 @@ export async function wipeForNewInstall(db) {
|
|
|
531
535
|
}
|
|
532
536
|
export async function clearReferenceData(db) {
|
|
533
537
|
await db.execAsync(`DELETE FROM reference_data;`);
|
|
538
|
+
try {
|
|
539
|
+
await db.execAsync(`DELETE FROM record_tombstones;`);
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
// Older schema before migration 15.
|
|
543
|
+
}
|
|
534
544
|
}
|
|
535
545
|
const dbChangeListeners = new Set();
|
|
536
546
|
export function subscribeDbChanges(listener) {
|
|
@@ -7,7 +7,7 @@ export declare const MENU_CATEGORY_KEY: ReferenceDataKey;
|
|
|
7
7
|
* (for reference_data.changed fan-out) and whether anything was written.
|
|
8
8
|
* Idempotent by entity id: re-applying the same command converges.
|
|
9
9
|
*/
|
|
10
|
-
export declare function applyMenuCommandLocal(businessId: string, command: Pick<Command, "command_type" | "entity_id" | "command_id" | "payload">): Promise<{
|
|
10
|
+
export declare function applyMenuCommandLocal(businessId: string, command: Pick<Command, "command_type" | "entity_id" | "command_id" | "payload" | "timestamp">): Promise<{
|
|
11
11
|
keys: ReferenceDataKey[];
|
|
12
12
|
applied: boolean;
|
|
13
13
|
}>;
|
|
@@ -18,6 +18,7 @@ import { syncLogger } from "../utils/sync-logger";
|
|
|
18
18
|
import { generateUUID } from "../utils/uuid";
|
|
19
19
|
import { hlcToString, tickHLC, hlcFromString } from "../utils/hlc";
|
|
20
20
|
import { getAppInstanceId } from "../adapters/devices";
|
|
21
|
+
import { clearRecordTombstone, markRecordDeleted } from "./tombstones";
|
|
21
22
|
export const MENU_ITEM_KEY = "menu:items";
|
|
22
23
|
export const MENU_CATEGORY_KEY = "menu:categories";
|
|
23
24
|
function asArray(payload) {
|
|
@@ -90,6 +91,7 @@ async function patchKey(businessId, key, patch) {
|
|
|
90
91
|
export async function applyMenuCommandLocal(businessId, command) {
|
|
91
92
|
const payload = (command.payload ?? {});
|
|
92
93
|
const idOf = (v) => (typeof v === "string" && v ? v : null);
|
|
94
|
+
const stamp = command.timestamp ?? new Date().toISOString();
|
|
93
95
|
switch (command.command_type) {
|
|
94
96
|
case "menu:create_item": {
|
|
95
97
|
const id = idOf(command.entity_id) ?? idOf(payload.id) ?? generateUUID();
|
|
@@ -103,6 +105,7 @@ export async function applyMenuCommandLocal(businessId, command) {
|
|
|
103
105
|
stashPrior(arr, businessId, id);
|
|
104
106
|
return arr.some((o) => String(o.id) === id) ? arr.map((o) => (String(o.id) === id ? item : o)) : [...arr, item];
|
|
105
107
|
});
|
|
108
|
+
await clearRecordTombstone(await openSyncDatabase(), businessId, MENU_ITEM_KEY, id);
|
|
106
109
|
return { keys: [MENU_ITEM_KEY], applied };
|
|
107
110
|
}
|
|
108
111
|
case "menu:update_item": {
|
|
@@ -118,6 +121,7 @@ export async function applyMenuCommandLocal(businessId, command) {
|
|
|
118
121
|
stashPrior(arr, businessId, id);
|
|
119
122
|
return arr.map((o) => (String(o.id) === id ? { ...record, id } : o));
|
|
120
123
|
});
|
|
124
|
+
await clearRecordTombstone(await openSyncDatabase(), businessId, MENU_ITEM_KEY, id);
|
|
121
125
|
return { keys: [MENU_ITEM_KEY], applied };
|
|
122
126
|
}
|
|
123
127
|
case "menu:delete_item": {
|
|
@@ -125,6 +129,7 @@ export async function applyMenuCommandLocal(businessId, command) {
|
|
|
125
129
|
if (!id)
|
|
126
130
|
return { keys: [MENU_ITEM_KEY], applied: false };
|
|
127
131
|
const applied = await patchKey(businessId, MENU_ITEM_KEY, (arr) => arr.filter((o) => String(o.id) !== id));
|
|
132
|
+
await markRecordDeleted(await openSyncDatabase(), businessId, MENU_ITEM_KEY, id, stamp);
|
|
128
133
|
return { keys: [MENU_ITEM_KEY], applied };
|
|
129
134
|
}
|
|
130
135
|
case "menu:create_category": {
|
|
@@ -139,6 +144,7 @@ export async function applyMenuCommandLocal(businessId, command) {
|
|
|
139
144
|
stashPrior(arr, businessId, id);
|
|
140
145
|
return arr.some((o) => String(o.id) === id) ? arr.map((o) => (String(o.id) === id ? cat : o)) : [...arr, cat];
|
|
141
146
|
});
|
|
147
|
+
await clearRecordTombstone(await openSyncDatabase(), businessId, MENU_CATEGORY_KEY, id);
|
|
142
148
|
return { keys: [MENU_CATEGORY_KEY], applied };
|
|
143
149
|
}
|
|
144
150
|
case "menu:update_category": {
|
|
@@ -154,6 +160,7 @@ export async function applyMenuCommandLocal(businessId, command) {
|
|
|
154
160
|
stashPrior(arr, businessId, id);
|
|
155
161
|
return arr.map((o) => (String(o.id) === id ? { ...record, id } : o));
|
|
156
162
|
});
|
|
163
|
+
await clearRecordTombstone(await openSyncDatabase(), businessId, MENU_CATEGORY_KEY, id);
|
|
157
164
|
return { keys: [MENU_CATEGORY_KEY], applied };
|
|
158
165
|
}
|
|
159
166
|
case "menu:delete_category": {
|
|
@@ -161,6 +168,7 @@ export async function applyMenuCommandLocal(businessId, command) {
|
|
|
161
168
|
if (!id)
|
|
162
169
|
return { keys: [MENU_CATEGORY_KEY], applied: false };
|
|
163
170
|
const applied = await patchKey(businessId, MENU_CATEGORY_KEY, (arr) => arr.filter((o) => String(o.id) !== id));
|
|
171
|
+
await markRecordDeleted(await openSyncDatabase(), businessId, MENU_CATEGORY_KEY, id, stamp);
|
|
164
172
|
return { keys: [MENU_CATEGORY_KEY], applied };
|
|
165
173
|
}
|
|
166
174
|
default:
|
|
@@ -7,6 +7,7 @@ import { openSyncDatabase } from "../db/connection";
|
|
|
7
7
|
import { getAppInstanceId } from "../adapters/devices";
|
|
8
8
|
import { notifyDbChange } from "../db/operations";
|
|
9
9
|
import { isServerAuthoritativeKey } from "./entitlements";
|
|
10
|
+
import { applyTombstonesToPayload } from "./tombstones";
|
|
10
11
|
export async function getReferenceData(db, businessId, key) {
|
|
11
12
|
const row = await db.getFirstAsync("SELECT * FROM reference_data WHERE business_id = ? AND key = ?;", businessId, key);
|
|
12
13
|
if (!row)
|
|
@@ -44,11 +45,14 @@ export async function mergeReferenceData(db, businessId, key, incoming, hlc, eve
|
|
|
44
45
|
if (isServerAuthoritativeKey(key))
|
|
45
46
|
return;
|
|
46
47
|
const existing = await getReferenceData(db, businessId, key);
|
|
48
|
+
// Local deletes win over stale full-array pulls. This must run before the
|
|
49
|
+
// cache is written so a deleted menu row can never be resurrected.
|
|
50
|
+
const incomingWithoutTombstones = await applyTombstonesToPayload(db, businessId, key, incoming);
|
|
47
51
|
if (!existing) {
|
|
48
52
|
await setReferenceData(db, businessId, {
|
|
49
53
|
key,
|
|
50
54
|
version: hlc,
|
|
51
|
-
payload:
|
|
55
|
+
payload: incomingWithoutTombstones,
|
|
52
56
|
downloadedAt: new Date().toISOString(),
|
|
53
57
|
lastEventId: eventId,
|
|
54
58
|
lastHlc: hlc,
|
|
@@ -63,7 +67,7 @@ export async function mergeReferenceData(db, businessId, key, incoming, hlc, eve
|
|
|
63
67
|
await setReferenceData(db, businessId, {
|
|
64
68
|
key,
|
|
65
69
|
version: hlc,
|
|
66
|
-
payload:
|
|
70
|
+
payload: incomingWithoutTombstones,
|
|
67
71
|
downloadedAt: new Date().toISOString(),
|
|
68
72
|
lastEventId: eventId,
|
|
69
73
|
lastHlc: hlc,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delete tombstones for mutable reference collections.
|
|
3
|
+
*
|
|
4
|
+
* LWW alone is not enough for deletes: a stale full-array pull would simply
|
|
5
|
+
* re-add a row that was removed on this device. A tombstone records the
|
|
6
|
+
* deletion locally so every reference write filters it out until either:
|
|
7
|
+
*
|
|
8
|
+
* - a newer record with the same id arrives (server clock wins), or
|
|
9
|
+
* - the user recreates the row locally (explicit local intent wins).
|
|
10
|
+
*
|
|
11
|
+
* Collections are keyed by reference-data key today. The same table will be
|
|
12
|
+
* used by the per-record HLC migration for menu/category/settings data.
|
|
13
|
+
*/
|
|
14
|
+
import type { SQLiteDatabase } from "../adapters/sqlite";
|
|
15
|
+
import type { ReferenceDataKey } from "./types";
|
|
16
|
+
export declare function tombstoneCollectionFor(key: ReferenceDataKey): string | null;
|
|
17
|
+
export declare function markRecordDeleted(db: SQLiteDatabase, businessId: string, key: ReferenceDataKey, entityId: string, deletedAt: string, deviceId?: string): Promise<void>;
|
|
18
|
+
export declare function clearRecordTombstone(db: SQLiteDatabase, businessId: string, key: ReferenceDataKey, entityId: string): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Filter a reference payload against local delete tombstones.
|
|
21
|
+
*
|
|
22
|
+
* A row whose `updated_at` is newer than the tombstone is a real recreation
|
|
23
|
+
* (for example, another device re-created the same id) and wins; its
|
|
24
|
+
* tombstone is cleared. Everything else stays deleted locally.
|
|
25
|
+
*/
|
|
26
|
+
export declare function applyTombstonesToPayload<T>(db: SQLiteDatabase, businessId: string, key: ReferenceDataKey, payload: T): Promise<T>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delete tombstones for mutable reference collections.
|
|
3
|
+
*
|
|
4
|
+
* LWW alone is not enough for deletes: a stale full-array pull would simply
|
|
5
|
+
* re-add a row that was removed on this device. A tombstone records the
|
|
6
|
+
* deletion locally so every reference write filters it out until either:
|
|
7
|
+
*
|
|
8
|
+
* - a newer record with the same id arrives (server clock wins), or
|
|
9
|
+
* - the user recreates the row locally (explicit local intent wins).
|
|
10
|
+
*
|
|
11
|
+
* Collections are keyed by reference-data key today. The same table will be
|
|
12
|
+
* used by the per-record HLC migration for menu/category/settings data.
|
|
13
|
+
*/
|
|
14
|
+
import { getAppInstanceId } from "../adapters/devices";
|
|
15
|
+
const TOMBSTONE_COLLECTIONS = {
|
|
16
|
+
"menu:items": "menu:items",
|
|
17
|
+
"menu:categories": "menu:categories",
|
|
18
|
+
};
|
|
19
|
+
export function tombstoneCollectionFor(key) {
|
|
20
|
+
return TOMBSTONE_COLLECTIONS[key] ?? null;
|
|
21
|
+
}
|
|
22
|
+
export async function markRecordDeleted(db, businessId, key, entityId, deletedAt, deviceId = getAppInstanceId()) {
|
|
23
|
+
const collection = tombstoneCollectionFor(key);
|
|
24
|
+
if (!collection || !businessId || !entityId)
|
|
25
|
+
return;
|
|
26
|
+
await db.runAsync(`
|
|
27
|
+
INSERT INTO record_tombstones (
|
|
28
|
+
business_id, collection, entity_id, deleted_at, device_id
|
|
29
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
30
|
+
ON CONFLICT(business_id, collection, entity_id) DO UPDATE SET
|
|
31
|
+
deleted_at = excluded.deleted_at,
|
|
32
|
+
device_id = excluded.device_id
|
|
33
|
+
WHERE excluded.deleted_at > record_tombstones.deleted_at;
|
|
34
|
+
`, businessId, collection, String(entityId), deletedAt, deviceId);
|
|
35
|
+
}
|
|
36
|
+
export async function clearRecordTombstone(db, businessId, key, entityId) {
|
|
37
|
+
const collection = tombstoneCollectionFor(key);
|
|
38
|
+
if (!collection || !businessId || !entityId)
|
|
39
|
+
return;
|
|
40
|
+
await db.runAsync("DELETE FROM record_tombstones WHERE business_id = ? AND collection = ? AND entity_id = ?;", businessId, collection, String(entityId));
|
|
41
|
+
}
|
|
42
|
+
function rowId(row) {
|
|
43
|
+
const id = row?.id;
|
|
44
|
+
return id == null ? null : String(id);
|
|
45
|
+
}
|
|
46
|
+
function rowStamp(row) {
|
|
47
|
+
const rec = row;
|
|
48
|
+
const raw = rec?.updated_at ?? rec?.inserted_at;
|
|
49
|
+
const parsed = typeof raw === "string" ? Date.parse(raw) : NaN;
|
|
50
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Filter a reference payload against local delete tombstones.
|
|
54
|
+
*
|
|
55
|
+
* A row whose `updated_at` is newer than the tombstone is a real recreation
|
|
56
|
+
* (for example, another device re-created the same id) and wins; its
|
|
57
|
+
* tombstone is cleared. Everything else stays deleted locally.
|
|
58
|
+
*/
|
|
59
|
+
export async function applyTombstonesToPayload(db, businessId, key, payload) {
|
|
60
|
+
const collection = tombstoneCollectionFor(key);
|
|
61
|
+
if (!collection || !businessId || payload == null)
|
|
62
|
+
return payload;
|
|
63
|
+
const tombstones = await db.getAllAsync("SELECT entity_id, deleted_at FROM record_tombstones WHERE business_id = ? AND collection = ?;", businessId, collection);
|
|
64
|
+
if (tombstones.length === 0)
|
|
65
|
+
return payload;
|
|
66
|
+
const deletedAt = new Map(tombstones.map((row) => [row.entity_id, row.deleted_at]));
|
|
67
|
+
const resurrected = [];
|
|
68
|
+
const filterRows = (rows) => rows.filter((row) => {
|
|
69
|
+
const id = rowId(row);
|
|
70
|
+
if (!id)
|
|
71
|
+
return true;
|
|
72
|
+
const deleted = deletedAt.get(id);
|
|
73
|
+
if (!deleted)
|
|
74
|
+
return true;
|
|
75
|
+
if (rowStamp(row) > Date.parse(deleted)) {
|
|
76
|
+
resurrected.push(id);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
});
|
|
81
|
+
let next = payload;
|
|
82
|
+
if (Array.isArray(payload)) {
|
|
83
|
+
next = filterRows(payload);
|
|
84
|
+
}
|
|
85
|
+
else if (typeof payload === "object") {
|
|
86
|
+
const obj = payload;
|
|
87
|
+
if (Array.isArray(obj.data)) {
|
|
88
|
+
next = { ...obj, data: filterRows(obj.data) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (resurrected.length > 0) {
|
|
92
|
+
for (const id of resurrected) {
|
|
93
|
+
await clearRecordTombstone(db, businessId, key, id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
@@ -7,6 +7,7 @@ export function createMemoryDriver() {
|
|
|
7
7
|
events: [],
|
|
8
8
|
orders: [],
|
|
9
9
|
sync_jobs: [],
|
|
10
|
+
record_tombstones: [],
|
|
10
11
|
};
|
|
11
12
|
let autoId = { commands: 1, events: 1, migrations: 1, sync_jobs: 1 };
|
|
12
13
|
const driver = {
|
|
@@ -23,6 +24,34 @@ export function createMemoryDriver() {
|
|
|
23
24
|
tables.migrations.push({ version, applied_at: args[1] });
|
|
24
25
|
return { lastInsertRowId: autoId.migrations++, changes: 1 };
|
|
25
26
|
}
|
|
27
|
+
// record_tombstones upsert
|
|
28
|
+
if (s.includes("INSERT INTO record_tombstones")) {
|
|
29
|
+
const [business_id, collection, entity_id, deleted_at, device_id] = args;
|
|
30
|
+
const existing = tables.record_tombstones.find((r) => r.business_id === business_id &&
|
|
31
|
+
r.collection === collection &&
|
|
32
|
+
r.entity_id === entity_id);
|
|
33
|
+
if (existing) {
|
|
34
|
+
if (String(deleted_at) > String(existing.deleted_at)) {
|
|
35
|
+
existing.deleted_at = deleted_at;
|
|
36
|
+
existing.device_id = device_id;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
tables.record_tombstones.push({ business_id, collection, entity_id, deleted_at, device_id });
|
|
41
|
+
}
|
|
42
|
+
return { lastInsertRowId: 1, changes: 1 };
|
|
43
|
+
}
|
|
44
|
+
if (s.includes("DELETE FROM record_tombstones WHERE business_id = ? AND collection = ? AND entity_id = ?")) {
|
|
45
|
+
const [business_id, collection, entity_id] = args;
|
|
46
|
+
tables.record_tombstones = tables.record_tombstones.filter((r) => !(r.business_id === business_id &&
|
|
47
|
+
r.collection === collection &&
|
|
48
|
+
r.entity_id === entity_id));
|
|
49
|
+
return { lastInsertRowId: 0, changes: 1 };
|
|
50
|
+
}
|
|
51
|
+
if (s.includes("DELETE FROM record_tombstones")) {
|
|
52
|
+
tables.record_tombstones = [];
|
|
53
|
+
return { lastInsertRowId: 0, changes: 1 };
|
|
54
|
+
}
|
|
26
55
|
// reference_data insert
|
|
27
56
|
if (s.includes("INSERT INTO reference_data")) {
|
|
28
57
|
const [business_id, key, version, payload, downloaded_at, last_event_id, last_hlc] = args;
|
|
@@ -389,6 +418,18 @@ export function createMemoryDriver() {
|
|
|
389
418
|
},
|
|
390
419
|
async getAllAsync(sql, ...args) {
|
|
391
420
|
const s = sql.replace(/\s+/g, " ").trim();
|
|
421
|
+
if (s.includes("SELECT entity_id, deleted_at FROM record_tombstones WHERE business_id = ? AND collection = ?")) {
|
|
422
|
+
const [business_id, collection] = args;
|
|
423
|
+
return tables.record_tombstones
|
|
424
|
+
.filter((r) => r.business_id === business_id && r.collection === collection)
|
|
425
|
+
.map((r) => ({ entity_id: r.entity_id, deleted_at: r.deleted_at }));
|
|
426
|
+
}
|
|
427
|
+
if (s.includes("SELECT entity_id FROM record_tombstones WHERE business_id = ? AND entity_id = ?")) {
|
|
428
|
+
const [business_id, entity_id] = args;
|
|
429
|
+
return tables.record_tombstones
|
|
430
|
+
.filter((r) => r.business_id === business_id && r.entity_id === entity_id)
|
|
431
|
+
.map((r) => ({ entity_id: r.entity_id }));
|
|
432
|
+
}
|
|
392
433
|
if (s.includes("SELECT key FROM reference_data WHERE business_id = ? ORDER BY key")) {
|
|
393
434
|
const [business_id] = args;
|
|
394
435
|
return tables.reference_data.filter((r) => r.business_id === business_id).map((r) => ({ key: r.key }));
|
package/package.json
CHANGED