@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.
- package/dist/catalog/hooks.d.ts +27 -10
- package/dist/catalog/hooks.js +193 -56
- package/dist/db/migrations.js +25 -0
- package/dist/db/operations.js +10 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- 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/sync/offline-capable.d.ts +21 -0
- package/dist/sync/offline-capable.js +81 -33
- package/dist/test-helpers/memory-driver.js +41 -0
- package/package.json +1 -1
package/dist/catalog/hooks.d.ts
CHANGED
|
@@ -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
|
-
|
|
21
|
-
export declare function
|
|
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
|
-
},
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
},
|
|
35
|
-
|
|
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
|
@@ -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 {
|
|
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
|
-
|
|
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 =
|
|
216
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
141
217
|
return useMutation({
|
|
142
|
-
mutationFn: (payload) => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 =
|
|
240
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
158
241
|
return useMutation({
|
|
159
|
-
mutationFn: ({ id, payload }) => {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 =
|
|
264
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
174
265
|
return useMutation({
|
|
175
|
-
mutationFn: (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 =
|
|
285
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
186
286
|
return useMutation({
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 =
|
|
311
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
200
312
|
return useMutation({
|
|
201
|
-
mutationFn: (payload) => {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 =
|
|
335
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
216
336
|
return useMutation({
|
|
217
|
-
mutationFn: ({ id, payload }) => {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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 =
|
|
359
|
+
const { qc, snapshot, restore } = useOptimisticMenuPatch();
|
|
232
360
|
return useMutation({
|
|
233
|
-
mutationFn: (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
|
}
|
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) {
|
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:
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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