@teincfood/core 0.10.25 → 0.10.27

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,20 @@ 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
+ export declare function useCreateMenuItemMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, Record<string, unknown>, unknown>;
21
+ export declare function useUpdateMenuItemMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, {
22
22
  id: string;
23
23
  payload: Record<string, unknown>;
24
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, {
25
+ export declare function useDeleteMenuItemMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, string, unknown>;
26
+ export declare function useToggleItemAvailabilityMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, {
27
27
  id: string;
28
28
  available: boolean;
29
+ cacheRecord?: unknown;
29
30
  }, 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, {
31
+ export declare function useCreateMenuCategoryMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, Record<string, unknown>, unknown>;
32
+ export declare function useUpdateMenuCategoryMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, {
32
33
  id: string;
33
34
  payload: Record<string, unknown>;
34
35
  }, unknown>;
35
- export declare function useDeleteMenuCategoryMutation(): import("@tanstack/react-query").UseMutationResult<unknown, Error, string, unknown>;
36
+ export declare function useDeleteMenuCategoryMutation(): import("@tanstack/react-query").UseMutationResult<import("..").DomainEvent<unknown>, Error, string, unknown>;
@@ -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"],
@@ -134,21 +133,24 @@ export function useMenuCategoriesQuery() {
134
133
  error: hasCache ? null : networkQuery.error,
135
134
  };
136
135
  }
137
- // Mutations — offline-capable
136
+ // Mutations — local-first.
137
+ //
138
+ // The only work on the mutation path is the local SQLite commit. Reference
139
+ // fan-out and cloud/LAN replay are scheduled by commitMenuMutationLocal AFTER
140
+ // the outbox row exists, so a slow or absent network can never delay a save.
141
+ // Invalidating the cached query key makes the committed row render immediately.
138
142
  export function useCreateMenuItemMutation() {
139
143
  const businessId = useBusinessId();
140
144
  const qc = useQueryClient();
141
145
  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 });
147
- },
146
+ mutationFn: (payload) => commitMenuMutationLocal({
147
+ commandType: "menu:create_item",
148
+ businessId: businessId,
149
+ entityId: payload.id,
150
+ payload,
151
+ }),
148
152
  onSuccess: () => {
149
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
150
- if (businessId)
151
- emitReferenceChanged(businessId, ["menu:items"]);
153
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
152
154
  },
153
155
  });
154
156
  }
@@ -156,15 +158,14 @@ export function useUpdateMenuItemMutation() {
156
158
  const businessId = useBusinessId();
157
159
  const qc = useQueryClient();
158
160
  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 });
163
- },
161
+ mutationFn: ({ id, payload }) => commitMenuMutationLocal({
162
+ commandType: "menu:update_item",
163
+ businessId: businessId,
164
+ entityId: id,
165
+ payload,
166
+ }),
164
167
  onSuccess: () => {
165
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
166
- if (businessId)
167
- emitReferenceChanged(businessId, ["menu:items"]);
168
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
168
169
  },
169
170
  });
170
171
  }
@@ -172,11 +173,14 @@ export function useDeleteMenuItemMutation() {
172
173
  const businessId = useBusinessId();
173
174
  const qc = useQueryClient();
174
175
  return useMutation({
175
- mutationFn: (id) => runOfflineCapableMutation({ run: () => menuService.deleteMenuItem(id), commandType: "menu:delete_item", businessId: businessId, entityId: id, payload: { id } }),
176
+ mutationFn: (id) => commitMenuMutationLocal({
177
+ commandType: "menu:delete_item",
178
+ businessId: businessId,
179
+ entityId: id,
180
+ payload: { id },
181
+ }),
176
182
  onSuccess: () => {
177
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
178
- if (businessId)
179
- emitReferenceChanged(businessId, ["menu:items"]);
183
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
180
184
  },
181
185
  });
182
186
  }
@@ -184,13 +188,14 @@ export function useToggleItemAvailabilityMutation() {
184
188
  const businessId = useBusinessId();
185
189
  const qc = useQueryClient();
186
190
  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 } }),
191
+ mutationFn: ({ id, available, cacheRecord }) => commitMenuMutationLocal({
192
+ commandType: "menu:update_item",
193
+ businessId: businessId,
194
+ entityId: id,
195
+ payload: cacheRecord ? { id, available, cacheRecord } : { id, available },
196
+ }),
190
197
  onSuccess: () => {
191
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
192
- if (businessId)
193
- emitReferenceChanged(businessId, ["menu:items"]);
198
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.items(businessId) });
194
199
  },
195
200
  });
196
201
  }
@@ -198,15 +203,14 @@ export function useCreateMenuCategoryMutation() {
198
203
  const businessId = useBusinessId();
199
204
  const qc = useQueryClient();
200
205
  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 });
205
- },
206
+ mutationFn: (payload) => commitMenuMutationLocal({
207
+ commandType: "menu:create_category",
208
+ businessId: businessId,
209
+ entityId: payload.id,
210
+ payload,
211
+ }),
206
212
  onSuccess: () => {
207
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
208
- if (businessId)
209
- emitReferenceChanged(businessId, ["menu:categories"]);
213
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
210
214
  },
211
215
  });
212
216
  }
@@ -214,15 +218,14 @@ export function useUpdateMenuCategoryMutation() {
214
218
  const businessId = useBusinessId();
215
219
  const qc = useQueryClient();
216
220
  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 } });
221
- },
221
+ mutationFn: ({ id, payload }) => commitMenuMutationLocal({
222
+ commandType: "menu:update_category",
223
+ businessId: businessId,
224
+ entityId: id,
225
+ payload: { ...payload, id },
226
+ }),
222
227
  onSuccess: () => {
223
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
224
- if (businessId)
225
- emitReferenceChanged(businessId, ["menu:categories"]);
228
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
226
229
  },
227
230
  });
228
231
  }
@@ -230,11 +233,14 @@ export function useDeleteMenuCategoryMutation() {
230
233
  const businessId = useBusinessId();
231
234
  const qc = useQueryClient();
232
235
  return useMutation({
233
- mutationFn: (id) => runOfflineCapableMutation({ run: () => menuService.deleteCategory(id), commandType: "menu:delete_category", businessId: businessId, entityId: id, payload: { id } }),
236
+ mutationFn: (id) => commitMenuMutationLocal({
237
+ commandType: "menu:delete_category",
238
+ businessId: businessId,
239
+ entityId: id,
240
+ payload: { id },
241
+ }),
234
242
  onSuccess: () => {
235
- qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
236
- if (businessId)
237
- emitReferenceChanged(businessId, ["menu:categories"]);
243
+ void qc.invalidateQueries({ queryKey: CATALOG_KEYS.categories(businessId) });
238
244
  },
239
245
  });
240
246
  }
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";
@@ -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>;
@@ -24,61 +24,121 @@ import { applyMenuCommandLocal } from "../reference/menu-apply";
24
24
  import { emitReferenceChanged } from "../reference/events";
25
25
  import { assertEntitledForCommand } from "../reference/entitlement-guard";
26
26
  import { isOnlineCapable } from "./online-gates";
27
- export async function runOfflineCapableMutation(options) {
28
- // Per-command capability guard before even attempting direct call
29
- const required = CAPABILITY_BY_COMMAND[options.commandType];
30
- if (required) {
31
- // Online-required gate first: these commands can never succeed offline —
32
- // throw loud immediately instead of queuing what no transport can fulfill.
33
- assertOnlineFor(options.commandType, options.payload);
34
- // Local-first for engine-routed domains (menu:*): persist to SQLite and the
35
- // durable outbox immediately, update the local reference cache, and let
36
- // replay upload to the cloud/LAN in the background. A slow or absent
37
- // network must never hold the form open.
38
- if (options.commandType.startsWith("menu:")) {
39
- // Preserve the entitlement gate that the engine normally applies before
40
- // queueing: local-first must not weaken subscription enforcement.
41
- await assertEntitledForCommand(options.businessId, options.commandType);
42
- const command = buildCommand({
43
- commandType: options.commandType,
44
- businessId: options.businessId,
45
- entityId: options.entityId,
46
- payload: options.payload,
47
- });
27
+ /**
28
+ * Enforce the cached RBAC capabilities downloaded from the backend.
29
+ *
30
+ * Fail-open only when no capabilities record exists yet (first boot); once a
31
+ * record is present, an unknown capability is a real denial and must never
32
+ * reach the local cache or the outbox.
33
+ */
34
+ async function assertCachedCapability(businessId, required) {
35
+ try {
36
+ const db = await openSyncDatabase();
37
+ const rec = await getReferenceData(db, businessId, "capabilities");
38
+ const caps = rec?.payload?.data
39
+ ?.capabilities ?? [];
40
+ if (rec && caps.length > 0 && !caps.includes(required)) {
41
+ throw new ApiError(`Missing capability: ${required}`, 403, "FORBIDDEN");
42
+ }
43
+ }
44
+ catch (e) {
45
+ if (e instanceof ApiError && e.status === 403)
46
+ throw e;
47
+ }
48
+ }
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) {
48
59
  try {
49
- const { keys } = await applyMenuCommandLocal(options.businessId, command);
50
- if (keys.length)
51
- emitReferenceChanged(options.businessId, keys);
60
+ emitReferenceChanged(businessId, keys);
52
61
  }
53
62
  catch (error) {
54
- syncLogger.debug("OfflineCapable", "menu local apply failed", {
63
+ syncLogger.debug("OfflineCapable", "menu reference fan-out failed", {
55
64
  error: String(error),
56
65
  });
57
66
  }
58
- const event = await commandQueueService.enqueue(command);
59
- if (isOnlineCapable()) {
60
- // Background only: save must return now.
61
- void syncEngine.replayQueuedCommands().catch((error) => {
62
- syncLogger.debug("OfflineCapable", "menu background replay failed", {
63
- error: String(error),
64
- });
65
- });
66
- }
67
- return event.payload;
68
67
  }
69
- try {
70
- const db = await openSyncDatabase();
71
- const rec = await getReferenceData(db, options.businessId, "capabilities");
72
- const caps = rec?.payload?.data?.capabilities ?? [];
73
- if (rec && caps.length > 0 && !caps.includes(required)) {
74
- throw new ApiError(`Missing capability: ${required}`, 403, "FORBIDDEN");
75
- }
76
- }
77
- catch (e) {
78
- if (e instanceof ApiError && e.status === 403)
79
- throw e;
68
+ if (isOnlineCapable()) {
69
+ void syncEngine.replayQueuedCommands().catch((error) => {
70
+ syncLogger.debug("OfflineCapable", "menu background replay failed", {
71
+ error: String(error),
72
+ });
73
+ });
80
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
+ });
133
+ return event.payload;
81
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];
140
+ if (required)
141
+ await assertCachedCapability(options.businessId, required);
82
142
  try {
83
143
  // Cloud already known unreachable (or no network): skip the direct REST
84
144
  // attempt entirely — it would only burn a timeout before falling back.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.10.25",
3
+ "version": "0.10.27",
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",