@teincfood/core 0.10.26 → 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,19 +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<unknown, Error, Record<string, unknown>, unknown>;
21
- export declare function useUpdateMenuItemMutation(): import("@tanstack/react-query").UseMutationResult<unknown, Error, {
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
+ }>;
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<unknown, Error, string, unknown>;
26
- export declare function useToggleItemAvailabilityMutation(): import("@tanstack/react-query").UseMutationResult<unknown, Error, {
27
+ }, {
28
+ prev: QuerySnapshot;
29
+ }>;
30
+ export declare function useDeleteMenuItemMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, string, {
31
+ prev: QuerySnapshot;
32
+ }>;
33
+ export declare function useToggleItemAvailabilityMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, {
27
34
  id: string;
28
35
  available: boolean;
29
- }, unknown>;
30
- export declare function useCreateMenuCategoryMutation(): import("@tanstack/react-query").UseMutationResult<unknown, Error, Record<string, unknown>, unknown>;
31
- export declare function useUpdateMenuCategoryMutation(): import("@tanstack/react-query").UseMutationResult<unknown, Error, {
36
+ cacheRecord?: 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
+ }>;
43
+ export declare function useUpdateMenuCategoryMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, {
32
44
  id: string;
33
45
  payload: Record<string, unknown>;
34
- }, unknown>;
35
- export declare function useDeleteMenuCategoryMutation(): import("@tanstack/react-query").UseMutationResult<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 {};
@@ -11,8 +11,7 @@ import { menuService } from "../adapters/services";
11
11
  import { getReferenceData } from "../reference/operations";
12
12
  import { openSyncDatabase } from "../db/connection";
13
13
  import { referenceSyncService } from "../reference/service";
14
- import { emitReferenceChanged } from "../reference/events";
15
- import { runOfflineCapableMutation } from "../sync/offline-capable";
14
+ import { commitMenuMutationLocal } from "../sync/offline-capable";
16
15
  import { useIsEffectivelyOnline } from "../hooks/use-infinite-list";
17
16
  export const CATALOG_KEYS = {
18
17
  all: ["menu"],
@@ -72,10 +71,21 @@ export function useMenuItemsQuery(filters, enabled = true) {
72
71
  const networkData = networkQuery.data?.pages
73
72
  ? networkQuery.data.pages.flatMap((p) => p.data)
74
73
  : undefined;
75
- const data = isOnline && networkData !== undefined ? networkData : enabled ? filtered : undefined;
76
74
  // A local snapshot (even an empty list) means the POS has something to
77
75
  // render: never gate the whole screen on the in-flight network refresh.
78
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;
79
89
  const pending = cachedQuery.isPending || (!hasLocalSnapshot && networkQuery.isFetching);
80
90
  return {
81
91
  data,
@@ -119,9 +129,14 @@ export function useMenuCategoriesQuery() {
119
129
  retry: false,
120
130
  networkMode: "offlineFirst",
121
131
  });
122
- const usingNetwork = isOnline && networkQuery.data;
123
- const data = usingNetwork ? networkQuery.data : cachedQuery.data;
124
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;
125
140
  // Only report "loading" when there is nothing local to show; the network
126
141
  // refresh must never hide an already-cached category list.
127
142
  const pending = cachedQuery.isPending || (!hasCache && networkQuery.isFetching);
@@ -134,107 +149,229 @@ export function useMenuCategoriesQuery() {
134
149
  error: hasCache ? null : networkQuery.error,
135
150
  };
136
151
  }
137
- // Mutations — offline-capable
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
+ }
208
+ // Mutations — local-first.
209
+ //
210
+ // The only work on the mutation path is the local SQLite commit. Reference
211
+ // fan-out and cloud/LAN replay are scheduled by commitMenuMutationLocal AFTER
212
+ // the outbox row exists, so a slow or absent network can never delay a save.
213
+ // Invalidating the cached query key makes the committed row render immediately.
138
214
  export function useCreateMenuItemMutation() {
139
215
  const businessId = useBusinessId();
140
- const qc = useQueryClient();
216
+ const { qc, snapshot, restore } = useOptimisticMenuPatch();
141
217
  return useMutation({
142
- mutationFn: (payload) => {
143
- // cacheRecord is local-cache only — never on the wire.
144
- const { cacheRecord: _omit, ...wire } = payload;
145
- void _omit;
146
- return runOfflineCapableMutation({ run: () => menuService.createMenuItem(wire), commandType: "menu:create_item", businessId: businessId, entityId: payload.id, payload });
218
+ mutationFn: (payload) => commitMenuMutationLocal({
219
+ commandType: "menu:create_item",
220
+ businessId: businessId,
221
+ entityId: payload.id,
222
+ payload,
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 };
147
231
  },
232
+ onError: (_error, _vars, ctx) => restore(ctx?.prev),
148
233
  onSuccess: () => {
149
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
150
- if (businessId)
151
- emitReferenceChanged(businessId, ["menu:items"]);
234
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
152
235
  },
153
236
  });
154
237
  }
155
238
  export function useUpdateMenuItemMutation() {
156
239
  const businessId = useBusinessId();
157
- const qc = useQueryClient();
240
+ const { qc, snapshot, restore } = useOptimisticMenuPatch();
158
241
  return useMutation({
159
- mutationFn: ({ id, payload }) => {
160
- const { cacheRecord: _omit, ...wire } = payload;
161
- void _omit;
162
- return runOfflineCapableMutation({ run: () => menuService.updateMenuItem(id, wire), commandType: "menu:update_item", businessId: businessId, entityId: id, payload });
242
+ mutationFn: ({ id, payload }) => commitMenuMutationLocal({
243
+ commandType: "menu:update_item",
244
+ businessId: businessId,
245
+ entityId: id,
246
+ payload,
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 };
163
255
  },
256
+ onError: (_error, _vars, ctx) => restore(ctx?.prev),
164
257
  onSuccess: () => {
165
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
166
- if (businessId)
167
- emitReferenceChanged(businessId, ["menu:items"]);
258
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
168
259
  },
169
260
  });
170
261
  }
171
262
  export function useDeleteMenuItemMutation() {
172
263
  const businessId = useBusinessId();
173
- const qc = useQueryClient();
264
+ const { qc, snapshot, restore } = useOptimisticMenuPatch();
174
265
  return useMutation({
175
- mutationFn: (id) => runOfflineCapableMutation({ run: () => menuService.deleteMenuItem(id), commandType: "menu:delete_item", businessId: businessId, entityId: id, payload: { id } }),
266
+ mutationFn: (id) => commitMenuMutationLocal({
267
+ commandType: "menu:delete_item",
268
+ businessId: businessId,
269
+ entityId: id,
270
+ payload: { id },
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),
176
278
  onSuccess: () => {
177
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
178
- if (businessId)
179
- emitReferenceChanged(businessId, ["menu:items"]);
279
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
180
280
  },
181
281
  });
182
282
  }
183
283
  export function useToggleItemAvailabilityMutation() {
184
284
  const businessId = useBusinessId();
185
- const qc = useQueryClient();
285
+ const { qc, snapshot, restore } = useOptimisticMenuPatch();
186
286
  return useMutation({
187
- // Availability is an update_item with an availability patch — same engine
188
- // route (cloud → LAN → queue), same cache convergence.
189
- mutationFn: ({ id, available }) => runOfflineCapableMutation({ run: () => menuService.updateMenuItem(id, { available }), commandType: "menu:update_item", businessId: businessId, entityId: id, payload: { id, available } }),
287
+ mutationFn: ({ id, available, cacheRecord }) => commitMenuMutationLocal({
288
+ commandType: "menu:update_item",
289
+ businessId: businessId,
290
+ entityId: id,
291
+ payload: cacheRecord ? { id, available, cacheRecord } : { id, available },
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),
190
304
  onSuccess: () => {
191
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
192
- if (businessId)
193
- emitReferenceChanged(businessId, ["menu:items"]);
305
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
194
306
  },
195
307
  });
196
308
  }
197
309
  export function useCreateMenuCategoryMutation() {
198
310
  const businessId = useBusinessId();
199
- const qc = useQueryClient();
311
+ const { qc, snapshot, restore } = useOptimisticMenuPatch();
200
312
  return useMutation({
201
- mutationFn: (payload) => {
202
- const { cacheRecord: _omit, ...wire } = payload;
203
- void _omit;
204
- return runOfflineCapableMutation({ run: () => menuService.createCategory(businessId, wire), commandType: "menu:create_category", businessId: businessId, entityId: payload.id, payload });
313
+ mutationFn: (payload) => commitMenuMutationLocal({
314
+ commandType: "menu:create_category",
315
+ businessId: businessId,
316
+ entityId: payload.id,
317
+ payload,
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 };
205
326
  },
327
+ onError: (_error, _vars, ctx) => restore(ctx?.prev),
206
328
  onSuccess: () => {
207
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
208
- if (businessId)
209
- emitReferenceChanged(businessId, ["menu:categories"]);
329
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
210
330
  },
211
331
  });
212
332
  }
213
333
  export function useUpdateMenuCategoryMutation() {
214
334
  const businessId = useBusinessId();
215
- const qc = useQueryClient();
335
+ const { qc, snapshot, restore } = useOptimisticMenuPatch();
216
336
  return useMutation({
217
- mutationFn: ({ id, payload }) => {
218
- const { cacheRecord: _omit, ...wire } = payload;
219
- void _omit;
220
- return runOfflineCapableMutation({ run: () => menuService.updateCategory(id, wire), commandType: "menu:update_category", businessId: businessId, entityId: id, payload: { ...payload, id } });
337
+ mutationFn: ({ id, payload }) => commitMenuMutationLocal({
338
+ commandType: "menu:update_category",
339
+ businessId: businessId,
340
+ entityId: id,
341
+ payload: { ...payload, id },
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 };
221
350
  },
351
+ onError: (_error, _vars, ctx) => restore(ctx?.prev),
222
352
  onSuccess: () => {
223
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
224
- if (businessId)
225
- emitReferenceChanged(businessId, ["menu:categories"]);
353
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
226
354
  },
227
355
  });
228
356
  }
229
357
  export function useDeleteMenuCategoryMutation() {
230
358
  const businessId = useBusinessId();
231
- const qc = useQueryClient();
359
+ const { qc, snapshot, restore } = useOptimisticMenuPatch();
232
360
  return useMutation({
233
- mutationFn: (id) => runOfflineCapableMutation({ run: () => menuService.deleteCategory(id), commandType: "menu:delete_category", businessId: businessId, entityId: id, payload: { id } }),
361
+ mutationFn: (id) => commitMenuMutationLocal({
362
+ commandType: "menu:delete_category",
363
+ businessId: businessId,
364
+ entityId: id,
365
+ payload: { id },
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),
234
373
  onSuccess: () => {
235
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
236
- if (businessId)
237
- emitReferenceChanged(businessId, ["menu:categories"]);
374
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
238
375
  },
239
376
  });
240
377
  }
@@ -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) {
package/dist/index.d.ts CHANGED
@@ -73,8 +73,8 @@ export { localNodeTransport } from "./sync/transport-local-node";
73
73
  export * from "./sync/transport-local-node";
74
74
  export * from "./sync/order-snapshots.service";
75
75
  export { hydratePendingOrders, applyPendingOrderEvent, applyOrderStateChangeEvent } from "./sync/pending-orders.service";
76
- export { runOfflineCapableMutation } from "./sync/offline-capable";
77
- export type { OfflineCapableOptions } from "./sync/offline-capable";
76
+ export { commitMenuMutationLocal, runOfflineCapableMutation } from "./sync/offline-capable";
77
+ export type { LocalMenuMutationOptions, OfflineCapableOptions } from "./sync/offline-capable";
78
78
  export { orderRepository } from "./repositories/order.repository";
79
79
  export type { CreateOrderInput, AssignRiderInput, OrderActionInput } from "./repositories/order.repository";
80
80
  export { posRepository } from "./repositories/pos.repository";
package/dist/index.js CHANGED
@@ -75,7 +75,7 @@ export { localNodeTransport } from "./sync/transport-local-node";
75
75
  export * from "./sync/transport-local-node";
76
76
  export * from "./sync/order-snapshots.service";
77
77
  export { hydratePendingOrders, applyPendingOrderEvent, applyOrderStateChangeEvent } from "./sync/pending-orders.service";
78
- export { runOfflineCapableMutation } from "./sync/offline-capable";
78
+ export { commitMenuMutationLocal, runOfflineCapableMutation } from "./sync/offline-capable";
79
79
  // ── Repositories ──
80
80
  export { orderRepository } from "./repositories/order.repository";
81
81
  export { posRepository } from "./repositories/pos.repository";
@@ -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
+ }
@@ -10,6 +10,7 @@
10
10
  *
11
11
  * Keeps the online path and its result shape unchanged.
12
12
  */
13
+ import type { DomainEvent } from "./types";
13
14
  export interface OfflineCapableOptions<TService, TCommandPayload> {
14
15
  /** Direct REST call — used online. */
15
16
  run: () => Promise<TService>;
@@ -19,4 +20,24 @@ export interface OfflineCapableOptions<TService, TCommandPayload> {
19
20
  entityId?: string;
20
21
  payload: TCommandPayload;
21
22
  }
23
+ export interface LocalMenuMutationOptions<TCommandPayload> {
24
+ /** Must be a `menu:*` command. */
25
+ commandType: string;
26
+ businessId: string;
27
+ entityId?: string;
28
+ payload: TCommandPayload;
29
+ }
30
+ /**
31
+ * The ONLY menu write path.
32
+ *
33
+ * Local SQLite is the source of truth: capability/entitlement checks read the
34
+ * cached proof, the menu row is applied to the reference cache, and the
35
+ * command is inserted into the durable outbox. All of that completes before
36
+ * this promise resolves. No HTTP, connectivity probe, image upload, reference
37
+ * pull, LAN call, or replay is allowed here.
38
+ *
39
+ * Consumers must treat the returned event as the committed local state and
40
+ * close the form immediately. Sync is a separate background worker.
41
+ */
42
+ export declare function commitMenuMutationLocal<TCommandPayload>(options: LocalMenuMutationOptions<TCommandPayload>): Promise<DomainEvent>;
22
43
  export declare function runOfflineCapableMutation<TService, TCommandPayload>(options: OfflineCapableOptions<TService, TCommandPayload>): Promise<TService>;
@@ -46,49 +46,97 @@ async function assertCachedCapability(businessId, required) {
46
46
  throw e;
47
47
  }
48
48
  }
49
- export async function runOfflineCapableMutation(options) {
50
- // Online-required gate first: these commands can never succeed offline —
51
- // throw loud immediately instead of queuing what no transport can fulfill.
52
- assertOnlineFor(options.commandType, options.payload);
53
- // Per-command capability guard before touching local state or the network.
54
- const required = CAPABILITY_BY_COMMAND[options.commandType];
55
- // Local-first for engine-routed domains (menu:*): persist to SQLite and the
56
- // durable outbox immediately, update the local reference cache, and let
57
- // replay upload to the cloud/LAN in the background. A slow or absent
58
- // network must never hold the form open.
59
- if (options.commandType.startsWith("menu:")) {
60
- if (required)
61
- await assertCachedCapability(options.businessId, required);
62
- // Preserve the entitlement gate that the engine normally applies before
63
- // queueing: local-first must not weaken subscription enforcement.
64
- await assertEntitledForCommand(options.businessId, options.commandType);
65
- const command = buildCommand({
66
- commandType: options.commandType,
67
- businessId: options.businessId,
68
- entityId: options.entityId,
69
- payload: options.payload,
70
- });
71
- try {
72
- const { keys } = await applyMenuCommandLocal(options.businessId, command);
73
- if (keys.length)
74
- emitReferenceChanged(options.businessId, keys);
75
- }
76
- catch (error) {
77
- syncLogger.debug("OfflineCapable", "menu local apply failed", {
78
- error: String(error),
79
- });
49
+ /**
50
+ * Publish fan-out only after the local transaction has committed.
51
+ *
52
+ * This is deliberately scheduled off the mutation's promise: reference sync
53
+ * and replay are downstream of SQLite, never a prerequisite for it. If either
54
+ * fails, the local row and outbox stay intact for the next retry.
55
+ */
56
+ function scheduleMenuPostCommit(businessId, keys) {
57
+ setTimeout(() => {
58
+ if (keys.length > 0) {
59
+ try {
60
+ emitReferenceChanged(businessId, keys);
61
+ }
62
+ catch (error) {
63
+ syncLogger.debug("OfflineCapable", "menu reference fan-out failed", {
64
+ error: String(error),
65
+ });
66
+ }
80
67
  }
81
- const event = await commandQueueService.enqueue(command);
82
68
  if (isOnlineCapable()) {
83
- // Background only: save must return now.
84
69
  void syncEngine.replayQueuedCommands().catch((error) => {
85
70
  syncLogger.debug("OfflineCapable", "menu background replay failed", {
86
71
  error: String(error),
87
72
  });
88
73
  });
89
74
  }
75
+ }, 0);
76
+ }
77
+ /**
78
+ * The ONLY menu write path.
79
+ *
80
+ * Local SQLite is the source of truth: capability/entitlement checks read the
81
+ * cached proof, the menu row is applied to the reference cache, and the
82
+ * command is inserted into the durable outbox. All of that completes before
83
+ * this promise resolves. No HTTP, connectivity probe, image upload, reference
84
+ * pull, LAN call, or replay is allowed here.
85
+ *
86
+ * Consumers must treat the returned event as the committed local state and
87
+ * close the form immediately. Sync is a separate background worker.
88
+ */
89
+ export async function commitMenuMutationLocal(options) {
90
+ if (!options.commandType.startsWith("menu:")) {
91
+ throw new Error(`commitMenuMutationLocal only accepts menu:* commands, got ${options.commandType}`);
92
+ }
93
+ // Cached permission proof — local reads only. The backend remains the final
94
+ // authority when the queued command replays.
95
+ const required = CAPABILITY_BY_COMMAND[options.commandType];
96
+ if (required)
97
+ await assertCachedCapability(options.businessId, required);
98
+ await assertEntitledForCommand(options.businessId, options.commandType);
99
+ const command = buildCommand({
100
+ commandType: options.commandType,
101
+ businessId: options.businessId,
102
+ entityId: options.entityId,
103
+ payload: options.payload,
104
+ });
105
+ // 1) local read model, 2) durable outbox. Both are local writes.
106
+ let keys = [];
107
+ try {
108
+ const applied = await applyMenuCommandLocal(options.businessId, command);
109
+ keys = applied.keys;
110
+ }
111
+ catch (error) {
112
+ // The outbox row is still authoritative and will re-apply on replay.
113
+ syncLogger.debug("OfflineCapable", "menu local apply failed", {
114
+ error: String(error),
115
+ });
116
+ }
117
+ const event = await commandQueueService.enqueue(command);
118
+ // 3) only now may the rest of the system hear about it.
119
+ scheduleMenuPostCommit(options.businessId, keys);
120
+ return event;
121
+ }
122
+ export async function runOfflineCapableMutation(options) {
123
+ // Menu writes have a dedicated local-first API. Keep this branch for legacy
124
+ // callers (e.g. image upload follow-ups) but route it through the same
125
+ // commit-only implementation.
126
+ if (options.commandType.startsWith("menu:")) {
127
+ const event = await commitMenuMutationLocal({
128
+ commandType: options.commandType,
129
+ businessId: options.businessId,
130
+ entityId: options.entityId,
131
+ payload: options.payload,
132
+ });
90
133
  return event.payload;
91
134
  }
135
+ // Online-required gate first: these commands can never succeed offline —
136
+ // throw loud immediately instead of queuing what no transport can fulfill.
137
+ assertOnlineFor(options.commandType, options.payload);
138
+ // Per-command capability guard before touching local state or the network.
139
+ const required = CAPABILITY_BY_COMMAND[options.commandType];
92
140
  if (required)
93
141
  await assertCachedCapability(options.businessId, required);
94
142
  try {
@@ -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.26",
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",