cursedbelt 2.6.1 → 2.8.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.
- package/dist/core/file-tree/fileTreeModel.d.ts +18 -0
- package/dist/core/file-tree/fileTreeModel.d.ts.map +1 -1
- package/dist/core/file-tree/fileTreeModel.js +24 -0
- package/dist/core/file-tree/fileTreeModel.js.map +1 -1
- package/dist/react/components/TagsInput.d.ts +8 -1
- package/dist/react/components/TagsInput.d.ts.map +1 -1
- package/dist/react/components/TagsInput.js +10 -4
- package/dist/react/components/TagsInput.js.map +1 -1
- package/dist/react/file-tree/FileTree.d.ts +14 -2
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +3 -9
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/index.d.ts +1 -1
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +1 -1
- package/dist/react/file-tree/index.js.map +1 -1
- package/dist/server/sync/alarm.d.ts +35 -0
- package/dist/server/sync/alarm.d.ts.map +1 -0
- package/dist/server/sync/alarm.js +92 -0
- package/dist/server/sync/alarm.js.map +1 -0
- package/dist/server/sync/commands.d.ts +88 -0
- package/dist/server/sync/commands.d.ts.map +1 -0
- package/dist/server/sync/commands.js +242 -0
- package/dist/server/sync/commands.js.map +1 -0
- package/dist/server/sync/engine.d.ts +63 -0
- package/dist/server/sync/engine.d.ts.map +1 -0
- package/dist/server/sync/engine.js +185 -0
- package/dist/server/sync/engine.js.map +1 -0
- package/dist/server/sync/http.d.ts +107 -0
- package/dist/server/sync/http.d.ts.map +1 -0
- package/dist/server/sync/http.js +244 -0
- package/dist/server/sync/http.js.map +1 -0
- package/dist/server/sync/index.d.ts +42 -0
- package/dist/server/sync/index.d.ts.map +1 -0
- package/dist/server/sync/index.js +42 -0
- package/dist/server/sync/index.js.map +1 -0
- package/dist/server/sync/opLog.d.ts +62 -0
- package/dist/server/sync/opLog.d.ts.map +1 -0
- package/dist/server/sync/opLog.js +97 -0
- package/dist/server/sync/opLog.js.map +1 -0
- package/dist/server/sync/planner.d.ts +32 -0
- package/dist/server/sync/planner.d.ts.map +1 -0
- package/dist/server/sync/planner.js +31 -0
- package/dist/server/sync/planner.js.map +1 -0
- package/dist/server/sync/status.d.ts +64 -0
- package/dist/server/sync/status.d.ts.map +1 -0
- package/dist/server/sync/status.js +50 -0
- package/dist/server/sync/status.js.map +1 -0
- package/dist/server/sync/timer.d.ts +53 -0
- package/dist/server/sync/timer.d.ts.map +1 -0
- package/dist/server/sync/timer.js +171 -0
- package/dist/server/sync/timer.js.map +1 -0
- package/dist/server/sync/tokens.d.ts +26 -0
- package/dist/server/sync/tokens.d.ts.map +1 -0
- package/dist/server/sync/tokens.js +52 -0
- package/dist/server/sync/tokens.js.map +1 -0
- package/dist/server/sync/types.d.ts +102 -0
- package/dist/server/sync/types.d.ts.map +1 -0
- package/dist/server/sync/types.js +30 -0
- package/dist/server/sync/types.js.map +1 -0
- package/package.json +25 -15
- package/scripts/verify.ts +292 -0
- package/src/core/file-tree/fileTreeModel.spec.ts +23 -0
- package/src/core/file-tree/fileTreeModel.ts +33 -0
- package/src/demoFixture.spec.ts +11 -1
- package/src/react/components/TagsInput.spec.tsx +34 -0
- package/src/react/components/TagsInput.tsx +23 -1
- package/src/react/file-tree/FileTree.tsx +19 -11
- package/src/react/file-tree/index.ts +2 -0
- package/src/server/sync/alarm.spec.ts +149 -0
- package/src/server/sync/alarm.ts +137 -0
- package/src/server/sync/commands.spec.ts +145 -0
- package/src/server/sync/commands.ts +361 -0
- package/src/server/sync/engine.spec.ts +496 -0
- package/src/server/sync/engine.ts +255 -0
- package/src/server/sync/http.ts +316 -0
- package/src/server/sync/httpRemote.spec.ts +158 -0
- package/src/server/sync/index.ts +90 -0
- package/src/server/sync/opLog.spec.ts +110 -0
- package/src/server/sync/opLog.ts +189 -0
- package/src/server/sync/planner.spec.ts +53 -0
- package/src/server/sync/planner.ts +62 -0
- package/src/server/sync/status.spec.ts +92 -0
- package/src/server/sync/status.ts +108 -0
- package/src/server/sync/timer.spec.ts +150 -0
- package/src/server/sync/timer.ts +207 -0
- package/src/server/sync/tokens.spec.ts +38 -0
- package/src/server/sync/tokens.ts +94 -0
- package/src/server/sync/types.ts +108 -0
- package/src/verifyGraph.spec.ts +181 -0
|
@@ -68,6 +68,8 @@ import type {
|
|
|
68
68
|
} from 'react';
|
|
69
69
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
70
70
|
import {
|
|
71
|
+
type AccessMarkCopy,
|
|
72
|
+
accessMarkCopy,
|
|
71
73
|
describeDrop,
|
|
72
74
|
descendantIdsOf,
|
|
73
75
|
type DropOffer,
|
|
@@ -225,6 +227,19 @@ export interface FileTreeProps {
|
|
|
225
227
|
/** Whether an in-flight external drag is one this tree wants. Default: any drag. */
|
|
226
228
|
acceptsExternalDrag?: (event: ReactDragEvent) => boolean;
|
|
227
229
|
|
|
230
|
+
/**
|
|
231
|
+
* What the 🔒 access mark SAYS, per access state — hover text on the mark a row
|
|
232
|
+
* with `access: 'private' | 'locked'` draws.
|
|
233
|
+
*
|
|
234
|
+
* 🔴 The defaults speak collections' vocabulary ("hidden from a locked page"),
|
|
235
|
+
* because that is the app the mark was built for — but the mark itself is generic
|
|
236
|
+
* and the words are not. The vault's protected folders wear the same padlock
|
|
237
|
+
* meaning "everything in here needs the master password", and a tooltip claiming
|
|
238
|
+
* they are hidden would be false: the rows are visible, their SECRETS are sealed.
|
|
239
|
+
* An app whose privacy model has its own name passes its own words.
|
|
240
|
+
*/
|
|
241
|
+
accessCopy?: AccessMarkCopy;
|
|
242
|
+
|
|
228
243
|
/** Narrow the tree to matching rows, keeping the folders on the way to each. */
|
|
229
244
|
filter?: string;
|
|
230
245
|
/** What to show when there are no rows — differs between "nothing here" and "no match". */
|
|
@@ -331,6 +346,7 @@ export function FileTree({
|
|
|
331
346
|
renderAccessory,
|
|
332
347
|
onDropExternal,
|
|
333
348
|
acceptsExternalDrag,
|
|
349
|
+
accessCopy,
|
|
334
350
|
filter,
|
|
335
351
|
empty = 'Nothing here yet.',
|
|
336
352
|
ariaLabel = 'Files',
|
|
@@ -1255,18 +1271,10 @@ export function FileTree({
|
|
|
1255
1271
|
is the twisty's, and a folder losing its arrow would lose the control that
|
|
1256
1272
|
opens it) and never moves with how many actions a consumer wired up.
|
|
1257
1273
|
*/}
|
|
1274
|
+
{/* The mark's words are the consumer's when it has its own privacy
|
|
1275
|
+
vocabulary — see accessMarkCopy for why the defaults are collections'. */}
|
|
1258
1276
|
{node.access === 'private' || node.access === 'locked' ? (
|
|
1259
|
-
<Tooltip
|
|
1260
|
-
content={
|
|
1261
|
-
node.access === 'locked'
|
|
1262
|
-
? 'Hidden — unlock to see what is in here'
|
|
1263
|
-
: // Deliberately "this or what is in it": a consumer sets `private` both
|
|
1264
|
-
// for a row that is itself private and for a public folder holding
|
|
1265
|
-
// private children (collections does exactly that), and a wording that
|
|
1266
|
-
// claimed only the first would be false on half the rows it marks.
|
|
1267
|
-
'Private — this, or something in it, is hidden from a locked page'
|
|
1268
|
-
}
|
|
1269
|
-
>
|
|
1277
|
+
<Tooltip content={accessMarkCopy(node.access, accessCopy)}>
|
|
1270
1278
|
{/* Not `aria-hidden`: this is the one thing on the row a screen reader
|
|
1271
1279
|
cannot work out from anything else, and the name alone would announce a
|
|
1272
1280
|
private folder identically to a public one. */}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import {
|
|
5
|
+
createCommandQueue,
|
|
6
|
+
createCommandReceiver,
|
|
7
|
+
createCommandRemote,
|
|
8
|
+
startCommandWorker,
|
|
9
|
+
type SyncCommand,
|
|
10
|
+
} from "./commands";
|
|
11
|
+
|
|
12
|
+
const queue = () => createCommandQueue(new Database(":memory:"));
|
|
13
|
+
|
|
14
|
+
describe("command queue", () => {
|
|
15
|
+
test("enqueue → claim → complete lifecycle", () => {
|
|
16
|
+
const q = queue();
|
|
17
|
+
const cmd = q.enqueue("app.start", { key: "notes" }, "prod-ui");
|
|
18
|
+
expect(cmd.status).toBe("queued");
|
|
19
|
+
expect(q.pendingCount()).toBe(1);
|
|
20
|
+
const claimed = q.claimNext("mac");
|
|
21
|
+
expect(claimed?.uid).toBe(cmd.uid);
|
|
22
|
+
expect(claimed?.status).toBe("claimed");
|
|
23
|
+
// Nothing else to claim while it is held.
|
|
24
|
+
expect(q.claimNext("other")).toBeNull();
|
|
25
|
+
expect(q.complete(cmd.uid, { started: true })).toBe(true);
|
|
26
|
+
expect(q.get(cmd.uid)?.status).toBe("done");
|
|
27
|
+
expect(q.pendingCount()).toBe(0);
|
|
28
|
+
// Completing twice is refused — the row already finished.
|
|
29
|
+
expect(q.complete(cmd.uid, {})).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("claims are ordered oldest-first and atomic across workers", () => {
|
|
33
|
+
const q = queue();
|
|
34
|
+
const first = q.enqueue("a", null, "ui");
|
|
35
|
+
const second = q.enqueue("b", null, "ui");
|
|
36
|
+
expect(q.claimNext("w1")?.uid).toBe(first.uid);
|
|
37
|
+
expect(q.claimNext("w2")?.uid).toBe(second.uid);
|
|
38
|
+
expect(q.claimNext("w3")).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a stale claim is reclaimable (a dead worker cannot strand the queue)", async () => {
|
|
42
|
+
const q = queue();
|
|
43
|
+
q.enqueue("a", null, "ui");
|
|
44
|
+
expect(q.claimNext("w1", 60_000)).not.toBeNull();
|
|
45
|
+
// Let the claim age past a 1ms stale window, then reclaim it.
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
47
|
+
expect(q.claimNext("w2", 1)?.claimedBy).toBe("w2");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("fail records the error", () => {
|
|
51
|
+
const q = queue();
|
|
52
|
+
const cmd = q.enqueue("a", null, "ui");
|
|
53
|
+
q.claimNext("w");
|
|
54
|
+
expect(q.fail(cmd.uid, "boom")).toBe(true);
|
|
55
|
+
const row = q.get(cmd.uid);
|
|
56
|
+
expect(row?.status).toBe("failed");
|
|
57
|
+
expect(row?.error).toBe("boom");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("command receiver + remote + worker", () => {
|
|
62
|
+
function mount(q: ReturnType<typeof createCommandQueue>) {
|
|
63
|
+
const app = new Hono();
|
|
64
|
+
app.route(
|
|
65
|
+
"/api/relay",
|
|
66
|
+
createCommandReceiver({
|
|
67
|
+
queue: q,
|
|
68
|
+
verifyToken: (t) => t === "tok",
|
|
69
|
+
maxHoldMs: 300,
|
|
70
|
+
pollStepMs: 20,
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
const remote = createCommandRemote({
|
|
74
|
+
basePath: "http://peer/api/relay",
|
|
75
|
+
token: "tok",
|
|
76
|
+
fetchImpl: ((input: RequestInfo | URL, init?: RequestInit) =>
|
|
77
|
+
app.request(input as string, init)) as typeof fetch,
|
|
78
|
+
});
|
|
79
|
+
return { app, remote };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test("held poll returns a queued command immediately, null after the hold", async () => {
|
|
83
|
+
const q = queue();
|
|
84
|
+
const { remote } = mount(q);
|
|
85
|
+
const empty = await remote.poll("mac", 50);
|
|
86
|
+
expect(empty).toBeNull();
|
|
87
|
+
q.enqueue("repo.sync", null, "ui");
|
|
88
|
+
const got = await remote.poll("mac", 50);
|
|
89
|
+
expect(got?.kind).toBe("repo.sync");
|
|
90
|
+
await remote.report(got?.uid ?? "", { ok: true, result: { healed: 3 } });
|
|
91
|
+
expect(q.get(got?.uid ?? "")?.status).toBe("done");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a command enqueued DURING the hold is delivered within it", async () => {
|
|
95
|
+
const q = queue();
|
|
96
|
+
const { remote } = mount(q);
|
|
97
|
+
const pending = remote.poll("mac", 250);
|
|
98
|
+
setTimeout(() => q.enqueue("late", null, "ui"), 40);
|
|
99
|
+
const got = await pending;
|
|
100
|
+
expect(got?.kind).toBe("late");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the gate refuses a wrong token uniformly", async () => {
|
|
104
|
+
const q = queue();
|
|
105
|
+
const app = new Hono();
|
|
106
|
+
app.route("/api/relay", createCommandReceiver({ queue: q, verifyToken: (t) => t === "tok" }));
|
|
107
|
+
const res = await app.request("http://peer/api/relay/pending?wait=0", {
|
|
108
|
+
headers: { authorization: "Bearer wrong" },
|
|
109
|
+
});
|
|
110
|
+
expect(res.status).toBe(401);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("the worker loop polls, executes, reports, and keeps cycling", async () => {
|
|
114
|
+
const q = queue();
|
|
115
|
+
const { remote } = mount(q);
|
|
116
|
+
const executed: string[] = [];
|
|
117
|
+
const worker = startCommandWorker({
|
|
118
|
+
resolveRemote: () => remote,
|
|
119
|
+
execute: async (command: SyncCommand) => {
|
|
120
|
+
executed.push(command.kind);
|
|
121
|
+
if (command.kind === "explode") throw new Error("kaboom");
|
|
122
|
+
return { ok: true };
|
|
123
|
+
},
|
|
124
|
+
holdMs: 60,
|
|
125
|
+
idleGapMs: 5,
|
|
126
|
+
log: () => {},
|
|
127
|
+
warn: () => {},
|
|
128
|
+
});
|
|
129
|
+
q.enqueue("good", null, "ui");
|
|
130
|
+
q.enqueue("explode", null, "ui");
|
|
131
|
+
const deadline = Date.now() + 3_000;
|
|
132
|
+
while (Date.now() < deadline) {
|
|
133
|
+
const rows = q.list();
|
|
134
|
+
if (rows.length === 2 && rows.every((r) => r.status === "done" || r.status === "failed"))
|
|
135
|
+
break;
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
137
|
+
}
|
|
138
|
+
worker.stop();
|
|
139
|
+
expect(executed.sort()).toEqual(["explode", "good"]);
|
|
140
|
+
const byKind = new Map(q.list().map((r) => [r.kind, r]));
|
|
141
|
+
expect(byKind.get("good")?.status).toBe("done");
|
|
142
|
+
expect(byKind.get("explode")?.status).toBe("failed");
|
|
143
|
+
expect(byKind.get("explode")?.error).toContain("kaboom");
|
|
144
|
+
});
|
|
145
|
+
});
|