@teamscala/service-boot 0.23.516 → 0.23.518
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/package.json +232 -1127
- package/src/agreement-gate/settings.ts +8 -8
- package/src/apply-logger-config.test.ts +46 -0
- package/src/apply-logger-config.ts +31 -0
- package/src/asset-proxy.test.ts +9 -5
- package/src/auth/sign-out.ts +122 -0
- package/src/build-client-assets/hydrate-bundle.ts +14 -4
- package/src/build-client-assets/manifest.ts +5 -18
- package/src/build-client-assets/styles-build.test.ts +11 -8
- package/src/build-client-assets/styles-build.ts +14 -63
- package/src/build-client-assets.test.ts +0 -161
- package/src/build-client-assets.ts +20 -67
- package/src/content-gate/token.ts +9 -2
- package/src/install-logging.ts +10 -26
- package/src/mount-orpc-router.ts +8 -2
- package/src/mutable-app-ref.ts +0 -7
- package/src/page-server-loader-graph.test.ts +31 -0
- package/src/page-server-loader-graph.ts +21 -0
- package/src/pg-boss-mount.ts +3 -16
- package/src/readiness-gate.ts +0 -4
- package/src/register-api-routes.ts +8 -17
- package/src/register-page-server-client.ts +6 -0
- package/src/register-registry-page-route/meta-title.test.ts +50 -0
- package/src/register-registry-page-route/meta-title.ts +1 -1
- package/src/register-registry-page-route/page-data-shared.ts +7 -11
- package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
- package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
- package/src/register-registry-page-route.ts +14 -16
- package/src/resolve-brand-favicon.test.ts +63 -0
- package/src/resolve-theme-mode.test.ts +51 -0
- package/src/resolvers/fetch-org-brand.test.ts +48 -0
- package/src/resolvers/website-by-host-map.test.ts +63 -0
- package/src/resolvers/website-by-host-map.ts +15 -3
- package/src/run-boot-hook-list.test.ts +47 -0
- package/src/run-boot-hooks.test.ts +56 -0
- package/src/scala-tools-currency-watchdog.test.ts +43 -0
- package/src/scala-tools-currency-watchdog.ts +0 -5
- package/src/transfers/presigned-upload.ts +165 -0
- package/src/two-phase-minimal-app.test.ts +24 -0
- package/src/two-phase-readiness.test.ts +102 -0
- package/src/types.test.ts +20 -0
- package/src/ui-service.test.ts +24 -0
- package/src/ws-boot-self-test.test.ts +43 -0
- package/src/build-client-assets/input-fingerprint.ts +0 -92
- package/src/build-client-assets/input-hashing.ts +0 -206
- package/src/handlers/bun-stable-watch.ts +0 -86
- package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
- package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
- package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
- package/src/handlers/messaging-send-recording-logic.ts +0 -68
- package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
- package/src/handlers/messaging-send-recording-watch.ts +0 -244
- package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
- package/src/handlers/messaging-send-sweep-selection.ts +0 -35
- package/src/handlers/platform-fault-escalation/config.ts +0 -95
- package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
- package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
- package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
- package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
- package/src/handlers/platform-fault-escalation/probe.ts +0 -19
- package/src/handlers/platform-fault-escalation-logic.ts +0 -382
- package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
- package/src/handlers/platform-fault-escalation.test.ts +0 -497
- package/src/handlers/platform-fault-escalation.ts +0 -246
- package/src/handlers/projects-digest.ts +0 -198
- package/src/handlers/quiz-research.ts +0 -178
- package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
- package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
- package/src/handlers/scala-tools-currency-watch.ts +0 -162
- package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
- package/src/handlers/singleton-tools-currency-watch.ts +0 -380
- package/src/handlers/sweep-event-log.ts +0 -98
- package/src/handlers/upstream-issue-sync.ts +0 -220
- package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
- package/src/handlers/whatsapp-liveness-logic.ts +0 -53
- package/src/handlers/whatsapp-liveness.test.ts +0 -78
- package/src/handlers/whatsapp-liveness.ts +0 -138
- package/src/interpolate-meta.ts +0 -123
- package/src/load-service-prisma-client.ts +0 -53
- package/src/loader-errors.ts +0 -41
- package/src/read-viewport-from-request.ts +0 -36
- package/src/register-page-server-client.test.ts +0 -185
- package/src/resolve-app-base-url.ts +0 -35
- package/src/resolve-from-loader-config.test.ts +0 -429
- package/src/resolve-from-loader-config.ts +0 -316
- package/src/resolve-service-context.ts +0 -88
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./two-phase-readiness declares 6 runtime export(s) and 8 declared behaviour vector(s);
|
|
5
|
+
* this suite asserts the import resolves, the runtime surface matches
|
|
6
|
+
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
|
+
* overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { mock } from "bun:test";
|
|
13
|
+
const __realServerClient = await import("./page-server-orpc/server-client.ts");
|
|
14
|
+
const __capabilityFault: { value: string | null } = { value: null };
|
|
15
|
+
mock.module("./page-server-orpc/server-client.ts", () => ({ ...__realServerClient, getServerClientCapabilityFault: () => __capabilityFault.value }));
|
|
16
|
+
const __realNotFound = await import("./resource-not-found.ts");
|
|
17
|
+
mock.module("./resource-not-found.ts", () => ({ ...__realNotFound, resolveErrorPageLogo: async () => "https://logo.local/light.png" }));
|
|
18
|
+
const __ctx = { json: (body: unknown, status?: number, headers?: Record<string, string>) => new Response(JSON.stringify(body), { status: status ?? 200, headers }) };
|
|
19
|
+
|
|
20
|
+
const mod = await import("./two-phase-readiness");
|
|
21
|
+
|
|
22
|
+
const { setPageHandler, markFullAppReady, __resetPhase2StateForTest, getPageHandlerState, healthHandler, createPhase1PageHandler } = mod;
|
|
23
|
+
|
|
24
|
+
describe("two-phase-readiness exports-map smoke", () => {
|
|
25
|
+
it("the runtime export set is exactly the declared surface", () => {
|
|
26
|
+
expect(Object.keys(mod).sort()).toEqual([
|
|
27
|
+
"__resetPhase2StateForTest",
|
|
28
|
+
"createPhase1PageHandler",
|
|
29
|
+
"getPageHandlerState",
|
|
30
|
+
"healthHandler",
|
|
31
|
+
"markFullAppReady",
|
|
32
|
+
"setPageHandler",
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
it("setPageHandler is a function", () => {
|
|
36
|
+
expect(typeof mod["setPageHandler"]).toBe("function");
|
|
37
|
+
});
|
|
38
|
+
it("markFullAppReady is a function", () => {
|
|
39
|
+
expect(typeof mod["markFullAppReady"]).toBe("function");
|
|
40
|
+
});
|
|
41
|
+
it("__resetPhase2StateForTest is a function", () => {
|
|
42
|
+
expect(typeof mod["__resetPhase2StateForTest"]).toBe("function");
|
|
43
|
+
});
|
|
44
|
+
it("getPageHandlerState is a function", () => {
|
|
45
|
+
expect(typeof mod["getPageHandlerState"]).toBe("function");
|
|
46
|
+
});
|
|
47
|
+
it("healthHandler is a function", () => {
|
|
48
|
+
expect(typeof mod["healthHandler"]).toBe("function");
|
|
49
|
+
});
|
|
50
|
+
it("createPhase1PageHandler is a function", () => {
|
|
51
|
+
expect(typeof mod["createPhase1PageHandler"]).toBe("function");
|
|
52
|
+
});
|
|
53
|
+
it("a fresh boot starts unready with no handler", async () => {
|
|
54
|
+
__resetPhase2StateForTest();
|
|
55
|
+
const got = getPageHandlerState();
|
|
56
|
+
expect(got).toEqual({ ready: false, hasHandler: false });
|
|
57
|
+
});
|
|
58
|
+
it("markFullAppReady alone flips ready WITHOUT a handler — the client-render contract", async () => {
|
|
59
|
+
__resetPhase2StateForTest();
|
|
60
|
+
markFullAppReady(); const got = getPageHandlerState();
|
|
61
|
+
expect(got.ready).toBe(true);
|
|
62
|
+
expect(got.hasHandler).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
it("setPageHandler implies ready AND a handler (the SSR contract)", async () => {
|
|
65
|
+
__resetPhase2StateForTest();
|
|
66
|
+
setPageHandler((() => async () => new Response("ok")) as never); const got = getPageHandlerState();
|
|
67
|
+
expect(got.ready).toBe(true);
|
|
68
|
+
expect(got.hasHandler).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
it("/health reports 503 starting before phase 2", async () => {
|
|
71
|
+
__resetPhase2StateForTest(); __capabilityFault.value = null;
|
|
72
|
+
const got = healthHandler(__ctx as never) as Response;
|
|
73
|
+
expect(got.status).toBe(503);
|
|
74
|
+
expect((await got.json() as { status: string }).status).toBe("starting");
|
|
75
|
+
});
|
|
76
|
+
it("/health asserts the capability, not just the phase flag", async () => {
|
|
77
|
+
__resetPhase2StateForTest(); markFullAppReady(); __capabilityFault.value = "registered client carries no CRUD models";
|
|
78
|
+
const got = healthHandler(__ctx as never) as Response;
|
|
79
|
+
expect(got.status).toBe(503);
|
|
80
|
+
expect((await got.json() as { status: string }).status).toBe("degraded");
|
|
81
|
+
});
|
|
82
|
+
it("/health answers ok once ready and the capability is intact", async () => {
|
|
83
|
+
__resetPhase2StateForTest(); markFullAppReady(); __capabilityFault.value = null;
|
|
84
|
+
const got = healthHandler(__ctx as never) as Response;
|
|
85
|
+
expect(got.status).toBe(200);
|
|
86
|
+
expect((await got.json() as { status: string }).status).toBe("ok");
|
|
87
|
+
});
|
|
88
|
+
it("the phase-1 page handler serves the branded waking 503 before phase 2", async () => {
|
|
89
|
+
__resetPhase2StateForTest();
|
|
90
|
+
const handler = createPhase1PageHandler("svc-tr")( {} as never );
|
|
91
|
+
const got = await handler(new Request("http://x.local/"), {} as never);
|
|
92
|
+
expect(got.status).toBe(503);
|
|
93
|
+
expect(got.headers.get("content-type")).toContain("text/html");
|
|
94
|
+
expect(got.headers.get("retry-after")).not.toBeNull();
|
|
95
|
+
});
|
|
96
|
+
it("the phase-1 handler delegates to the real handler once registered", async () => {
|
|
97
|
+
__resetPhase2StateForTest(); setPageHandler((() => async () => new Response("real-render")) as never);
|
|
98
|
+
const handler = createPhase1PageHandler("svc-tr")( {} as never );
|
|
99
|
+
const got = await handler(new Request("http://x.local/"), {} as never);
|
|
100
|
+
expect(await got.text()).toBe("real-render");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./types declares 0 runtime export(s) + 5 type(s);
|
|
5
|
+
* this suite asserts the import resolves, the runtime surface matches
|
|
6
|
+
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
|
+
* overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
|
|
13
|
+
const mod = await import("./types");
|
|
14
|
+
|
|
15
|
+
describe("types exports-map smoke", () => {
|
|
16
|
+
it("is type-only — the runtime module carries no values", () => {
|
|
17
|
+
// Declared types: RendererComponentEntry, PageHandlerExtras, CreatePageHandler, PageHandlerConfig, ServiceInstallConfig
|
|
18
|
+
expect(Object.keys(mod).length).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./ui-service declares 1 runtime export(s) + 3 type(s);
|
|
5
|
+
* this suite asserts the import resolves, the runtime surface matches
|
|
6
|
+
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
|
+
* overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
|
|
13
|
+
const mod = await import("./ui-service");
|
|
14
|
+
|
|
15
|
+
describe("ui-service exports-map smoke", () => {
|
|
16
|
+
it("the runtime export set is exactly the declared surface", () => {
|
|
17
|
+
expect(Object.keys(mod).sort()).toEqual([
|
|
18
|
+
"bootUiService",
|
|
19
|
+
]);
|
|
20
|
+
});
|
|
21
|
+
it("bootUiService is a function", () => {
|
|
22
|
+
expect(typeof mod["bootUiService"]).toBe("function");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./ws-boot-self-test declares 1 runtime export(s) and 3 declared behaviour vector(s);
|
|
5
|
+
* this suite asserts the import resolves, the runtime surface matches
|
|
6
|
+
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
|
+
* overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
|
|
13
|
+
const mod = await import("./ws-boot-self-test");
|
|
14
|
+
|
|
15
|
+
const { runWsBootSelfTest } = mod;
|
|
16
|
+
|
|
17
|
+
describe("ws-boot-self-test exports-map smoke", () => {
|
|
18
|
+
it("the runtime export set is exactly the declared surface", () => {
|
|
19
|
+
expect(Object.keys(mod).sort()).toEqual([
|
|
20
|
+
"runWsBootSelfTest",
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
it("runWsBootSelfTest is a function", () => {
|
|
24
|
+
expect(typeof mod["runWsBootSelfTest"]).toBe("function");
|
|
25
|
+
});
|
|
26
|
+
it("no declared websocket routes resolves without connecting", async () => {
|
|
27
|
+
const got = await runWsBootSelfTest({ websocket_routes: [] } as never, "svc-ws", 59999);
|
|
28
|
+
expect(got).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
it("a route whose server upgrades resolves 101", async () => {
|
|
31
|
+
const server = Bun.serve({ port: 0, fetch(req, srv) { if (!srv.upgrade(req)) return new Response("no upgrade", { status: 401 }); }, websocket: { open() {}, message() {} } });
|
|
32
|
+
const port = server.port as number;
|
|
33
|
+
const got = await runWsBootSelfTest({ websocket_routes: [{ path: "/ws-boot-self-test" }] } as never, "svc-ws", port);
|
|
34
|
+
expect(got).toBeUndefined();
|
|
35
|
+
server.stop(true);
|
|
36
|
+
});
|
|
37
|
+
it("a route nobody listens on fails boot loudly, naming the path", async () => {
|
|
38
|
+
let thrown: unknown = null;
|
|
39
|
+
try { await runWsBootSelfTest({ websocket_routes: [{ path: "/ws-boot-self-test-dead" }] } as never, "svc-ws", 59987); } catch (err) { thrown = err; }
|
|
40
|
+
expect(thrown).not.toBeNull();
|
|
41
|
+
expect(String(thrown)).toContain("/ws-boot-self-test-dead");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system server-handlers
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* The content fingerprint of the client-bundle dependency closure — the
|
|
7
|
-
* cache key that decides whether the boot-time client-asset build can be
|
|
8
|
-
* skipped. Composes the input-hashing module's closure walkers.
|
|
9
|
-
*
|
|
10
|
-
* Split out of build-client-assets.ts (962 lines) alongside its build
|
|
11
|
-
* siblings. Byte-for-byte move from the parent except createHash →
|
|
12
|
-
* Bun.CryptoHasher (bun-native-crypto guard; identical digest output).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
// Content fingerprint of the client-bundle dependency CLOSURE — hashed by CONTENT, never mtime (cache-key-hashes-source-inputs-only). Rebuild iff this
|
|
16
|
-
// differs from the fingerprint stored in the existing manifest. Scoped to the CLOSURE (not all of node_modules) so a @teamscala/* publish that is not in this
|
|
17
|
-
// service's client bundle cannot trigger a rebuild — the publish→rebuild storm is architecturally impossible (`no-uncontrolled-repetition-or-cascade`).
|
|
18
|
-
//
|
|
19
|
-
// Inputs (everything that can change the emitted bytes): src/entry-client.ts — the bundle entrypoint. src/page-manifest.ts — the generated block→component map the bundle is built
|
|
20
|
-
// from; a codegen regen (block added/removed) changes it, and the hydrate bundle MUST rebuild or new blocks render
|
|
21
|
-
// empty → hydration mismatch. src/app.css — the Tailwind entry. @teamscala/* JS closure (hashTeamgbjJsGraph) — the CONTENT of every @teamscala/*
|
|
22
|
-
// module reachable from the client entrypoints. Content (not version) so a mid-install stale bake (bun.lock
|
|
23
|
-
// ahead of node_modules — the lock can name a new version while the prior bytes are still on disk) is detected on
|
|
24
|
-
// the next boot and self-heals (scala-agents 2026-07-12). @teamscala/* CSS closure (hashResolvedPackageStylesheets) — the CONTENT of
|
|
25
|
-
// every package stylesheet app.css @imports (transitively). third-party closure-boundary versions (hashTeamgbgJsGraph) — the resolved
|
|
26
|
-
// VERSION of each third-party package the client closure imports (react, etc.). Third-party modules are not
|
|
27
|
-
// content-walked (large; pinned by bun.lock + change only on a version bump), but a version bump of a closure dep
|
|
28
|
-
// MUST invalidate, so the installed package.json#version is folded. This is the closure-scoped replacement for
|
|
29
|
-
// hashing all of bun.lock: react bumping in the closure still rebuilds, while a backend @teamscala/* publish
|
|
30
|
-
// outside the closure does not. NB: bun.lock is INTENTIONALLY NOT an input. It encodes every resolved
|
|
31
|
-
// @teamscala/* version (backend packages included), so it invalidates on every publish — the false-invalidation source that made every UI service rebuild
|
|
32
|
-
// on a backend publish. The closure inputs above are a strict improvement: they catch every change that affects THIS bundle AND nothing that doesn't.
|
|
33
|
-
|
|
34
|
-
// Keyed on each input's RELATIVE label + CONTENT only — never the absolute
|
|
35
|
-
// path (that would make the fingerprint cwd-dependent and defeat the skip
|
|
36
|
-
// across two checkouts of the same service). bun.lock is DELIBERATELY
|
|
37
|
-
// EXCLUDED — it encodes EVERY resolved @teamscala/* version, so folding it in
|
|
38
|
-
// invalidates on every publish including packages NOT in this service's
|
|
39
|
-
// client bundle (the publish→rebuild storm — `no-uncontrolled-repetition-
|
|
40
|
-
// or-cascade`). The closure content hashes below (hashTeamgbgJsGraph +
|
|
41
|
-
// hashResolvedPackageStylesheets) + the third-party closure-boundary
|
|
42
|
-
// versions cover every dependency change that ACTUALLY affects this bundle,
|
|
43
|
-
// so a non-closure publish changes nothing here → zero rebuild. Rebuild
|
|
44
|
-
// iff this fingerprint differs from the one stored in the manifest.
|
|
45
|
-
|
|
46
|
-
// Fold in the resolved CONTENT of every @teamscala/* JS/TS module reachable
|
|
47
|
-
// from the client entrypoints (hashTeamgbgJsGraph). bun.lock pins versions;
|
|
48
|
-
// the build reads BYTES — and a boot can build MID-INSTALL (the package-
|
|
49
|
-
// poller restarts the service after writing the new bun.lock but before
|
|
50
|
-
// node_modules extraction finishes), so Bun.build faithfully bakes the OLD
|
|
51
|
-
// @teamgbg JS while the lock-derived fingerprint is stored as "current".
|
|
52
|
-
// Every later restart then recomputes the same lock fingerprint → skip →
|
|
53
|
-
// STALE bundle served forever (scala-agents 2026-07-12: hydrate-4dad7478bd
|
|
54
|
-
// carried zero of portal-ui@2.4.41's markers across restarts). Bun.build
|
|
55
|
-
// itself is NOT caching — an isolated repro proves it reads fresh content
|
|
56
|
-
// across processes — so the fix is to make the fingerprint cover the actual
|
|
57
|
-
// @teamgbg JS bytes (the JS analogue of hashResolvedPackageStylesheets,
|
|
58
|
-
// which already closes this exact race for CSS). A stale bake then hashes
|
|
59
|
-
// differently from the freshly-installed bytes → rebuild → self-heals.
|
|
60
|
-
|
|
61
|
-
import { join } from "node:path";
|
|
62
|
-
import {
|
|
63
|
-
hashResolvedPackageStylesheets,
|
|
64
|
-
hashTeamgbgJsGraph,
|
|
65
|
-
} from "./input-hashing.ts";
|
|
66
|
-
|
|
67
|
-
export async function computeInputFingerprint(cwd: string): Promise<string> {
|
|
68
|
-
const sources: Array<[label: string, path: string]> = [
|
|
69
|
-
["src/entry-client.ts", join(cwd, "src", "entry-client.ts")],
|
|
70
|
-
["src/page-manifest.ts", join(cwd, "src", "page-manifest.ts")],
|
|
71
|
-
["src/app.css", join(cwd, "src", "app.css")],
|
|
72
|
-
];
|
|
73
|
-
const h = new Bun.CryptoHasher("sha256");
|
|
74
|
-
for (const [label, path] of sources) {
|
|
75
|
-
// Missing input contributes a stable sentinel rather than throwing — a
|
|
76
|
-
// backend service has no app.css, and that absence is itself part of the
|
|
77
|
-
// fingerprint. `(await Bun.file().text())` is parenthesised so a missing
|
|
78
|
-
// file's sync TypeError is caught (bun-async-file-operations).
|
|
79
|
-
try {
|
|
80
|
-
h.update(label).update("\0").update(await Bun.file(path).text()).update("\0");
|
|
81
|
-
} catch {
|
|
82
|
-
h.update(label).update("\0<absent>\0");
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
// Fold the resolved CONTENT of every @teamscala/* stylesheet app.css @imports
|
|
86
|
-
// (client-asset-cache-key-embeds-package-content). bun.lock pins versions; the
|
|
87
|
-
// build reads bytes. When they diverge (boot built mid-install), the lock-only
|
|
88
|
-
// fingerprint false-matches and skips forever — hashing the bytes closes it.
|
|
89
|
-
await hashResolvedPackageStylesheets(cwd, h);
|
|
90
|
-
await hashTeamgbgJsGraph(cwd, h);
|
|
91
|
-
return h.digest("hex").slice(0, 16);
|
|
92
|
-
}
|
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Input hashing for the client-asset build fingerprint.
|
|
7
|
-
*
|
|
8
|
-
* Split out of build-client-assets.ts, which had grown to 1221 lines holding
|
|
9
|
-
* six distinct concerns (lock + status, manifest, hydrate bundle, source maps,
|
|
10
|
-
* stylesheet/tailwind, and this). `single-purpose-file` did not catch it: that
|
|
11
|
-
* rule flags a file whose top-level exports share no common name root, which
|
|
12
|
-
* measures export-NAME cohesion rather than how many concerns a file holds — so
|
|
13
|
-
* a large file with few tidily-named exports stays invisible to it.
|
|
14
|
-
*
|
|
15
|
-
* This module owns ONE concern: turning the build's real inputs into a stable
|
|
16
|
-
* hash. `computeInputFingerprint` (still in the parent, and still the only
|
|
17
|
-
* exported entry point) composes the two functions exported here; everything
|
|
18
|
-
* else is module-private because nothing outside needs it.
|
|
19
|
-
*
|
|
20
|
-
* Byte-for-byte move — imports adjusted, nothing else. No renames, no type
|
|
21
|
-
* changes (`migrations-copy-not-rewrite`).
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
25
|
-
import { firstPartyScopeAlternation } from "@teamscala/os/workspace-topology/first-party-scopes";
|
|
26
|
-
|
|
27
|
-
// Resolve + hash the content of every `@import` in app.css's @teamscala/*
|
|
28
|
-
// stylesheet chain — INCLUDING transitive @imports (generic-ui/styles.css
|
|
29
|
-
// @imports blk-utilities/styles.css; hashing only the direct import left
|
|
30
|
-
// blk-utilities changes undetected → stale bundle). Cycle-safe via `seen`.
|
|
31
|
-
export async function hashResolvedPackageStylesheets(
|
|
32
|
-
cwd: string,
|
|
33
|
-
h: Bun.CryptoHasher,
|
|
34
|
-
): Promise<void> {
|
|
35
|
-
let appCss: string;
|
|
36
|
-
try {
|
|
37
|
-
appCss = await Bun.file(join(cwd, "src", "app.css")).text();
|
|
38
|
-
} catch {
|
|
39
|
-
return; // no app.css → nothing to fold (the main loop already recorded <absent>)
|
|
40
|
-
}
|
|
41
|
-
const seen = new Set<string>();
|
|
42
|
-
const hashSpec = async (spec: string, base: string): Promise<void> => {
|
|
43
|
-
if (seen.has(spec)) return;
|
|
44
|
-
seen.add(spec);
|
|
45
|
-
try {
|
|
46
|
-
const resolved = await Bun.resolve(spec, base);
|
|
47
|
-
const content = await Bun.file(resolved).text();
|
|
48
|
-
h.update("pkgcss:").update(spec).update("\0").update(content).update("\0");
|
|
49
|
-
// Follow transitive @imports within this stylesheet (relative + @teamscala/*).
|
|
50
|
-
for (const child of [
|
|
51
|
-
...content.matchAll(/@import\s+["']([^"']+)["']/g),
|
|
52
|
-
].map((m) => m[1].trim())) {
|
|
53
|
-
if (child.startsWith("tailwindcss")) continue;
|
|
54
|
-
await hashSpec(child, dirname(resolved));
|
|
55
|
-
}
|
|
56
|
-
} catch {
|
|
57
|
-
h.update("pkgcss:").update(spec).update("\0<unresolved>\0");
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
for (const spec of [
|
|
61
|
-
...appCss.matchAll(new RegExp(`@import\\s+["']((?:${firstPartyScopeAlternation()})\\/[^"']+)['"]`, "g")),
|
|
62
|
-
].map((m) => m[1].trim())) {
|
|
63
|
-
await hashSpec(spec, join(cwd, "src"));
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// The JS twin of hashResolvedPackageStylesheets: resolve + hash the CONTENT of every @teamscala/* JS/TS module reachable from the client entrypoints, so a
|
|
68
|
-
// mid-install stale bake (bun.lock ahead of node_modules — see computeInputFingerprint) is detected on the next boot and self-heals. Third-party modules are NOT walked
|
|
69
|
-
// (their versions are pinned by bun.lock and they don't change on a @teamgbg
|
|
70
|
-
// publish); only the @teamgbg surface — the publish-driven change graph — is folded.
|
|
71
|
-
//
|
|
72
|
-
// Determinism: per-file content hashes are collected into a map keyed on the
|
|
73
|
-
// package-relative path, then folded into the aggregate hasher in SORTED path
|
|
74
|
-
// order. The walk itself runs bounded-concurrent (no-serial-per-unit-work-on-hot-
|
|
75
|
-
// paths — the @teamgbg graph is ~1k+ files for a large UI; a serial await-per-file
|
|
76
|
-
// walk starves the boot path), but scheduling order never affects the result.
|
|
77
|
-
const JS_IMPORT_SPEC_RE = /\b(?:from|import|export|require)\s*\(?["']([^"']+)["']/g;
|
|
78
|
-
const JS_GRAPH_CONCURRENCY = 32;
|
|
79
|
-
|
|
80
|
-
// Parse the package name (scoped `@scope/pkg` or unscoped `pkg`) from a
|
|
81
|
-
// resolved node_modules path. lastIndexOf picks the INNERMOST package when
|
|
82
|
-
// node_modules is nested (a -> node_modules/b), which is the package actually
|
|
83
|
-
// imported. Returns null for a path not under node_modules.
|
|
84
|
-
function nodeModulesPkgName(resolvedPath: string): string | null {
|
|
85
|
-
const idx = resolvedPath.lastIndexOf("/node_modules/");
|
|
86
|
-
if (idx < 0) return null;
|
|
87
|
-
const rest = resolvedPath.slice(idx + "/node_modules/".length);
|
|
88
|
-
const slash = rest.indexOf("/");
|
|
89
|
-
if (rest.startsWith("@")) {
|
|
90
|
-
if (slash < 0) return rest; // malformed scoped name with no slash
|
|
91
|
-
const secondSlash = rest.indexOf("/", slash + 1);
|
|
92
|
-
return secondSlash < 0 ? rest : rest.slice(0, secondSlash);
|
|
93
|
-
}
|
|
94
|
-
return slash < 0 ? rest : rest.slice(0, slash);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Best-effort read of an installed package's declared version. Used at the
|
|
98
|
-
// third-party closure boundary so a version bump of a closure dep (react, etc.)
|
|
99
|
-
// invalidates the fingerprint without content-walking the (large, pinned)
|
|
100
|
-
// package. Returns null on any failure — an ABSENT signal, not a masking
|
|
101
|
-
// fallback (protective-layers-fail-explicitly): the caller omits the package,
|
|
102
|
-
// so a later successful read folds in a real version and invalidates. Never
|
|
103
|
-
// returns a non-null placeholder that would hide the failure.
|
|
104
|
-
async function readInstalledPkgVersion(
|
|
105
|
-
resolvedPath: string,
|
|
106
|
-
pkgName: string,
|
|
107
|
-
): Promise<string | null> {
|
|
108
|
-
const marker = "/node_modules/";
|
|
109
|
-
const idx = resolvedPath.lastIndexOf(marker);
|
|
110
|
-
const pkgRoot = join(resolvedPath.slice(0, idx + marker.length), pkgName);
|
|
111
|
-
try {
|
|
112
|
-
const pkg = await Bun.file(join(pkgRoot, "package.json")).json();
|
|
113
|
-
return typeof pkg.version === "string" ? pkg.version : null;
|
|
114
|
-
} catch {
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export async function hashTeamgbgJsGraph(
|
|
120
|
-
cwd: string,
|
|
121
|
-
h: Bun.CryptoHasher,
|
|
122
|
-
): Promise<void> {
|
|
123
|
-
const roots = [];
|
|
124
|
-
for (const p of [
|
|
125
|
-
join(cwd, "src", "entry-client.ts"),
|
|
126
|
-
join(cwd, "src", "page-manifest.ts"),
|
|
127
|
-
]) {
|
|
128
|
-
if (await Bun.file(p).exists()) roots.push(p);
|
|
129
|
-
}
|
|
130
|
-
if (roots.length === 0) return; // backend service — no client bundle to guard
|
|
131
|
-
|
|
132
|
-
const seen = new Set<string>();
|
|
133
|
-
for (const r of roots) seen.add(r);
|
|
134
|
-
const fileHashes = new Map<string, string>(); // pkg-relative path → content hash
|
|
135
|
-
const thirdPartyVersions = new Map<string, string>(); // pkg name → installed version
|
|
136
|
-
|
|
137
|
-
let frontier: string[] = [...roots];
|
|
138
|
-
while (frontier.length > 0) {
|
|
139
|
-
const batch = frontier.splice(0, JS_GRAPH_CONCURRENCY);
|
|
140
|
-
const expanded = await Promise.all(
|
|
141
|
-
batch.map(async (file) => {
|
|
142
|
-
let text: string;
|
|
143
|
-
try {
|
|
144
|
-
text = await Bun.file(file).text();
|
|
145
|
-
} catch {
|
|
146
|
-
return [] as string[];
|
|
147
|
-
}
|
|
148
|
-
const nmIdx = file.indexOf("/node_modules/@teamscala/");
|
|
149
|
-
if (nmIdx >= 0) {
|
|
150
|
-
const rel = file.slice(nmIdx + "/node_modules/".length); // @teamscala/pkg/...
|
|
151
|
-
fileHashes.set(rel, new Bun.CryptoHasher("sha256").update(text).digest("hex"));
|
|
152
|
-
}
|
|
153
|
-
const next: string[] = [];
|
|
154
|
-
for (const m of text.matchAll(JS_IMPORT_SPEC_RE)) {
|
|
155
|
-
let resolved: string;
|
|
156
|
-
try {
|
|
157
|
-
resolved = await Bun.resolve(m[1].trim(), dirname(file));
|
|
158
|
-
} catch {
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
const inNodeModules = resolved.includes("/node_modules/");
|
|
162
|
-
const isTeamgbg = resolved.includes("/node_modules/@teamscala/");
|
|
163
|
-
if (inNodeModules && !isTeamgbg) {
|
|
164
|
-
// Third-party closure boundary: stop the content walk (large,
|
|
165
|
-
// version-pinned packages) but record this dep's resolved VERSION
|
|
166
|
-
// so a closure version bump (react, …) still invalidates the
|
|
167
|
-
// fingerprint. The closure-scoped replacement for hashing all of
|
|
168
|
-
// bun.lock (`no-uncontrolled-repetition-or-cascade`). has()+set()
|
|
169
|
-
// straddle an await, so two concurrent imports of the same package
|
|
170
|
-
// can both read its version — idempotent (same value), so the
|
|
171
|
-
// folded result is deterministic regardless of scheduling.
|
|
172
|
-
const pkgName = nodeModulesPkgName(resolved);
|
|
173
|
-
if (pkgName && !thirdPartyVersions.has(pkgName)) {
|
|
174
|
-
const version = await readInstalledPkgVersion(resolved, pkgName);
|
|
175
|
-
// null = absent (unreadable package.json) — OMIT so a later
|
|
176
|
-
// successful read invalidates; never mask behind a placeholder
|
|
177
|
-
// (protective-layers-fail-explicitly). has() stays false → the
|
|
178
|
-
// read is re-attempted on the next import (cheap, bounded by
|
|
179
|
-
// closure size); a successful read sets + dedups.
|
|
180
|
-
if (version !== null) thirdPartyVersions.set(pkgName, version);
|
|
181
|
-
}
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
// Atomic dedup: check+add are synchronous (no await between), so on
|
|
185
|
-
// the single-threaded event loop only one task ever enqueues a path.
|
|
186
|
-
if (seen.has(resolved)) continue;
|
|
187
|
-
seen.add(resolved);
|
|
188
|
-
next.push(resolved);
|
|
189
|
-
}
|
|
190
|
-
return next;
|
|
191
|
-
}),
|
|
192
|
-
);
|
|
193
|
-
for (const arr of expanded) frontier.push(...arr);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
for (const rel of [...fileHashes.keys()].sort()) {
|
|
197
|
-
h.update("tgjs:").update(rel).update("\0").update(fileHashes.get(rel) as string).update("\0");
|
|
198
|
-
}
|
|
199
|
-
// Fold the resolved versions of every third-party package at the closure
|
|
200
|
-
// boundary, sorted by name for determinism. This is the ONLY signal for
|
|
201
|
-
// third-party changes (their bodies are not content-walked); without it a
|
|
202
|
-
// react version bump in the closure would bake a stale bundle.
|
|
203
|
-
for (const name of [...thirdPartyVersions.keys()].sort()) {
|
|
204
|
-
h.update("tpv:").update(name).update("\0").update(thirdPartyVersions.get(name) as string).update("\0");
|
|
205
|
-
}
|
|
206
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system server-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* The `bun-stable-watch` pg-boss handler — the durable trigger for the
|
|
7
|
-
* bun-1.4 lock-resolution escape hatch's atomic flip-back (open-migration
|
|
8
|
-
* `bun-14-lock-resolution-pin`). Polls the public npm `bun` `latest`
|
|
9
|
-
* dist-tag once daily; when a STABLE 1.4.x (≥1.4.0, no prerelease) ships,
|
|
10
|
-
* records a detection event so the escape hatch can be retired via the atomic
|
|
11
|
-
* flip-back: `config/scala-tools-deps.lockResolutionBun` → "bun" + the ~18
|
|
12
|
-
* `railpack_config` rows → 1.4.0 stable + delete the pinned
|
|
13
|
-
* `/usr/local/bin/bun-1.3.14` binary + a green deploy (all one change). The
|
|
14
|
-
* flip-back itself is a deliberate action, not auto-fired — it mutates config
|
|
15
|
-
* + railpack + a binary + a deploy.
|
|
16
|
-
*
|
|
17
|
-
* `bun/latest` returns the stable release (canaries live under the `canary`
|
|
18
|
-
* dist-tag, not `latest`), so a non-prerelease version ≥1.4.0 means stable
|
|
19
|
-
* shipped. A daily `bun.watch.tick` liveness event lets a watchdog detect a
|
|
20
|
-
* silently-stopped watch (the same shape as `sweep.completed`).
|
|
21
|
-
*
|
|
22
|
-
* Registered under slug `@teamscala/service-boot:bun-stable-watch`.
|
|
23
|
-
*/
|
|
24
|
-
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
25
|
-
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
26
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
27
|
-
import { getDistTagVersion } from "@teamscala/publish-registry";
|
|
28
|
-
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
29
|
-
|
|
30
|
-
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
31
|
-
const TARGET_STABLE = "1.4.0"; // flip back when a stable at or above this ships
|
|
32
|
-
|
|
33
|
-
interface BunLatestManifest {
|
|
34
|
-
version: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function parseSemver(
|
|
38
|
-
v: string,
|
|
39
|
-
): { major: number; minor: number; patch: number; prerelease: string } | null {
|
|
40
|
-
const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v.trim());
|
|
41
|
-
if (!m) return null;
|
|
42
|
-
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), prerelease: m[4] ?? "" };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** A stable (non-prerelease) release at or above the target version. */
|
|
46
|
-
function isStableAtOrAbove(version: string, target: string): boolean {
|
|
47
|
-
const v = parseSemver(version);
|
|
48
|
-
const t = parseSemver(target);
|
|
49
|
-
if (!v || !t) return false;
|
|
50
|
-
if (v.prerelease) return false; // canary/beta/rc are not stable
|
|
51
|
-
if (v.major !== t.major) return v.major > t.major;
|
|
52
|
-
if (v.minor !== t.minor) return v.minor > t.minor;
|
|
53
|
-
return v.patch >= t.patch;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function bunStableWatch(_ctx: { config: ScheduledJobConfig }): Promise<void> {
|
|
57
|
-
const log = getAppLogger();
|
|
58
|
-
const version = await getDistTagVersion("bun", "latest", NPM_REGISTRY);
|
|
59
|
-
if (!version) {
|
|
60
|
-
log.warn("[bun-stable-watch] failed to fetch npm bun/latest");
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
if (isStableAtOrAbove(version, TARGET_STABLE)) {
|
|
64
|
-
log.warn(
|
|
65
|
-
`[bun-stable-watch] Bun stable ${version} (>=${TARGET_STABLE}) shipped — escape hatch 'bun-14-lock-resolution-pin' is ready to retire. Run the atomic flip-back: config/scala-tools-deps.lockResolutionBun -> "bun" + railpack_config rows -> 1.4.0 stable + delete /usr/local/bin/bun-1.3.14 + a green deploy.`,
|
|
66
|
-
);
|
|
67
|
-
recordEvent({
|
|
68
|
-
kind: "bun.stable.shipped",
|
|
69
|
-
payload: {
|
|
70
|
-
latest: version,
|
|
71
|
-
targetStable: TARGET_STABLE,
|
|
72
|
-
note: "escape hatch bun-14-lock-resolution-pin ready to retire; verify railpack 1.4.0 support then run the atomic flip-back",
|
|
73
|
-
},
|
|
74
|
-
});
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
log.info(
|
|
78
|
-
`[bun-stable-watch] npm bun/latest = ${version} — not yet stable ${TARGET_STABLE}; escape hatch remains`,
|
|
79
|
-
);
|
|
80
|
-
recordEvent({
|
|
81
|
-
kind: "bun.watch.tick",
|
|
82
|
-
payload: { latest: version, targetStable: TARGET_STABLE },
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
pgBossHandlerRegistry.register("@teamscala/service-boot:bun-stable-watch", bunStableWatch);
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
// @system codegen
|
|
2
|
-
// @status generated
|
|
3
|
-
// @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
|
|
4
|
-
//
|
|
5
|
-
// This suite's assertions are OWNED by the codegen band: the band module
|
|
6
|
-
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
|
-
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
|
-
// with it into the band.
|
|
9
|
-
|
|
10
|
-
import { describe, expect, test } from "bun:test";
|
|
11
|
-
import {
|
|
12
|
-
buildBackfillRow,
|
|
13
|
-
diffOutboundSends,
|
|
14
|
-
type GowaOutboundMessage,
|
|
15
|
-
} from "./messaging-send-reconcile-logic.ts";
|
|
16
|
-
|
|
17
|
-
const NOW = new Date("2026-08-26T02:30:00Z");
|
|
18
|
-
const SETTLE_MS = 120_000;
|
|
19
|
-
|
|
20
|
-
describe("diffOutboundSends", () => {
|
|
21
|
-
const rawSend: GowaOutboundMessage = {
|
|
22
|
-
id: "3EB0F05AC9FAAF24BDA045",
|
|
23
|
-
chat_jid: "61428193498@s.whatsapp.net",
|
|
24
|
-
content: "Launcha Authority Index — report page header",
|
|
25
|
-
timestamp: "2026-08-25T16:13:57Z",
|
|
26
|
-
is_from_me: true,
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
test("CLEAN: every outbound id already recorded — nothing to backfill", () => {
|
|
30
|
-
const missing = diffOutboundSends(
|
|
31
|
-
[rawSend],
|
|
32
|
-
new Set(["3EB0F05AC9FAAF24BDA045"]),
|
|
33
|
-
NOW,
|
|
34
|
-
SETTLE_MS,
|
|
35
|
-
);
|
|
36
|
-
expect(missing).toEqual([]);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("VIOLATING: a GOWA-outbound send with no recorded row is a backfill candidate", () => {
|
|
40
|
-
const missing = diffOutboundSends([rawSend], new Set(), NOW, SETTLE_MS);
|
|
41
|
-
expect(missing).toHaveLength(1);
|
|
42
|
-
expect(missing[0]).toEqual({
|
|
43
|
-
id: "3EB0F05AC9FAAF24BDA045",
|
|
44
|
-
chatJid: "61428193498@s.whatsapp.net",
|
|
45
|
-
content: "Launcha Authority Index — report page header",
|
|
46
|
-
timestamp: "2026-08-25T16:13:57.000Z",
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("inbound messages are never candidates (they record themselves via webhook)", () => {
|
|
51
|
-
const inbound: GowaOutboundMessage = { ...rawSend, is_from_me: false };
|
|
52
|
-
expect(diffOutboundSends([inbound], new Set(), NOW, SETTLE_MS)).toEqual([]);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("a send younger than the settle window waits one tick for the recorder", () => {
|
|
56
|
-
const fresh: GowaOutboundMessage = {
|
|
57
|
-
...rawSend,
|
|
58
|
-
timestamp: "2026-08-26T02:29:30Z",
|
|
59
|
-
};
|
|
60
|
-
expect(diffOutboundSends([fresh], new Set(), NOW, SETTLE_MS)).toEqual([]);
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe("buildBackfillRow", () => {
|
|
65
|
-
test("carries the provider id, the recipient, and the reconciled send_kind", () => {
|
|
66
|
-
const row = buildBackfillRow({
|
|
67
|
-
id: "3EB0AF4413C8A5A0694B38",
|
|
68
|
-
chatJid: "61428193498@s.whatsapp.net",
|
|
69
|
-
content: "The quiz report email as delivered",
|
|
70
|
-
timestamp: "2026-08-25T16:14:13Z",
|
|
71
|
-
});
|
|
72
|
-
expect(row.direction).toBe("outbound");
|
|
73
|
-
expect(row.event_type).toBe("sent");
|
|
74
|
-
expect(row.external_msg_id).toBe("3EB0AF4413C8A5A0694B38");
|
|
75
|
-
expect(row.external_chat_id).toBe("61428193498@s.whatsapp.net");
|
|
76
|
-
expect(row.user_phone).toBe("61428193498@s.whatsapp.net");
|
|
77
|
-
expect(row.payload.send_kind).toBe("gowa-reconcile");
|
|
78
|
-
});
|
|
79
|
-
});
|