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.
Files changed (90) hide show
  1. package/dist/core/file-tree/fileTreeModel.d.ts +18 -0
  2. package/dist/core/file-tree/fileTreeModel.d.ts.map +1 -1
  3. package/dist/core/file-tree/fileTreeModel.js +24 -0
  4. package/dist/core/file-tree/fileTreeModel.js.map +1 -1
  5. package/dist/react/components/TagsInput.d.ts +8 -1
  6. package/dist/react/components/TagsInput.d.ts.map +1 -1
  7. package/dist/react/components/TagsInput.js +10 -4
  8. package/dist/react/components/TagsInput.js.map +1 -1
  9. package/dist/react/file-tree/FileTree.d.ts +14 -2
  10. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  11. package/dist/react/file-tree/FileTree.js +3 -9
  12. package/dist/react/file-tree/FileTree.js.map +1 -1
  13. package/dist/react/file-tree/index.d.ts +1 -1
  14. package/dist/react/file-tree/index.d.ts.map +1 -1
  15. package/dist/react/file-tree/index.js +1 -1
  16. package/dist/react/file-tree/index.js.map +1 -1
  17. package/dist/server/sync/alarm.d.ts +35 -0
  18. package/dist/server/sync/alarm.d.ts.map +1 -0
  19. package/dist/server/sync/alarm.js +92 -0
  20. package/dist/server/sync/alarm.js.map +1 -0
  21. package/dist/server/sync/commands.d.ts +88 -0
  22. package/dist/server/sync/commands.d.ts.map +1 -0
  23. package/dist/server/sync/commands.js +242 -0
  24. package/dist/server/sync/commands.js.map +1 -0
  25. package/dist/server/sync/engine.d.ts +63 -0
  26. package/dist/server/sync/engine.d.ts.map +1 -0
  27. package/dist/server/sync/engine.js +185 -0
  28. package/dist/server/sync/engine.js.map +1 -0
  29. package/dist/server/sync/http.d.ts +107 -0
  30. package/dist/server/sync/http.d.ts.map +1 -0
  31. package/dist/server/sync/http.js +244 -0
  32. package/dist/server/sync/http.js.map +1 -0
  33. package/dist/server/sync/index.d.ts +42 -0
  34. package/dist/server/sync/index.d.ts.map +1 -0
  35. package/dist/server/sync/index.js +42 -0
  36. package/dist/server/sync/index.js.map +1 -0
  37. package/dist/server/sync/opLog.d.ts +62 -0
  38. package/dist/server/sync/opLog.d.ts.map +1 -0
  39. package/dist/server/sync/opLog.js +97 -0
  40. package/dist/server/sync/opLog.js.map +1 -0
  41. package/dist/server/sync/planner.d.ts +32 -0
  42. package/dist/server/sync/planner.d.ts.map +1 -0
  43. package/dist/server/sync/planner.js +31 -0
  44. package/dist/server/sync/planner.js.map +1 -0
  45. package/dist/server/sync/status.d.ts +64 -0
  46. package/dist/server/sync/status.d.ts.map +1 -0
  47. package/dist/server/sync/status.js +50 -0
  48. package/dist/server/sync/status.js.map +1 -0
  49. package/dist/server/sync/timer.d.ts +53 -0
  50. package/dist/server/sync/timer.d.ts.map +1 -0
  51. package/dist/server/sync/timer.js +171 -0
  52. package/dist/server/sync/timer.js.map +1 -0
  53. package/dist/server/sync/tokens.d.ts +26 -0
  54. package/dist/server/sync/tokens.d.ts.map +1 -0
  55. package/dist/server/sync/tokens.js +52 -0
  56. package/dist/server/sync/tokens.js.map +1 -0
  57. package/dist/server/sync/types.d.ts +102 -0
  58. package/dist/server/sync/types.d.ts.map +1 -0
  59. package/dist/server/sync/types.js +30 -0
  60. package/dist/server/sync/types.js.map +1 -0
  61. package/package.json +25 -15
  62. package/scripts/verify.ts +292 -0
  63. package/src/core/file-tree/fileTreeModel.spec.ts +23 -0
  64. package/src/core/file-tree/fileTreeModel.ts +33 -0
  65. package/src/demoFixture.spec.ts +11 -1
  66. package/src/react/components/TagsInput.spec.tsx +34 -0
  67. package/src/react/components/TagsInput.tsx +23 -1
  68. package/src/react/file-tree/FileTree.tsx +19 -11
  69. package/src/react/file-tree/index.ts +2 -0
  70. package/src/server/sync/alarm.spec.ts +149 -0
  71. package/src/server/sync/alarm.ts +137 -0
  72. package/src/server/sync/commands.spec.ts +145 -0
  73. package/src/server/sync/commands.ts +361 -0
  74. package/src/server/sync/engine.spec.ts +496 -0
  75. package/src/server/sync/engine.ts +255 -0
  76. package/src/server/sync/http.ts +316 -0
  77. package/src/server/sync/httpRemote.spec.ts +158 -0
  78. package/src/server/sync/index.ts +90 -0
  79. package/src/server/sync/opLog.spec.ts +110 -0
  80. package/src/server/sync/opLog.ts +189 -0
  81. package/src/server/sync/planner.spec.ts +53 -0
  82. package/src/server/sync/planner.ts +62 -0
  83. package/src/server/sync/status.spec.ts +92 -0
  84. package/src/server/sync/status.ts +108 -0
  85. package/src/server/sync/timer.spec.ts +150 -0
  86. package/src/server/sync/timer.ts +207 -0
  87. package/src/server/sync/tokens.spec.ts +38 -0
  88. package/src/server/sync/tokens.ts +94 -0
  89. package/src/server/sync/types.ts +108 -0
  90. package/src/verifyGraph.spec.ts +181 -0
@@ -0,0 +1,108 @@
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
+
23
+ /** One durable mutation in the shared op log. `payload` is app-defined per `kind`. */
24
+ export interface SyncOp {
25
+ /** This instance's monotonic sequence for the op (assigned by the local log). */
26
+ seq: number;
27
+ /** The instance id that BORN the op (stable per instance, never per device). */
28
+ origin: string;
29
+ /** The origin's own monotonic counter — `(origin, originSeq)` is the identity. */
30
+ originSeq: number;
31
+ /** Wall-clock ms when the op was born. LWW inputs, display, nothing structural. */
32
+ ts: number;
33
+ /** App-defined kind, e.g. `doc.set`. The version-negotiation unit. */
34
+ kind: string;
35
+ payload: unknown;
36
+ }
37
+
38
+ /** What applying one batch did. `cursor` is the last seq FULLY applied — a stopped
39
+ * batch leaves it just before the failure so the next run resumes there. */
40
+ export interface ApplyReport {
41
+ /** Ops that changed local state. */
42
+ applied: number;
43
+ /** Ops already held (dedupe hit) — counted, never re-applied. */
44
+ seen: number;
45
+ /** Concurrent edits reconciled (however the app's applier chose to). */
46
+ conflicts: number;
47
+ cursor: number;
48
+ /** Non-null when the batch stopped early (version skew, deferred ordering…). */
49
+ error: string | null;
50
+ }
51
+
52
+ /** The peer info handshake. `opKinds` is the compatibility advertisement — a dialer
53
+ * stops BEFORE the first op a peer cannot take (notes' version-skew lesson: sending
54
+ * it wedges the push with an error that reads like corruption). */
55
+ export interface PeerInfo {
56
+ instanceId: string;
57
+ head: number;
58
+ opKinds: string[];
59
+ }
60
+
61
+ /** The transport seam. Pure interface so tests drive the engine against a REAL second
62
+ * store mounted in a Hono app — no HTTP mocks (the notes/vault discipline). */
63
+ export interface RemoteApi {
64
+ info(): Promise<PeerInfo>;
65
+ pull(
66
+ after: number,
67
+ excludeOrigin: string,
68
+ ): Promise<{ ops: SyncOp[]; cursor: number; head: number }>;
69
+ push(payload: { ops: SyncOp[]; peerHas: number }): Promise<ApplyReport>;
70
+ /** The receiver's "someone pressed Sync now here" stamp; null when unsupported. */
71
+ requestedAt?(): Promise<number | null>;
72
+ }
73
+
74
+ /** Context the applier gets per batch — vault's concurrency bound. */
75
+ export interface ApplyContext {
76
+ /** The peer's stable instance id. */
77
+ peerId: string;
78
+ /** Max LOCAL-born seq the peer already holds of OURS — an entity whose newest
79
+ * local op is beyond this AND diverges is a true concurrent edit. */
80
+ peerReceivedUpTo: number;
81
+ }
82
+
83
+ /** The app's merge logic for ONE op that passed the dedupe ledger. Return value:
84
+ * - `"applied"` — state changed
85
+ * - `"ignored"` — stale/no-op (still recorded in the ledger, cursor advances)
86
+ * - `"conflict"` — applied with a materialized conflict artifact
87
+ * Throw {@link StopBatch} to stop BEFORE this op (cursor stays short of it). */
88
+ export type ApplyOne = (op: SyncOp, ctx: ApplyContext) => "applied" | "ignored" | "conflict";
89
+
90
+ /** Stop the current batch before the offending op — deferred ordering, a payload this
91
+ * build cannot hold yet, anything where "retry after the world changes" is right. */
92
+ export class StopBatch extends Error {
93
+ constructor(reason: string) {
94
+ super(reason);
95
+ this.name = "StopBatch";
96
+ }
97
+ }
98
+
99
+ export interface SyncSummary {
100
+ remoteId: string;
101
+ pushed: number;
102
+ pulled: number;
103
+ conflicts: number;
104
+ /** Ops the export policy held back (counted, never sent). */
105
+ held: number;
106
+ /** Set when the remote runs an older schema and cannot take what comes next. */
107
+ blockedBy?: string;
108
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * 🔴 The gate got faster on 2026-09-15. This is the file that stops it getting smaller.
3
+ *
4
+ * `bun run verify` was `paths && typecheck && demo:check && build && test && e2e` — six
5
+ * serial links, 142.0s measured end to end on an idle machine, five of them single-threaded
6
+ * work queued behind each other on a fourteen-core Mac. It is now a dependency graph
7
+ * (`scripts/verify.ts`) that runs the same stages concurrently.
8
+ *
9
+ * Every speed-up of a gate is one edit away from being a deletion of a gate, and the two
10
+ * look identical in a diff: dropping `e2e` from the graph makes `verify` four times faster
11
+ * and every test still passes. This generation's third rule is that a repo's gate proves
12
+ * that repo, so the SET of stages is pinned here, by name, against the chain that existed
13
+ * before the change. Adding a stage is free; removing one has to argue with this file.
14
+ *
15
+ * The literal list below is deliberate duplication. A test that derived the expectation from
16
+ * `STAGES` would assert that the graph equals itself.
17
+ */
18
+ import { describe, expect, it } from 'bun:test';
19
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
20
+ import { join, resolve } from 'node:path';
21
+ import pkg from '../package.json';
22
+ import { graphFault, jobLimit, ROOT, STAGES, timingTable } from '../scripts/verify';
23
+
24
+ /**
25
+ * What `verify` ran before it became a graph, expanded to the leaf scripts.
26
+ *
27
+ * `typecheck` was one script running three `tsc` projects serially; it is now three scripts
28
+ * so they can run at once, and `typecheck` still runs all three for anybody typing it by
29
+ * hand. `styles` is new — it is the first two steps of `build`, hoisted so that the things
30
+ * which READ the generated stylesheets can wait on the thing that writes them.
31
+ */
32
+ const MUST_RUN = [
33
+ 'paths',
34
+ 'typecheck:root',
35
+ 'typecheck:server',
36
+ 'typecheck:specs',
37
+ 'demo:check',
38
+ 'styles',
39
+ 'build',
40
+ 'test',
41
+ 'e2e',
42
+ ] as const;
43
+
44
+ describe('the verify graph', () => {
45
+ it('still runs every check the serial chain ran', () => {
46
+ const inGraph = STAGES.map((s) => s.script);
47
+ const missing = MUST_RUN.filter((script) => !inGraph.includes(script));
48
+ expect(
49
+ missing,
50
+ `\`bun run verify\` no longer runs:\n ${missing.join('\n ')}\n` +
51
+ 'A gate that got faster by proving less is not faster. Add the stage back, or — if it ' +
52
+ 'genuinely belongs somewhere else now — say so here and in scripts/verify.ts together.',
53
+ ).toEqual([]);
54
+ });
55
+
56
+ it('names only scripts package.json actually declares', () => {
57
+ const scripts = pkg.scripts as Record<string, string | undefined>;
58
+ const undeclared = STAGES.map((s) => s.script).filter((script) => !scripts[script]);
59
+ expect(undeclared, `verify stages with no script:\n ${undeclared.join('\n ')}`).toEqual([]);
60
+ });
61
+
62
+ it('is the thing `bun run verify` actually runs', () => {
63
+ // Without this the graph can be perfect and unreachable: `verify` could quietly go
64
+ // back to a chain, and every assertion above would still pass.
65
+ expect(pkg.scripts.verify).toInclude('scripts/verify.ts');
66
+ });
67
+
68
+ it('has no dangling `needs` and no cycle', () => {
69
+ expect(graphFault(STAGES)).toBeNull();
70
+ });
71
+
72
+ it('catches a dangling `needs` rather than idling with work left', () => {
73
+ // The negative control. A typo in `needs` makes a stage that can never become ready,
74
+ // and a scheduler that simply runs out of ready work would exit 0 having skipped it —
75
+ // "nothing failed" reported as "everything passed", which is the false green in its
76
+ // purest form.
77
+ expect(graphFault([{ script: 'a' }, { script: 'b', needs: ['typo'] }])).toInclude('typo');
78
+ });
79
+
80
+ it('catches a cycle', () => {
81
+ expect(graphFault([{ script: 'a', needs: ['b'] }, { script: 'b', needs: ['a'] }])).toInclude('cycle');
82
+ });
83
+
84
+ it('catches two stages claiming the same script', () => {
85
+ expect(graphFault([{ script: 'a' }, { script: 'a' }])).toInclude('same script');
86
+ });
87
+ });
88
+
89
+ describe('the two edges the graph rests on', () => {
90
+ it('keeps everything that reads the generated stylesheets behind `styles`', () => {
91
+ // `build` regenerates src/styles-static.css and src/styles-utilities.css;
92
+ // stylesStaticMatches.spec.ts and stylesUtilitiesMatches.spec.ts assert those exact
93
+ // files are current, and the demo bundle e2e drives is compiled from them. Any of
94
+ // those three running beside the generators is a writer racing a reader.
95
+ for (const script of ['build', 'test', 'e2e']) {
96
+ const stage = STAGES.find((s) => s.script === script);
97
+ expect(stage?.needs, `\`${script}\` must wait for \`styles\``).toContain('styles');
98
+ }
99
+ });
100
+
101
+ it('🔴 no spec reads dist/, which is why `build` is a leaf', () => {
102
+ // The graph runs `build` BESIDE `test` and `e2e` rather than in front of them, and
103
+ // that is only sound while nothing under test can observe the compiled output. The
104
+ // build stages into dist.next and swaps, so a reader of `dist/` during a build sees
105
+ // the previous bytes or — for one `mv` — no directory at all.
106
+ const offenders: string[] = [];
107
+ const walk = (dir: string): void => {
108
+ for (const entry of readdirSync(dir)) {
109
+ const full = join(dir, entry);
110
+ if (statSync(full).isDirectory()) {
111
+ walk(full);
112
+ continue;
113
+ }
114
+ if (!/\.spec\.tsx?$/.test(entry)) continue;
115
+ // This file states the pattern, so it matches itself — the same self-exemption
116
+ // `namedSubpathsResolve.spec.ts` needs for the same reason.
117
+ if (entry === 'verifyGraph.spec.ts') continue;
118
+ const text = readFileSync(full, 'utf8');
119
+ // A filesystem read whose path argument mentions dist — not the word in prose,
120
+ // and not `dist.next` inside buildIsStaged's assertions about the SCRIPT TEXT.
121
+ if (/(?:readFileSync|readdirSync|existsSync|statSync|Bun\.file|new Glob)\([^)]*['"`][^'"`]*\bdist\b/.test(text)) {
122
+ offenders.push(full.slice(ROOT.length + 1));
123
+ }
124
+ }
125
+ };
126
+ walk(resolve(ROOT, 'src'));
127
+ expect(
128
+ offenders,
129
+ `these specs read dist/, so \`build\` is no longer a leaf and the graph is wrong:\n ${offenders.join('\n ')}\n` +
130
+ 'Either give them `needs: ["build"]` in scripts/verify.ts, or stop reading dist.',
131
+ ).toEqual([]);
132
+ });
133
+ });
134
+
135
+ describe('the job limit', () => {
136
+ it('defaults to a third of the cores, because a stage is not a process', () => {
137
+ // e2e alone spawns seven Playwright workers; build spawns two tsc processes. Six
138
+ // stages is already twenty-odd processes on a machine with fourteen cores.
139
+ expect(jobLimit({}, 14)).toBe(4);
140
+ expect(jobLimit({}, 4)).toBe(2);
141
+ expect(jobLimit({}, 64)).toBe(6);
142
+ });
143
+
144
+ it('honours an explicit override, including 1', () => {
145
+ // 🔴 `1` is the old serial behaviour, and it must stay reachable without a second
146
+ // gate script existing for somebody to run instead of this one.
147
+ expect(jobLimit({ CURSEDBELT_VERIFY_JOBS: '1' }, 14)).toBe(1);
148
+ expect(jobLimit({ CURSEDBELT_VERIFY_JOBS: '9' }, 14)).toBe(9);
149
+ });
150
+
151
+ it('ignores nonsense rather than running zero stages at a time', () => {
152
+ expect(jobLimit({ CURSEDBELT_VERIFY_JOBS: 'lots' }, 14)).toBe(4);
153
+ expect(jobLimit({ CURSEDBELT_VERIFY_JOBS: '0' }, 14)).toBe(4);
154
+ expect(jobLimit({ CURSEDBELT_VERIFY_JOBS: '-3' }, 14)).toBe(4);
155
+ });
156
+ });
157
+
158
+ describe('the timing table', () => {
159
+ it('reports the overlap, which is the whole point of the change', () => {
160
+ const table = timingTable(
161
+ [
162
+ { script: 'test', ok: true, ms: 60_000, output: '' },
163
+ { script: 'e2e', ok: true, ms: 30_000, output: '' },
164
+ ],
165
+ 60_000,
166
+ );
167
+ expect(table).toInclude('1.5× overlap');
168
+ expect(table).toInclude('test');
169
+ });
170
+
171
+ it('says so plainly when nothing overlapped', () => {
172
+ const table = timingTable([{ script: 'paths', ok: true, ms: 1_000, output: '' }], 1_000);
173
+ expect(table).toInclude('No overlap');
174
+ });
175
+
176
+ it('shows a skipped stage as skipped, never as passing', () => {
177
+ const table = timingTable([{ script: 'e2e', ok: false, ms: 0, output: '', skipped: true }], 1_000);
178
+ expect(table).toInclude('skipped');
179
+ expect(table).not.toInclude(' ok');
180
+ });
181
+ });