@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.
@@ -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
- export declare function useCreateMenuItemMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, Record<string, unknown>, unknown>;
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
- }, unknown>;
25
- export declare function useDeleteMenuItemMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, string, unknown>;
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
- }, unknown>;
31
- export declare function useCreateMenuCategoryMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, Record<string, unknown>, unknown>;
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
- }, unknown>;
36
- export declare function useDeleteMenuCategoryMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, string, unknown>;
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 {};
@@ -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 = useQueryClient();
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 = useQueryClient();
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 = useQueryClient();
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 = useQueryClient();
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 = useQueryClient();
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 = useQueryClient();
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 = useQueryClient();
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
  },
@@ -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
  ];
@@ -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: incoming,
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: incoming,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.10.27",
3
+ "version": "0.10.28",
4
4
  "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",