cursedbelt 2.6.1 → 2.7.0

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.
Files changed (65) hide show
  1. package/dist/server/sync/alarm.d.ts +35 -0
  2. package/dist/server/sync/alarm.d.ts.map +1 -0
  3. package/dist/server/sync/alarm.js +92 -0
  4. package/dist/server/sync/alarm.js.map +1 -0
  5. package/dist/server/sync/commands.d.ts +88 -0
  6. package/dist/server/sync/commands.d.ts.map +1 -0
  7. package/dist/server/sync/commands.js +242 -0
  8. package/dist/server/sync/commands.js.map +1 -0
  9. package/dist/server/sync/engine.d.ts +63 -0
  10. package/dist/server/sync/engine.d.ts.map +1 -0
  11. package/dist/server/sync/engine.js +185 -0
  12. package/dist/server/sync/engine.js.map +1 -0
  13. package/dist/server/sync/http.d.ts +107 -0
  14. package/dist/server/sync/http.d.ts.map +1 -0
  15. package/dist/server/sync/http.js +244 -0
  16. package/dist/server/sync/http.js.map +1 -0
  17. package/dist/server/sync/index.d.ts +42 -0
  18. package/dist/server/sync/index.d.ts.map +1 -0
  19. package/dist/server/sync/index.js +42 -0
  20. package/dist/server/sync/index.js.map +1 -0
  21. package/dist/server/sync/opLog.d.ts +62 -0
  22. package/dist/server/sync/opLog.d.ts.map +1 -0
  23. package/dist/server/sync/opLog.js +97 -0
  24. package/dist/server/sync/opLog.js.map +1 -0
  25. package/dist/server/sync/planner.d.ts +32 -0
  26. package/dist/server/sync/planner.d.ts.map +1 -0
  27. package/dist/server/sync/planner.js +31 -0
  28. package/dist/server/sync/planner.js.map +1 -0
  29. package/dist/server/sync/status.d.ts +64 -0
  30. package/dist/server/sync/status.d.ts.map +1 -0
  31. package/dist/server/sync/status.js +50 -0
  32. package/dist/server/sync/status.js.map +1 -0
  33. package/dist/server/sync/timer.d.ts +53 -0
  34. package/dist/server/sync/timer.d.ts.map +1 -0
  35. package/dist/server/sync/timer.js +171 -0
  36. package/dist/server/sync/timer.js.map +1 -0
  37. package/dist/server/sync/tokens.d.ts +26 -0
  38. package/dist/server/sync/tokens.d.ts.map +1 -0
  39. package/dist/server/sync/tokens.js +52 -0
  40. package/dist/server/sync/tokens.js.map +1 -0
  41. package/dist/server/sync/types.d.ts +102 -0
  42. package/dist/server/sync/types.d.ts.map +1 -0
  43. package/dist/server/sync/types.js +30 -0
  44. package/dist/server/sync/types.js.map +1 -0
  45. package/package.json +19 -13
  46. package/src/server/sync/alarm.spec.ts +149 -0
  47. package/src/server/sync/alarm.ts +137 -0
  48. package/src/server/sync/commands.spec.ts +145 -0
  49. package/src/server/sync/commands.ts +361 -0
  50. package/src/server/sync/engine.spec.ts +496 -0
  51. package/src/server/sync/engine.ts +255 -0
  52. package/src/server/sync/http.ts +316 -0
  53. package/src/server/sync/httpRemote.spec.ts +158 -0
  54. package/src/server/sync/index.ts +90 -0
  55. package/src/server/sync/opLog.spec.ts +110 -0
  56. package/src/server/sync/opLog.ts +189 -0
  57. package/src/server/sync/planner.spec.ts +53 -0
  58. package/src/server/sync/planner.ts +62 -0
  59. package/src/server/sync/status.spec.ts +92 -0
  60. package/src/server/sync/status.ts +108 -0
  61. package/src/server/sync/timer.spec.ts +150 -0
  62. package/src/server/sync/timer.ts +207 -0
  63. package/src/server/sync/tokens.spec.ts +38 -0
  64. package/src/server/sync/tokens.ts +94 -0
  65. package/src/server/sync/types.ts +108 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timer.js","sourceRoot":"","sources":["../../../src/server/sync/timer.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Scoped device tokens — notes' mint/verify/revoke generalized (vault runs a single
3
+ * static env secret; the token TABLE is the better half: revocable per device, scoped
4
+ * per capability, sha256 at rest so a database read never yields a usable credential).
5
+ *
6
+ * The plaintext token is returned exactly once, at mint. Verification compares sha256
7
+ * digests via `timingSafeEqual` — length-independent, no oracle.
8
+ */
9
+ import type { Database } from "bun:sqlite";
10
+ export interface DeviceToken {
11
+ name: string;
12
+ scope: string;
13
+ createdAt: number;
14
+ revokedAt: number | null;
15
+ }
16
+ export interface TokenStore {
17
+ /** Mint a token for `name`+`scope`; returns the plaintext ONCE. Re-minting a name
18
+ * revokes the previous token of that name (one live credential per device name). */
19
+ mint(name: string, scope: string): string;
20
+ /** Null when unknown, revoked, or out of scope. */
21
+ verify(presented: string, scope: string): DeviceToken | null;
22
+ revoke(name: string): boolean;
23
+ list(): DeviceToken[];
24
+ }
25
+ export declare function createTokenStore(db: Database): TokenStore;
26
+ //# sourceMappingURL=tokens.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../../src/server/sync/tokens.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAG3C,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,UAAU;IAC1B;yFACqF;IACrF,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1C,mDAAmD;IACnD,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAAC;IAC7D,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IAC9B,IAAI,IAAI,WAAW,EAAE,CAAC;CACtB;AAID,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,QAAQ,GAAG,UAAU,CA+DzD"}
@@ -0,0 +1,52 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ const sha256 = (value) => createHash("sha256").update(value).digest();
3
+ export function createTokenStore(db) {
4
+ db.exec(`
5
+ CREATE TABLE IF NOT EXISTS sync_tokens (
6
+ name TEXT PRIMARY KEY,
7
+ scope TEXT NOT NULL,
8
+ token_sha256 BLOB NOT NULL,
9
+ created_at INTEGER NOT NULL,
10
+ revoked_at INTEGER
11
+ );
12
+ `);
13
+ const upsert = db.prepare("INSERT INTO sync_tokens (name, scope, token_sha256, created_at, revoked_at) VALUES (?, ?, ?, ?, NULL) ON CONFLICT(name) DO UPDATE SET scope = excluded.scope, token_sha256 = excluded.token_sha256, created_at = excluded.created_at, revoked_at = NULL");
14
+ const allLive = db.prepare("SELECT name, scope, token_sha256, created_at, revoked_at FROM sync_tokens WHERE revoked_at IS NULL");
15
+ const all = db.prepare("SELECT name, scope, token_sha256, created_at, revoked_at FROM sync_tokens ORDER BY created_at DESC");
16
+ const revokeStmt = db.prepare("UPDATE sync_tokens SET revoked_at = ? WHERE name = ? AND revoked_at IS NULL");
17
+ const toToken = (r) => ({
18
+ name: r.name,
19
+ scope: r.scope,
20
+ createdAt: r.created_at,
21
+ revokedAt: r.revoked_at,
22
+ });
23
+ return {
24
+ mint(name, scope) {
25
+ const token = randomBytes(32).toString("base64url");
26
+ upsert.run(name, scope, sha256(token), Date.now());
27
+ return token;
28
+ },
29
+ verify(presented, scope) {
30
+ if (!presented)
31
+ return null;
32
+ const got = sha256(presented);
33
+ // Scan live rows and compare digests constant-time each. The fleet holds a
34
+ // handful of devices; a table scan is simpler than an index that would make
35
+ // the lookup timing depend on the presented value.
36
+ for (const raw of allLive.all()) {
37
+ const want = Buffer.from(raw.token_sha256);
38
+ if (want.length === got.length && timingSafeEqual(want, got)) {
39
+ if (raw.scope !== scope)
40
+ return null;
41
+ return toToken(raw);
42
+ }
43
+ }
44
+ return null;
45
+ },
46
+ revoke(name) {
47
+ return revokeStmt.run(Date.now(), name).changes > 0;
48
+ },
49
+ list: () => all.all().map(toToken),
50
+ };
51
+ }
52
+ //# sourceMappingURL=tokens.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../../src/server/sync/tokens.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAmBvE,MAAM,MAAM,GAAG,CAAC,KAAa,EAAU,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;AAEtF,MAAM,UAAU,gBAAgB,CAAC,EAAY;IAC5C,EAAE,CAAC,IAAI,CAAC;;;;;;;;EAQP,CAAC,CAAC;IACH,MAAM,MAAM,GAAG,EAAE,CAAC,OAAO,CACxB,yPAAyP,CACzP,CAAC;IACF,MAAM,OAAO,GAAG,EAAE,CAAC,OAAO,CACzB,oGAAoG,CACpG,CAAC;IACF,MAAM,GAAG,GAAG,EAAE,CAAC,OAAO,CACrB,oGAAoG,CACpG,CAAC;IACF,MAAM,UAAU,GAAG,EAAE,CAAC,OAAO,CAC5B,6EAA6E,CAC7E,CAAC;IASF,MAAM,OAAO,GAAG,CAAC,CAAM,EAAe,EAAE,CAAC,CAAC;QACzC,IAAI,EAAE,CAAC,CAAC,IAAI;QACZ,KAAK,EAAE,CAAC,CAAC,KAAK;QACd,SAAS,EAAE,CAAC,CAAC,UAAU;QACvB,SAAS,EAAE,CAAC,CAAC,UAAU;KACvB,CAAC,CAAC;IAEH,OAAO;QACN,IAAI,CAAC,IAAI,EAAE,KAAK;YACf,MAAM,KAAK,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;YACpD,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;YACnD,OAAO,KAAK,CAAC;QACd,CAAC;QACD,MAAM,CAAC,SAAS,EAAE,KAAK;YACtB,IAAI,CAAC,SAAS;gBAAE,OAAO,IAAI,CAAC;YAC5B,MAAM,GAAG,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC;YAC9B,2EAA2E;YAC3E,4EAA4E;YAC5E,mDAAmD;YACnD,KAAK,MAAM,GAAG,IAAI,OAAO,CAAC,GAAG,EAAW,EAAE,CAAC;gBAC1C,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC;gBAC3C,IAAI,IAAI,CAAC,MAAM,KAAK,GAAG,CAAC,MAAM,IAAI,eAAe,CAAC,IAAI,EAAE,GAAG,CAAC,EAAE,CAAC;oBAC9D,IAAI,GAAG,CAAC,KAAK,KAAK,KAAK;wBAAE,OAAO,IAAI,CAAC;oBACrC,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC;gBACrB,CAAC;YACF,CAAC;YACD,OAAO,IAAI,CAAC;QACb,CAAC;QACD,MAAM,CAAC,IAAI;YACV,OAAO,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,IAAI,CAAC,CAAC,OAAO,GAAG,CAAC,CAAC;QACrD,CAAC;QACD,IAAI,EAAE,GAAG,EAAE,CAAE,GAAG,CAAC,GAAG,EAAY,CAAC,GAAG,CAAC,OAAO,CAAC;KAC7C,CAAC;AACH,CAAC"}
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The wire and store types every consumer of this sync engine shares. The op
3
+ * envelope is the notes shape — `(origin, originSeq)` gives exactly-once application
4
+ * with no coordination — and the report is vault's cursor discipline (the reader may
5
+ * advance exactly to `cursor`, never further) merged with notes' `seen` (a duplicate
6
+ * is not an error, it is proof the dedupe ledger works).
7
+ *
8
+ * ── 🔴 "sync-kit" is a PAST NAME. The engine is `cursedbelt/sync` ────────────
9
+ * The retired generation published this as `@satellites/sync-kit`. It came across
10
+ * whole into `apps/vault/src/kit/sync/` when that app graduated, and came here —
11
+ * `cursedbelt/sync` — on 2026-09-15, before `apps/station` could graduate and fork
12
+ * it a second time. That is the move this header used to call an open question in
13
+ * `tasks/`; it is done, and the app-local copies are what is temporary now.
14
+ *
15
+ * `@satellites/sync-kit` is NOT installable and never was — it was never published
16
+ * to npm — and its design plan (`plans/01-sync-kit-adoption-design.md`) belonged to
17
+ * the retired monorepo and did not come across. Comments in consuming apps still say
18
+ * "the sync-kit adoption": that is the NAME OF AN EVENT, the 2026-08 change that
19
+ * moved vault from its own `oplog` table onto this engine's `sync_ops`, and it is
20
+ * worth keeping because the dual-compat code only makes sense in its light.
21
+ */
22
+ /** One durable mutation in the shared op log. `payload` is app-defined per `kind`. */
23
+ export interface SyncOp {
24
+ /** This instance's monotonic sequence for the op (assigned by the local log). */
25
+ seq: number;
26
+ /** The instance id that BORN the op (stable per instance, never per device). */
27
+ origin: string;
28
+ /** The origin's own monotonic counter — `(origin, originSeq)` is the identity. */
29
+ originSeq: number;
30
+ /** Wall-clock ms when the op was born. LWW inputs, display, nothing structural. */
31
+ ts: number;
32
+ /** App-defined kind, e.g. `doc.set`. The version-negotiation unit. */
33
+ kind: string;
34
+ payload: unknown;
35
+ }
36
+ /** What applying one batch did. `cursor` is the last seq FULLY applied — a stopped
37
+ * batch leaves it just before the failure so the next run resumes there. */
38
+ export interface ApplyReport {
39
+ /** Ops that changed local state. */
40
+ applied: number;
41
+ /** Ops already held (dedupe hit) — counted, never re-applied. */
42
+ seen: number;
43
+ /** Concurrent edits reconciled (however the app's applier chose to). */
44
+ conflicts: number;
45
+ cursor: number;
46
+ /** Non-null when the batch stopped early (version skew, deferred ordering…). */
47
+ error: string | null;
48
+ }
49
+ /** The peer info handshake. `opKinds` is the compatibility advertisement — a dialer
50
+ * stops BEFORE the first op a peer cannot take (notes' version-skew lesson: sending
51
+ * it wedges the push with an error that reads like corruption). */
52
+ export interface PeerInfo {
53
+ instanceId: string;
54
+ head: number;
55
+ opKinds: string[];
56
+ }
57
+ /** The transport seam. Pure interface so tests drive the engine against a REAL second
58
+ * store mounted in a Hono app — no HTTP mocks (the notes/vault discipline). */
59
+ export interface RemoteApi {
60
+ info(): Promise<PeerInfo>;
61
+ pull(after: number, excludeOrigin: string): Promise<{
62
+ ops: SyncOp[];
63
+ cursor: number;
64
+ head: number;
65
+ }>;
66
+ push(payload: {
67
+ ops: SyncOp[];
68
+ peerHas: number;
69
+ }): Promise<ApplyReport>;
70
+ /** The receiver's "someone pressed Sync now here" stamp; null when unsupported. */
71
+ requestedAt?(): Promise<number | null>;
72
+ }
73
+ /** Context the applier gets per batch — vault's concurrency bound. */
74
+ export interface ApplyContext {
75
+ /** The peer's stable instance id. */
76
+ peerId: string;
77
+ /** Max LOCAL-born seq the peer already holds of OURS — an entity whose newest
78
+ * local op is beyond this AND diverges is a true concurrent edit. */
79
+ peerReceivedUpTo: number;
80
+ }
81
+ /** The app's merge logic for ONE op that passed the dedupe ledger. Return value:
82
+ * - `"applied"` — state changed
83
+ * - `"ignored"` — stale/no-op (still recorded in the ledger, cursor advances)
84
+ * - `"conflict"` — applied with a materialized conflict artifact
85
+ * Throw {@link StopBatch} to stop BEFORE this op (cursor stays short of it). */
86
+ export type ApplyOne = (op: SyncOp, ctx: ApplyContext) => "applied" | "ignored" | "conflict";
87
+ /** Stop the current batch before the offending op — deferred ordering, a payload this
88
+ * build cannot hold yet, anything where "retry after the world changes" is right. */
89
+ export declare class StopBatch extends Error {
90
+ constructor(reason: string);
91
+ }
92
+ export interface SyncSummary {
93
+ remoteId: string;
94
+ pushed: number;
95
+ pulled: number;
96
+ conflicts: number;
97
+ /** Ops the export policy held back (counted, never sent). */
98
+ held: number;
99
+ /** Set when the remote runs an older schema and cannot take what comes next. */
100
+ blockedBy?: string;
101
+ }
102
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/server/sync/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,sFAAsF;AACtF,MAAM,WAAW,MAAM;IACtB,iFAAiF;IACjF,GAAG,EAAE,MAAM,CAAC;IACZ,gFAAgF;IAChF,MAAM,EAAE,MAAM,CAAC;IACf,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB,mFAAmF;IACnF,EAAE,EAAE,MAAM,CAAC;IACX,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;CACjB;AAED;6EAC6E;AAC7E,MAAM,WAAW,WAAW;IAC3B,oCAAoC;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,IAAI,EAAE,MAAM,CAAC;IACb,wEAAwE;IACxE,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,gFAAgF;IAChF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED;;oEAEoE;AACpE,MAAM,WAAW,QAAQ;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;gFACgF;AAChF,MAAM,WAAW,SAAS;IACzB,IAAI,IAAI,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC1B,IAAI,CACH,KAAK,EAAE,MAAM,EACb,aAAa,EAAE,MAAM,GACnB,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC5D,IAAI,CAAC,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IACxE,mFAAmF;IACnF,WAAW,CAAC,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CACvC;AAED,sEAAsE;AACtE,MAAM,WAAW,YAAY;IAC5B,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAC;IACf;0EACsE;IACtE,gBAAgB,EAAE,MAAM,CAAC;CACzB;AAED;;;;iFAIiF;AACjF,MAAM,MAAM,QAAQ,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,KAAK,SAAS,GAAG,SAAS,GAAG,UAAU,CAAC;AAE7F;sFACsF;AACtF,qBAAa,SAAU,SAAQ,KAAK;gBACvB,MAAM,EAAE,MAAM;CAI1B;AAED,MAAM,WAAW,WAAW;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAC;IACb,gFAAgF;IAChF,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The wire and store types every consumer of this sync engine shares. The op
3
+ * envelope is the notes shape — `(origin, originSeq)` gives exactly-once application
4
+ * with no coordination — and the report is vault's cursor discipline (the reader may
5
+ * advance exactly to `cursor`, never further) merged with notes' `seen` (a duplicate
6
+ * is not an error, it is proof the dedupe ledger works).
7
+ *
8
+ * ── 🔴 "sync-kit" is a PAST NAME. The engine is `cursedbelt/sync` ────────────
9
+ * The retired generation published this as `@satellites/sync-kit`. It came across
10
+ * whole into `apps/vault/src/kit/sync/` when that app graduated, and came here —
11
+ * `cursedbelt/sync` — on 2026-09-15, before `apps/station` could graduate and fork
12
+ * it a second time. That is the move this header used to call an open question in
13
+ * `tasks/`; it is done, and the app-local copies are what is temporary now.
14
+ *
15
+ * `@satellites/sync-kit` is NOT installable and never was — it was never published
16
+ * to npm — and its design plan (`plans/01-sync-kit-adoption-design.md`) belonged to
17
+ * the retired monorepo and did not come across. Comments in consuming apps still say
18
+ * "the sync-kit adoption": that is the NAME OF AN EVENT, the 2026-08 change that
19
+ * moved vault from its own `oplog` table onto this engine's `sync_ops`, and it is
20
+ * worth keeping because the dual-compat code only makes sense in its light.
21
+ */
22
+ /** Stop the current batch before the offending op — deferred ordering, a payload this
23
+ * build cannot hold yet, anything where "retry after the world changes" is right. */
24
+ export class StopBatch extends Error {
25
+ constructor(reason) {
26
+ super(reason);
27
+ this.name = "StopBatch";
28
+ }
29
+ }
30
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/server/sync/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAqEH;sFACsF;AACtF,MAAM,OAAO,SAAU,SAAQ,KAAK;IACnC,YAAY,MAAc;QACzB,KAAK,CAAC,MAAM,CAAC,CAAC;QACd,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC;IACzB,CAAC;CACD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "2.6.1",
3
+ "version": "2.7.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "Shared Bun + React framework",
@@ -460,12 +460,12 @@
460
460
  "import": "./dist/server/storage/index.js"
461
461
  },
462
462
  "./server/item-crypto": {
463
- "types": "./dist/server/auth/vault.d.ts",
464
- "bun": "./src/server/auth/vault.ts",
465
- "source": "./src/server/auth/vault.ts",
466
- "import": "./dist/server/auth/vault.js"
467
- },
468
- "./server/storage/types": {
463
+ "types": "./dist/server/auth/vault.d.ts",
464
+ "bun": "./src/server/auth/vault.ts",
465
+ "source": "./src/server/auth/vault.ts",
466
+ "import": "./dist/server/auth/vault.js"
467
+ },
468
+ "./server/storage/types": {
469
469
  "types": "./dist/server/storage/types.d.ts",
470
470
  "bun": "./src/server/storage/types.ts",
471
471
  "source": "./src/server/storage/types.ts",
@@ -502,17 +502,23 @@
502
502
  "import": "./dist/server/index.js"
503
503
  },
504
504
  "./server/login-throttle": {
505
- "types": "./dist/server/auth/loginThrottle.d.ts",
506
- "bun": "./src/server/auth/loginThrottle.ts",
507
- "source": "./src/server/auth/loginThrottle.ts",
508
- "import": "./dist/server/auth/loginThrottle.js"
509
- },
510
- "./server/guard/revocations": {
505
+ "types": "./dist/server/auth/loginThrottle.d.ts",
506
+ "bun": "./src/server/auth/loginThrottle.ts",
507
+ "source": "./src/server/auth/loginThrottle.ts",
508
+ "import": "./dist/server/auth/loginThrottle.js"
509
+ },
510
+ "./server/guard/revocations": {
511
511
  "types": "./dist/server/guard/revocationStore.d.ts",
512
512
  "bun": "./src/server/guard/revocationStore.ts",
513
513
  "source": "./src/server/guard/revocationStore.ts",
514
514
  "import": "./dist/server/guard/revocationStore.js"
515
515
  },
516
+ "./sync": {
517
+ "types": "./dist/server/sync/index.d.ts",
518
+ "bun": "./src/server/sync/index.ts",
519
+ "source": "./src/server/sync/index.ts",
520
+ "import": "./dist/server/sync/index.js"
521
+ },
516
522
  "./server/sqlite": {
517
523
  "types": "./dist/server/sqlite/index.d.ts",
518
524
  "bun": "./src/server/sqlite/index.ts",
@@ -0,0 +1,149 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createSyncAlarm } from "./alarm";
6
+
7
+ /**
8
+ * 🔴 `tmpdir()`, not vault's `scratchDir()` — the seam moved when this engine did.
9
+ *
10
+ * In `apps/vault` this suite imported `scripts/testScratch`, an opt-in helper that
11
+ * hands out a directory under `$FORGE_STATE` and sweeps it. cursedbelt solves the
12
+ * same problem one level lower: `forceTestEnv.ts` is a bunfig PRELOAD that
13
+ * redirects `$TMPDIR` itself for every test process in the repo (rationale and the
14
+ * 4,950× measurement behind it are in `scripts/testTmpRoot.ts`). So a plain
15
+ * `mkdtempSync(join(tmpdir(), …))` here already lands in the small, swept root —
16
+ * and reaching back for the app's helper would be a cross-repo import that only
17
+ * resolves while the copy in vault still exists.
18
+ */
19
+ const dirs: string[] = [];
20
+ const scratchDir = (prefix: string): string => {
21
+ const dir = mkdtempSync(join(tmpdir(), `cb-${prefix}-`));
22
+ dirs.push(dir);
23
+ return dir;
24
+ };
25
+ afterAll(() => {
26
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
27
+ });
28
+
29
+ function makeAlarm(overrides: { threshold?: number; reRaiseMs?: number } = {}) {
30
+ const dir = scratchDir("sync-alarm");
31
+ const raised: string[] = [];
32
+ let clock = 1_000_000;
33
+ const alarm = createSyncAlarm({
34
+ statePath: join(dir, "alarm.json"),
35
+ threshold: overrides.threshold ?? 3,
36
+ reRaiseMs: overrides.reRaiseMs ?? 60_000,
37
+ notify: (_title, body) => raised.push(body),
38
+ now: () => clock,
39
+ appName: "station",
40
+ });
41
+ return { alarm, raised, tick: (ms: number) => (clock += ms) };
42
+ }
43
+
44
+ describe("sync alarm", () => {
45
+ test("raises after the threshold of paused rounds, not before", () => {
46
+ const { alarm, raised } = makeAlarm();
47
+ alarm.record("paused", "doc.set");
48
+ alarm.record("paused", "doc.set");
49
+ expect(raised).toHaveLength(0);
50
+ alarm.record("paused", "doc.set");
51
+ expect(raised).toHaveLength(1);
52
+ expect(raised[0]).toContain("doc.set");
53
+ });
54
+
55
+ test("re-raises only after the re-raise window", () => {
56
+ const { alarm, raised, tick } = makeAlarm();
57
+ for (let i = 0; i < 5; i++) alarm.record("paused", "k");
58
+ expect(raised).toHaveLength(1);
59
+ tick(59_000);
60
+ alarm.record("paused", "k");
61
+ expect(raised).toHaveLength(1);
62
+ tick(2_000);
63
+ alarm.record("paused", "k");
64
+ expect(raised).toHaveLength(2);
65
+ });
66
+
67
+ test("ok resets the counter; unreachable neither counts nor resets", () => {
68
+ const { alarm, raised } = makeAlarm();
69
+ alarm.record("paused", "k");
70
+ alarm.record("paused", "k");
71
+ // The nightly window: unreachable rounds must not walk the counter to the
72
+ // threshold, and must not clear a genuine pause streak either.
73
+ alarm.record("unreachable");
74
+ alarm.record("paused", "k");
75
+ expect(raised).toHaveLength(1);
76
+ alarm.record("ok");
77
+ alarm.record("paused", "k");
78
+ alarm.record("paused", "k");
79
+ expect(raised).toHaveLength(1); // streak restarted from zero
80
+ });
81
+
82
+ test("auth refusals raise with the re-link message", () => {
83
+ const { alarm, raised } = makeAlarm({ threshold: 1 });
84
+ alarm.record("auth");
85
+ expect(raised[0]).toContain("Re-link");
86
+ });
87
+
88
+ test("state survives a process restart (same state file)", () => {
89
+ const dir = scratchDir("sync-alarm");
90
+ const statePath = join(dir, "alarm.json");
91
+ const raised: string[] = [];
92
+ const make = () =>
93
+ createSyncAlarm({
94
+ statePath,
95
+ threshold: 3,
96
+ notify: (_t, b) => raised.push(b),
97
+ now: () => 5_000,
98
+ });
99
+ make().record("paused", "k");
100
+ make().record("paused", "k");
101
+ make().record("paused", "k");
102
+ expect(raised).toHaveLength(1);
103
+ });
104
+ });
105
+
106
+ /**
107
+ * 🔴 The outcome that was missing, and the eight days it cost.
108
+ *
109
+ * The console's subdomain cutover left the Mac's sync dialer aimed at a host
110
+ * that had become a 301. It followed the redirect into a Cloudflare Access login
111
+ * page and threw a JSON parse error every round — neither `paused` (nothing was
112
+ * version-skewed), nor `auth` (no token was refused), nor `unreachable` (the
113
+ * peer answered). So the alarm recorded NOTHING while every mirrored report on
114
+ * prod froze, each still rendering a confident age.
115
+ */
116
+ describe("a sustained hard ERROR raises, exactly like a sustained pause", () => {
117
+ test("consecutive errors cross the threshold and the alarm names the real error", () => {
118
+ const { alarm, raised } = makeAlarm({ threshold: 3 });
119
+ alarm.record("error", "sync/info: 200 answered text/html, not JSON");
120
+ alarm.record("error", "sync/info: 200 answered text/html, not JSON");
121
+ expect(raised).toHaveLength(0);
122
+ alarm.record("error", "sync/info: 200 answered text/html, not JSON");
123
+ expect(raised).toHaveLength(1);
124
+ // The message carries the cause, because "failed (ignored)" being
125
+ // uninformative is the whole reason this outcome exists.
126
+ expect(raised[0]).toContain("text/html");
127
+ });
128
+
129
+ test("one good round clears the counter", () => {
130
+ const { alarm, raised } = makeAlarm({ threshold: 3 });
131
+ alarm.record("error", "boom");
132
+ alarm.record("error", "boom");
133
+ alarm.record("ok");
134
+ alarm.record("error", "boom");
135
+ alarm.record("error", "boom");
136
+ expect(raised).toHaveLength(0);
137
+ });
138
+
139
+ test("an UNREACHABLE peer between errors neither counts nor resets", () => {
140
+ // The nightly EC2 sleep sits in the middle of a real outage all the time.
141
+ const { alarm, raised } = makeAlarm({ threshold: 3 });
142
+ alarm.record("error", "boom");
143
+ alarm.record("unreachable");
144
+ alarm.record("error", "boom");
145
+ alarm.record("unreachable");
146
+ alarm.record("error", "boom");
147
+ expect(raised).toHaveLength(1);
148
+ });
149
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Pause-alarm escalation — notes' `syncAlarm` generalized. A sync that is version-
3
+ * skew paused or auth-refused for a sustained stretch is a real problem the owner
4
+ * must hear about (the 2026-07-31 notes incident: prod ran stale code most of a day
5
+ * and only an owner eyeballing counts caught it). An UNREACHABLE peer neither counts
6
+ * nor resets — the nightly EC2 sleep must never false-alarm.
7
+ *
8
+ * State is a small JSON file so the alarm survives process restarts; the notifier is
9
+ * a seam (macOS `osascript` by default) so tests and non-Mac hosts inject their own.
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { dirname } from "node:path";
13
+
14
+ /**
15
+ * 🔴 `error` joined on 2026-08-21, and it is the outcome that was MISSING.
16
+ *
17
+ * The console's subdomain cutover left the Mac's `STATION_SYNC_URL` pointing at
18
+ * a host that had become a 301; the dialer followed it into a Cloudflare Access
19
+ * login page and threw `Failed to parse JSON` every round. That is neither
20
+ * `paused` (nothing is version-skewed) nor `auth` (no token was refused) nor
21
+ * `unreachable` (the peer answered), so it recorded NOTHING — the timer logged
22
+ * `[sync] failed (ignored)` and the alarm's counter sat at zero while every
23
+ * mirrored report on prod froze for days, each still rendering a confident age.
24
+ *
25
+ * A sustained hard error is exactly as bad as a sustained pause and is now
26
+ * counted the same. `unreachable` remains the one outcome that neither counts
27
+ * nor resets, because the nightly EC2 sleep must never false-alarm.
28
+ */
29
+ export type AlarmOutcome = "ok" | "paused" | "auth" | "error" | "unreachable";
30
+
31
+ interface AlarmState {
32
+ consecutiveBad: number;
33
+ firstBadAt: number | null;
34
+ lastRaisedAt: number | null;
35
+ lastReason: string | null;
36
+ }
37
+
38
+ const EMPTY: AlarmState = {
39
+ consecutiveBad: 0,
40
+ firstBadAt: null,
41
+ lastRaisedAt: null,
42
+ lastReason: null,
43
+ };
44
+
45
+ export interface SyncAlarmOptions {
46
+ /** Where the counter lives (survives restarts). */
47
+ statePath: string;
48
+ /** Rounds of paused/auth before raising (with the round cadence, pick so the
49
+ * threshold lands around ~45 min of sustained pause). */
50
+ threshold?: number;
51
+ /** Re-raise cadence while the condition persists. Default 4h. */
52
+ reRaiseMs?: number;
53
+ /** Present the alarm. Default: macOS notification via osascript. */
54
+ notify?: (title: string, body: string) => void;
55
+ now?: () => number;
56
+ appName?: string;
57
+ }
58
+
59
+ export interface SyncAlarm {
60
+ /** Record one sync round's outcome; raises when the threshold crosses. */
61
+ record(outcome: AlarmOutcome, reason?: string): void;
62
+ }
63
+
64
+ function defaultNotify(title: string, body: string): void {
65
+ try {
66
+ const esc = (s: string) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
67
+ Bun.spawnSync([
68
+ "osascript",
69
+ "-e",
70
+ `display notification "${esc(body)}" with title "${esc(title)}"`,
71
+ ]);
72
+ } catch {
73
+ // A notifier must never take the loop down.
74
+ }
75
+ }
76
+
77
+ export function createSyncAlarm(options: SyncAlarmOptions): SyncAlarm {
78
+ const threshold = options.threshold ?? 9;
79
+ const reRaiseMs = options.reRaiseMs ?? 4 * 60 * 60_000;
80
+ const notify = options.notify ?? defaultNotify;
81
+ const now = options.now ?? Date.now;
82
+ const appName = options.appName ?? "sync";
83
+
84
+ const load = (): AlarmState => {
85
+ try {
86
+ if (!existsSync(options.statePath)) return { ...EMPTY };
87
+ return {
88
+ ...EMPTY,
89
+ ...(JSON.parse(readFileSync(options.statePath, "utf8")) as Partial<AlarmState>),
90
+ };
91
+ } catch {
92
+ return { ...EMPTY };
93
+ }
94
+ };
95
+ const save = (state: AlarmState): void => {
96
+ try {
97
+ mkdirSync(dirname(options.statePath), { recursive: true });
98
+ writeFileSync(options.statePath, JSON.stringify(state));
99
+ } catch {
100
+ // Losing the counter degrades to a later alarm, never to a crash.
101
+ }
102
+ };
103
+
104
+ return {
105
+ record(outcome, reason) {
106
+ const state = load();
107
+ if (outcome === "ok") {
108
+ save({ ...EMPTY });
109
+ return;
110
+ }
111
+ if (outcome === "unreachable") return; // neither counts nor resets
112
+ state.consecutiveBad += 1;
113
+ state.lastReason = reason ?? outcome;
114
+ if (state.firstBadAt === null) state.firstBadAt = now();
115
+ const due =
116
+ state.consecutiveBad >= threshold &&
117
+ (state.lastRaisedAt === null || now() - state.lastRaisedAt >= reRaiseMs);
118
+ if (due) {
119
+ state.lastRaisedAt = now();
120
+ const since = state.firstBadAt
121
+ ? new Date(state.firstBadAt).toLocaleTimeString()
122
+ : "a while";
123
+ notify(
124
+ outcome === "error" ? `${appName}: sync is FAILING` : `${appName}: sync is paused`,
125
+ outcome === "auth"
126
+ ? `The sync token is being refused (since ${since}). Re-link the peer.`
127
+ : outcome === "error"
128
+ ? // Names the real error, because this alarm's whole reason for
129
+ // existing is that "failed (ignored)" was invisible.
130
+ `Every sync round has failed since ${since}: ${state.lastReason}`
131
+ : `Push has been paused on "${state.lastReason}" since ${since} — the peer likely needs a deploy.`,
132
+ );
133
+ }
134
+ save(state);
135
+ },
136
+ };
137
+ }