@teincfood/core 0.8.0 → 0.8.2
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/hooks/image.hooks.d.ts +5 -1
- package/dist/hooks/image.hooks.js +7 -3
- package/dist/image-cache/service.d.ts +20 -0
- package/dist/image-cache/service.js +36 -0
- package/dist/local-node/service.js +70 -9
- package/dist/realtime/events.service.js +41 -1
- package/dist/reference/menu-record.js +7 -2
- package/dist/sync/pending-orders.service.js +11 -3
- package/package.json +1 -1
|
@@ -16,5 +16,9 @@ export declare const CACHED_IMAGE_KEYS: {
|
|
|
16
16
|
};
|
|
17
17
|
/** Invalidate every cached-image query (e.g. after a bulk reference sync). */
|
|
18
18
|
export declare function invalidateCachedImages(queryClient: ReturnType<typeof useQueryClient>): void;
|
|
19
|
-
/** Resolve an image URL to a URI
|
|
19
|
+
/** Resolve an image URL to a URI safe for RN `<Image>`. Prefers local cache;
|
|
20
|
+
* null for null URL AND for URIs no native loader can handle (tfimg://
|
|
21
|
+
* leaks, bare S3 keys, blob:) — callers render placeholders instead of
|
|
22
|
+
* crashing the screen. resolveImageSource passthrough is preserved for
|
|
23
|
+
* non-render consumers; this gate applies at the render boundary only. */
|
|
20
24
|
export declare function useCachedImageUrl(url: string | null | undefined): string | null;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
9
|
import { useSyncExternalStore } from "react";
|
|
10
10
|
import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
|
|
11
|
-
import { cacheRemoteImage, getCachedImageInfo, resolveImageSource, stableCacheUrl } from "../image-cache/service";
|
|
11
|
+
import { cacheRemoteImage, getCachedImageInfo, resolveImageSource, stableCacheUrl, toRenderableUri } from "../image-cache/service";
|
|
12
12
|
export const IMAGE_CACHE_KEYS = {
|
|
13
13
|
all: ["cachedImage"],
|
|
14
14
|
url: (url) => ["cachedImage", url],
|
|
@@ -18,7 +18,11 @@ export const CACHED_IMAGE_KEYS = IMAGE_CACHE_KEYS;
|
|
|
18
18
|
export function invalidateCachedImages(queryClient) {
|
|
19
19
|
queryClient.invalidateQueries({ queryKey: IMAGE_CACHE_KEYS.all });
|
|
20
20
|
}
|
|
21
|
-
/** Resolve an image URL to a URI
|
|
21
|
+
/** Resolve an image URL to a URI safe for RN `<Image>`. Prefers local cache;
|
|
22
|
+
* null for null URL AND for URIs no native loader can handle (tfimg://
|
|
23
|
+
* leaks, bare S3 keys, blob:) — callers render placeholders instead of
|
|
24
|
+
* crashing the screen. resolveImageSource passthrough is preserved for
|
|
25
|
+
* non-render consumers; this gate applies at the render boundary only. */
|
|
22
26
|
export function useCachedImageUrl(url) {
|
|
23
27
|
const queryClient = useQueryClient();
|
|
24
28
|
const connectivity = useSyncExternalStore(subscribeConnectivity, getConnectivityState, getConnectivityState);
|
|
@@ -58,5 +62,5 @@ export function useCachedImageUrl(url) {
|
|
|
58
62
|
});
|
|
59
63
|
if (!enabled || !data)
|
|
60
64
|
return null;
|
|
61
|
-
return data.uri;
|
|
65
|
+
return toRenderableUri(data.uri);
|
|
62
66
|
}
|
|
@@ -22,6 +22,26 @@ export declare function getCachedImageInfo(url: string): Promise<ImageCacheEntry
|
|
|
22
22
|
* hook's warm-then-invalidate cycle into an unbounded retry storm.
|
|
23
23
|
*/
|
|
24
24
|
export declare function isDownloadableImageUrl(url: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* URI schemes React Native's `<Image>` can actually load on device. Anything
|
|
27
|
+
* else has no RCTImageURLLoader on iOS ("No suitable image URL loader
|
|
28
|
+
* found") and crashes the screen — notably `tfimg://` disk URIs leaked from
|
|
29
|
+
* desktop rows, bare S3 keys (`menus/biz/img.jpg`), and `blob:` preview
|
|
30
|
+
* URLs. The hook maps these to null so callers render their placeholder
|
|
31
|
+
* branch instead of feeding a fatal URI to the native loader.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isLoadableImageUri(uri: string): boolean;
|
|
34
|
+
/** Map a resolved URI to one RN `<Image>` can load, else null (placeholder). */
|
|
35
|
+
export declare function toRenderableUri(uri: string | null | undefined): string | null;
|
|
36
|
+
/**
|
|
37
|
+
* Values safe to persist as a menu image reference: S3 keys, http(s) URLs,
|
|
38
|
+
* null. Preview URIs (tfimg:, blob:, file:, data:) must never be stored —
|
|
39
|
+
* the backend keeps non-http values verbatim and they poison every other
|
|
40
|
+
* device (mobile has no native loader for tfimg:// and crashes). Returns
|
|
41
|
+
* null for un-storable values, so re-saving a poisoned row self-heals it
|
|
42
|
+
* (caller re-uploads to restore the image).
|
|
43
|
+
*/
|
|
44
|
+
export declare function toStorableImageUrl(value: string | null | undefined): string | null;
|
|
25
45
|
export declare function cacheRemoteImage(url: string | null | undefined, opts?: {
|
|
26
46
|
skipIfCached?: boolean;
|
|
27
47
|
removeOnFailure?: boolean;
|
|
@@ -60,6 +60,42 @@ const DOWNLOADABLE_RE = /^https?:\/\//i;
|
|
|
60
60
|
export function isDownloadableImageUrl(url) {
|
|
61
61
|
return DOWNLOADABLE_RE.test(url);
|
|
62
62
|
}
|
|
63
|
+
const LOADABLE_RE = /^(https?|file|data|asset|content):/i;
|
|
64
|
+
/**
|
|
65
|
+
* URI schemes React Native's `<Image>` can actually load on device. Anything
|
|
66
|
+
* else has no RCTImageURLLoader on iOS ("No suitable image URL loader
|
|
67
|
+
* found") and crashes the screen — notably `tfimg://` disk URIs leaked from
|
|
68
|
+
* desktop rows, bare S3 keys (`menus/biz/img.jpg`), and `blob:` preview
|
|
69
|
+
* URLs. The hook maps these to null so callers render their placeholder
|
|
70
|
+
* branch instead of feeding a fatal URI to the native loader.
|
|
71
|
+
*/
|
|
72
|
+
export function isLoadableImageUri(uri) {
|
|
73
|
+
return LOADABLE_RE.test(uri);
|
|
74
|
+
}
|
|
75
|
+
/** Map a resolved URI to one RN `<Image>` can load, else null (placeholder). */
|
|
76
|
+
export function toRenderableUri(uri) {
|
|
77
|
+
if (!uri)
|
|
78
|
+
return null;
|
|
79
|
+
return isLoadableImageUri(uri) ? uri : null;
|
|
80
|
+
}
|
|
81
|
+
const URI_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
82
|
+
/**
|
|
83
|
+
* Values safe to persist as a menu image reference: S3 keys, http(s) URLs,
|
|
84
|
+
* null. Preview URIs (tfimg:, blob:, file:, data:) must never be stored —
|
|
85
|
+
* the backend keeps non-http values verbatim and they poison every other
|
|
86
|
+
* device (mobile has no native loader for tfimg:// and crashes). Returns
|
|
87
|
+
* null for un-storable values, so re-saving a poisoned row self-heals it
|
|
88
|
+
* (caller re-uploads to restore the image).
|
|
89
|
+
*/
|
|
90
|
+
export function toStorableImageUrl(value) {
|
|
91
|
+
if (!value)
|
|
92
|
+
return null;
|
|
93
|
+
if (URI_SCHEME_RE.test(value) && !DOWNLOADABLE_RE.test(value)) {
|
|
94
|
+
syncLogger.warn("ImageCache", "Dropping un-storable image URL", { value: value.slice(0, 32) });
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
63
99
|
export async function cacheRemoteImage(url, opts = {}) {
|
|
64
100
|
if (!url)
|
|
65
101
|
return null;
|
|
@@ -221,18 +221,42 @@ class LocalNodeServiceCore {
|
|
|
221
221
|
return first ?? null;
|
|
222
222
|
}
|
|
223
223
|
broadcastToClients(event) {
|
|
224
|
-
// Mesh broadcast to all peers
|
|
224
|
+
// Mesh broadcast to all peers. Per-peer failures are LOUD (warn with
|
|
225
|
+
// peer id + error): a silent catch here once hid a full mesh blackout
|
|
226
|
+
// class of bug — peers that never receive order events never converge.
|
|
227
|
+
let delivered = 0;
|
|
228
|
+
let failed = 0;
|
|
225
229
|
for (const c of this.peers.values()) {
|
|
226
230
|
try {
|
|
227
231
|
c.publishEvent?.(event);
|
|
232
|
+
delivered++;
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
failed++;
|
|
236
|
+
syncLogger.warn("LocalNodeService", "Mesh publish failed", {
|
|
237
|
+
event_type: event.event_type,
|
|
238
|
+
entity_id: event.entity_id,
|
|
239
|
+
error: String(e),
|
|
240
|
+
});
|
|
228
241
|
}
|
|
229
|
-
catch { }
|
|
230
242
|
}
|
|
231
243
|
// Also via server broadcast to connected clients
|
|
232
244
|
try {
|
|
233
245
|
this.server?.broadcast(event);
|
|
234
246
|
}
|
|
235
|
-
catch {
|
|
247
|
+
catch (e) {
|
|
248
|
+
syncLogger.warn("LocalNodeService", "Mesh server broadcast failed", {
|
|
249
|
+
event_type: event.event_type,
|
|
250
|
+
entity_id: event.entity_id,
|
|
251
|
+
error: String(e),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
syncLogger.debug("LocalNodeService", "Mesh broadcast", {
|
|
255
|
+
event_type: event.event_type,
|
|
256
|
+
entity_id: event.entity_id,
|
|
257
|
+
peers: delivered,
|
|
258
|
+
failed,
|
|
259
|
+
});
|
|
236
260
|
}
|
|
237
261
|
setupStoreSubscriptions() {
|
|
238
262
|
if (this.subscribed)
|
|
@@ -467,32 +491,69 @@ class LocalNodeServiceCore {
|
|
|
467
491
|
secret: this.secret,
|
|
468
492
|
onEvent: (event) => {
|
|
469
493
|
syncLogger.event(event, "received", { origin: "lan" });
|
|
470
|
-
|
|
494
|
+
const eventType = event.event_type;
|
|
495
|
+
// Materialize LAN order events into the canonical orders table so
|
|
496
|
+
// list queries (POS In Progress, KDS) converge without the backend.
|
|
497
|
+
// Previously only order:sync inserted rows: a cloud/local-shape
|
|
498
|
+
// order.created from a peer fell through to the pending-store apply
|
|
499
|
+
// fns (which never touch the orders table), so the event arrived,
|
|
500
|
+
// every layer reported success, and the row never materialized.
|
|
501
|
+
if (eventType === "order:sync" || eventType === "order.created" || eventType === "order.status_changed") {
|
|
471
502
|
void (async () => {
|
|
472
503
|
try {
|
|
473
504
|
const p = event.payload;
|
|
505
|
+
const bid = deps.getBusiness()?.id;
|
|
506
|
+
const payloadBid = p.business_id;
|
|
507
|
+
if (typeof payloadBid === "string" && bid && payloadBid !== bid) {
|
|
508
|
+
syncLogger.warn("LocalNodeService", "Ignoring LAN order event for another business", {
|
|
509
|
+
event_type: eventType,
|
|
510
|
+
payloadBid,
|
|
511
|
+
bid,
|
|
512
|
+
});
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
474
515
|
if (p && typeof p.order_number === "string" && p.id) {
|
|
516
|
+
// Full-order payload (order:sync publish or order.created /
|
|
517
|
+
// status_changed echo): upsert the canonical row. HLC guard
|
|
518
|
+
// inside insertOrder keeps this idempotent across mesh + phx
|
|
519
|
+
// double delivery.
|
|
475
520
|
const db = await openSyncDatabase();
|
|
476
521
|
await insertOrder(db, p);
|
|
522
|
+
syncLogger.info("LocalNodeService", "LAN order row materialized", {
|
|
523
|
+
event_type: eventType,
|
|
524
|
+
order_number: p.order_number,
|
|
525
|
+
});
|
|
477
526
|
}
|
|
478
527
|
else {
|
|
528
|
+
// Action-shaped payload (order:sync from a LAN command):
|
|
529
|
+
// apply the transition to the local row.
|
|
479
530
|
const action = p.action;
|
|
480
|
-
const orderId = p.orderId;
|
|
481
|
-
const bid = deps.getBusiness()?.id;
|
|
531
|
+
const orderId = p.orderId ?? p.order_id;
|
|
482
532
|
if (action && orderId && bid) {
|
|
483
533
|
try {
|
|
484
534
|
await transitionLocalOrder(bid, String(orderId), action);
|
|
535
|
+
syncLogger.info("LocalNodeService", "LAN order transition applied", {
|
|
536
|
+
event_type: eventType,
|
|
537
|
+
action,
|
|
538
|
+
orderId: String(orderId),
|
|
539
|
+
});
|
|
485
540
|
}
|
|
486
541
|
catch { }
|
|
487
542
|
}
|
|
543
|
+
else {
|
|
544
|
+
syncLogger.warn("LocalNodeService", "LAN order event has no materializable payload", {
|
|
545
|
+
event_type: eventType,
|
|
546
|
+
entity_id: event.entity_id,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
488
549
|
}
|
|
489
550
|
}
|
|
490
551
|
catch (e) {
|
|
491
|
-
syncLogger.
|
|
552
|
+
syncLogger.warn("LocalNodeService", "LAN order apply failed", { err: String(e), event_type: eventType });
|
|
492
553
|
}
|
|
493
554
|
// The row is in SQLite now — invalidate so lists/detail refetch.
|
|
494
|
-
// (
|
|
495
|
-
// this for us.)
|
|
555
|
+
// (LAN-applied writes bypass the engine pipeline, so no
|
|
556
|
+
// subscriber does this for us.)
|
|
496
557
|
notifyPendingOrdersChanged();
|
|
497
558
|
})();
|
|
498
559
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { phoenixSocket } from "./phoenix-socket";
|
|
2
|
+
import { syncLogger } from "../utils/sync-logger";
|
|
2
3
|
class EventsService {
|
|
3
4
|
constructor() {
|
|
4
5
|
this.refCount = 0;
|
|
@@ -29,6 +30,22 @@ class EventsService {
|
|
|
29
30
|
this.lastSequence = sequence;
|
|
30
31
|
else if (typeof data?._sequence === "number")
|
|
31
32
|
this.lastSequence = data._sequence;
|
|
33
|
+
// Business-state frames are LOUD (info): when live updates stall,
|
|
34
|
+
// these lines prove whether the backend pushed anything at all.
|
|
35
|
+
if (event.startsWith("order.") ||
|
|
36
|
+
event.startsWith("delivery.") ||
|
|
37
|
+
event.startsWith("payment.") ||
|
|
38
|
+
event === "reference_data.changed") {
|
|
39
|
+
try {
|
|
40
|
+
syncLogger.info("EventsService", "Phoenix event received", {
|
|
41
|
+
topic,
|
|
42
|
+
event,
|
|
43
|
+
order_id: data?.order_id ?? data?.id,
|
|
44
|
+
business_id: payloadBid,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
}
|
|
32
49
|
const typeListeners = this.listeners.get(event);
|
|
33
50
|
if (typeListeners)
|
|
34
51
|
typeListeners.forEach((cb) => cb(data));
|
|
@@ -39,7 +56,30 @@ class EventsService {
|
|
|
39
56
|
joinPayload.last_sequence = this.lastSequence;
|
|
40
57
|
if (this.activeBusinessId)
|
|
41
58
|
joinPayload.business_id = this.activeBusinessId;
|
|
42
|
-
phoenixSocket
|
|
59
|
+
phoenixSocket
|
|
60
|
+
.connect()
|
|
61
|
+
.then(() => phoenixSocket.joinChannel(topic, joinPayload))
|
|
62
|
+
.then(() => {
|
|
63
|
+
this.joined = true;
|
|
64
|
+
try {
|
|
65
|
+
syncLogger.info("EventsService", "Phoenix channel joined", {
|
|
66
|
+
topic,
|
|
67
|
+
business_id: this.activeBusinessId,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch { }
|
|
71
|
+
})
|
|
72
|
+
.catch((e) => {
|
|
73
|
+
// A failed join previously vanished into a swallowed rejection:
|
|
74
|
+
// socket "connected" but subscribed to nothing. Loud from now on.
|
|
75
|
+
try {
|
|
76
|
+
syncLogger.warn("EventsService", "Phoenix channel join failed", {
|
|
77
|
+
topic,
|
|
78
|
+
error: String(e),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch { }
|
|
82
|
+
});
|
|
43
83
|
}
|
|
44
84
|
else if (businessId !== undefined) {
|
|
45
85
|
// business switched while already joined — update filter without rejoin (next events filtered). If backend requires business:{id} subscription, re-join could be forced — not needed as fallback business_authorized check still passes.
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { generateUUID } from "../utils/uuid";
|
|
11
11
|
import { CATALOG_KEYS } from "../catalog/hooks";
|
|
12
|
+
import { toStorableImageUrl } from "../image-cache/service";
|
|
12
13
|
export function buildMenuItemRecord(input) {
|
|
13
14
|
const currencyCode = input.currencyCode || "GHS";
|
|
14
15
|
const now = input.now ?? new Date().toISOString();
|
|
@@ -17,7 +18,9 @@ export function buildMenuItemRecord(input) {
|
|
|
17
18
|
name: input.name,
|
|
18
19
|
description: input.description ?? null,
|
|
19
20
|
basePrice: { amountMinor: input.priceMinor, currencyCode },
|
|
20
|
-
|
|
21
|
+
// Never persist preview URIs (tfimg:/blob:/file:): the backend stores
|
|
22
|
+
// non-http values verbatim and they poison every other device.
|
|
23
|
+
image_url: toStorableImageUrl(input.imageUrl),
|
|
21
24
|
is_available: input.available ?? true,
|
|
22
25
|
dietary_tags: input.dietaryTags ?? [],
|
|
23
26
|
category_id: input.categoryId,
|
|
@@ -152,7 +155,9 @@ export function buildMenuItemEnvelope(input) {
|
|
|
152
155
|
: { name: e.name, price_minor: e.price_minor ?? 0 };
|
|
153
156
|
}),
|
|
154
157
|
available: input.available ?? true,
|
|
155
|
-
|
|
158
|
+
// Same storable rule as the cache record: preview URIs must not reach
|
|
159
|
+
// the backend (it stores them verbatim).
|
|
160
|
+
image_url: toStorableImageUrl(input.imageUrl),
|
|
156
161
|
...(input.wireExtra ?? {}),
|
|
157
162
|
};
|
|
158
163
|
return { wire, cacheRecord };
|
|
@@ -296,10 +296,18 @@ export async function applyPendingOrderEvent(event, origin = "local") {
|
|
|
296
296
|
}
|
|
297
297
|
if (event.event_type === "order.created") {
|
|
298
298
|
const payload = event.payload;
|
|
299
|
+
// Local-shape echoes carry local_order_number; cloud-shape echoes
|
|
300
|
+
// (backend id/number, e.g. received over LAN from a peer) carry only
|
|
301
|
+
// order_number. Match on either so both shapes clear the optimistic
|
|
302
|
+
// entry and converge snapshots — previously cloud-shape events hit the
|
|
303
|
+
// early return and triggered no invalidation at all.
|
|
299
304
|
const localOrderNumber = payload?.local_order_number;
|
|
300
|
-
|
|
305
|
+
const matchNumber = typeof localOrderNumber === "string"
|
|
306
|
+
? localOrderNumber
|
|
307
|
+
: payload?.order_number;
|
|
308
|
+
if (typeof matchNumber !== "string")
|
|
301
309
|
return;
|
|
302
|
-
usePendingOrdersStore.getState().removeByOrderNumber(
|
|
310
|
+
usePendingOrdersStore.getState().removeByOrderNumber(matchNumber);
|
|
303
311
|
// Persist the cloud acknowledgment into the durable journal + read model.
|
|
304
312
|
await orderSnapshotsService.persistOrderEvent(event, "cloud");
|
|
305
313
|
const db = await openSyncDatabase();
|
|
@@ -313,7 +321,7 @@ export async function applyPendingOrderEvent(event, origin = "local") {
|
|
|
313
321
|
if (e.event_type !== PENDING_EVENT_TYPE)
|
|
314
322
|
continue;
|
|
315
323
|
if (e.payload?.order_number ===
|
|
316
|
-
|
|
324
|
+
matchNumber) {
|
|
317
325
|
ids.push(e.event_id);
|
|
318
326
|
}
|
|
319
327
|
}
|
package/package.json
CHANGED