browser-broker 0.1.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 (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Step one: the whole schema.
3
+ *
4
+ * **This step is history the moment it has run anywhere.** Every change after
5
+ * it is a new step with an `ALTER`, never an edit to this file — a step that
6
+ * has run somewhere means two installations reporting the same version with
7
+ * different schemas, which nothing reports until something breaks far from the
8
+ * cause (`SCHEMA.md` §1.2d, `MILESTONES.md` #7).
9
+ *
10
+ * ── Why this is raw SQL ─────────────────────────────────────────────────
11
+ *
12
+ * The schema is written in the language the database speaks rather than
13
+ * generated from a model of it, so there are not two descriptions of one
14
+ * schema to reconcile (`SCHEMA.md` §1.11). Two of the things below cannot be
15
+ * expressed by most model layers at all: a **partial** unique index, and a
16
+ * **composite** foreign key naming a two-column target.
17
+ *
18
+ * ── Conventions that hold on every table, so they are stated once ────────
19
+ *
20
+ * `SCHEMA.md` §1.1:
21
+ *
22
+ * - **Time is a timestamp and every process reads the same clock.** Stored in
23
+ * a single fixed textual form that sorts in chronological order, so a
24
+ * comparison is a comparison and needs no conversion inside a transaction
25
+ * every other caller is waiting behind. The default is read from the
26
+ * database's own clock, never from a value a caller computed — several
27
+ * processes are running by design (§1.0a) and two of them disagreeing by a
28
+ * second would make an expiry non-deterministic in a way nothing will ever
29
+ * reproduce.
30
+ * - **`created_at` and `updated_at` are on every table.**
31
+ * - **Identifiers are opaque text**, except `browsers.id`, which is one of two
32
+ * words because callers type it, and the two counter keys, which count
33
+ * upward because they double as a cursor.
34
+ *
35
+ * Enums are `CHECK` constraints rather than a lookup table: the value sets are
36
+ * fixed by this design, and a join to read a word the row already spells is a
37
+ * cost paid on every read to buy nothing.
38
+ */
39
+ /**
40
+ * The clock every default reads.
41
+ *
42
+ * Sub-second precision, because two leases created in the same second are
43
+ * ordinary at this rate and the queue is ordered by `created_at` (§1.5). The
44
+ * tie-break on `id` is what settles the remainder; a whole-second clock would
45
+ * push far too much onto it.
46
+ */
47
+ const NOW = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
48
+ /**
49
+ * `browsers` — a fixed two-row table (§1.2).
50
+ *
51
+ * The two allowed values are enforced by the check and the primary key makes
52
+ * each unique, so **at most** two rows can exist; the seed at the bottom of
53
+ * this step creates both, so **at least** two do. There is no create or delete
54
+ * operation for a browser on any surface (§3.13), so the pair is fixed.
55
+ *
56
+ * `UNIQUE (id, browser_id)`-style composite target: `claims` gets one below,
57
+ * not this table.
58
+ */
59
+ const BROWSERS = `
60
+ CREATE TABLE browsers (
61
+ id TEXT PRIMARY KEY
62
+ CHECK (id IN ('regular', 'private')),
63
+ state TEXT NOT NULL DEFAULT 'stopped'
64
+ CHECK (state IN ('stopped', 'starting', 'running', 'signing-in', 'failed')),
65
+ -- The isolation fact: the service acts on the process recorded here and on
66
+ -- nothing else, so a browser somebody else is running is never touched.
67
+ pid INTEGER,
68
+ launched_at TEXT,
69
+ -- A claim, never a proof (1.2c). It survives the browser dying, so nothing
70
+ -- may attach on the strength of this column without checking first.
71
+ endpoint TEXT,
72
+ -- What makes the endpoint safe to trust once it has been checked: a port
73
+ -- can be handed to something else entirely after the browser that had it
74
+ -- exits, and matching the port alone would attach to a stranger.
75
+ browser_uuid TEXT,
76
+ restart_count INTEGER NOT NULL DEFAULT 0
77
+ CHECK (restart_count >= 0),
78
+ created_at TEXT NOT NULL DEFAULT (${NOW}),
79
+ updated_at TEXT NOT NULL DEFAULT (${NOW}),
80
+ -- A stopped browser has no process, and a running one has one. Stated as a
81
+ -- constraint rather than as a convention because 'pid is null' is what the
82
+ -- reclamation path branches on.
83
+ CHECK ((state = 'stopped') = (pid IS NULL))
84
+ ) STRICT
85
+ `;
86
+ /**
87
+ * `claims` — the lease (§1.3). The entity the whole service is about.
88
+ *
89
+ * **The secret key is never stored.** `key_hash` is a one-way hash of it, and
90
+ * every call that carries a key hashes what it was handed and looks the lease
91
+ * up by this value.
92
+ */
93
+ const CLAIMS = `
94
+ CREATE TABLE claims (
95
+ id TEXT PRIMARY KEY,
96
+ key_hash TEXT NOT NULL UNIQUE,
97
+ -- Not a foreign key, and there is no table of sessions: session identity is
98
+ -- a shared key this service does not own, so a constraint here would mean
99
+ -- inventing a registry for something another system mints.
100
+ session_id TEXT NOT NULL,
101
+ browser_id TEXT NOT NULL REFERENCES browsers (id),
102
+ state TEXT NOT NULL
103
+ CHECK (state IN ('queued', 'active', 'released', 'expired', 'revoked')),
104
+ -- Mandatory, and its justification is revoking: an operator taking capacity
105
+ -- off a caller decides which caller, and session_id is a key another system
106
+ -- minted. Three to two hundred characters (1.3).
107
+ purpose TEXT NOT NULL
108
+ CHECK (length(purpose) BETWEEN 3 AND 200),
109
+ -- One column for both live states: queued and active leases expire by the
110
+ -- same mechanism and only the duration differs.
111
+ expires_at TEXT NOT NULL,
112
+ -- The duration in force for this lease, fixed when it entered its current
113
+ -- state. A renewal has to extend by the duration the caller was told.
114
+ ttl_seconds INTEGER NOT NULL
115
+ CHECK (ttl_seconds > 0),
116
+ activated_at TEXT,
117
+ renew_count INTEGER NOT NULL DEFAULT 0
118
+ CHECK (renew_count >= 0),
119
+ -- When the lease actually lapsed, which is not when a sweep noticed (2.4a).
120
+ expired_at TEXT,
121
+ ended_at TEXT,
122
+ revoke_reason TEXT,
123
+ created_at TEXT NOT NULL DEFAULT (${NOW}),
124
+ updated_at TEXT NOT NULL DEFAULT (${NOW}),
125
+ -- An operator taking capacity off a caller owes a sentence, and the
126
+ -- caller's next call is refused with it. Required only when revoked, and
127
+ -- meaningless otherwise, so both halves are said.
128
+ CHECK ((state = 'revoked') = (revoke_reason IS NOT NULL)),
129
+ -- Final is final, and a final lease has an end (2.1). One column rather
130
+ -- than three, because state already says which.
131
+ CHECK ((state IN ('released', 'expired', 'revoked')) = (ended_at IS NOT NULL)),
132
+ -- Null forever on a lease that expired while waiting, and set at the moment
133
+ -- a lease stops waiting. A queued lease has never had a tab.
134
+ CHECK (state <> 'queued' OR activated_at IS NULL)
135
+ ) STRICT
136
+ `;
137
+ /**
138
+ * `tabs` — the unit of capacity and the unit of ownership (§1.4).
139
+ *
140
+ * **There is no column recording where a tab is**, and that deliberate absence
141
+ * is the single largest privacy improvement in this design. A table of
142
+ * addresses kept over months is a browsing history; there is no such table, so
143
+ * there is no retention setting to get wrong and no clear-history command to
144
+ * build. `captures.url` is a different column and survives — it records what
145
+ * one picture was of, which a tab's later address is not.
146
+ *
147
+ * **The composite foreign key is the reason `foreign_keys` is set explicitly
148
+ * in `open.ts`.** `claim_id` alone would let a tab name a browser its own
149
+ * lease did not; naming the pair makes the database refuse it. That target
150
+ * needs `claims (id, browser_id)` to be unique, which is what the index below
151
+ * provides — free, given `id` is already unique.
152
+ */
153
+ const TABS = `
154
+ CREATE TABLE tabs (
155
+ id TEXT PRIMARY KEY,
156
+ -- The ownership fact. Set once, never null, never changed — which is why
157
+ -- "one live lease per tab" is structural rather than an index (1.11).
158
+ claim_id TEXT NOT NULL,
159
+ -- A copy of the lease's browser, kept because a uniqueness rule can only be
160
+ -- written over columns on one row. It cannot drift: the composite key below
161
+ -- refuses a tab whose browser disagrees with its lease's.
162
+ browser_id TEXT NOT NULL,
163
+ -- Whatever the automation tool calls this tab. Never returned to a caller
164
+ -- on any surface — it is the tool's namespace, and exposing it hands
165
+ -- callers a second, non-opaque way to name a tab.
166
+ driver_tab_id TEXT,
167
+ -- 'closing' is not ceremony: it is the honest representation of "the tool
168
+ -- was asked and has not answered", and it is what stops a page that may
169
+ -- still exist being counted as free.
170
+ state TEXT NOT NULL
171
+ CHECK (state IN ('opening', 'open', 'closing', 'closed', 'failed')),
172
+ opened_at TEXT,
173
+ closed_at TEXT,
174
+ -- A leaked tab, not a leaked lease (2.4b): the budget is not affected, a
175
+ -- page is. This is the flag the clear-a-leaked-tab operation selects on.
176
+ close_failed INTEGER NOT NULL DEFAULT 0
177
+ CHECK (close_failed IN (0, 1)),
178
+ close_attempts INTEGER NOT NULL DEFAULT 0
179
+ CHECK (close_attempts >= 0),
180
+ created_at TEXT NOT NULL DEFAULT (${NOW}),
181
+ updated_at TEXT NOT NULL DEFAULT (${NOW}),
182
+ FOREIGN KEY (claim_id, browser_id) REFERENCES claims (id, browser_id),
183
+ -- A tab that has not opened has no driver name to be unique against, and
184
+ -- one that has opened does. Without this the partial unique index below
185
+ -- would be satisfied by any number of live rows holding null.
186
+ CHECK ((state = 'opening') = (driver_tab_id IS NULL))
187
+ ) STRICT
188
+ `;
189
+ /**
190
+ * `events` — one row per decision, kept in order (§1.6).
191
+ *
192
+ * **Every decision, allowed and refused alike.** A record containing only
193
+ * refusals cannot answer "was this rule ever actually reached", which is the
194
+ * first question anybody asks the day something behaves oddly.
195
+ *
196
+ * The kind list is fixed rather than free text, because a typo in free text
197
+ * creates a phantom category every count then silently misses. It is added to
198
+ * only when the code that writes a new kind exists — which is why this list is
199
+ * exactly the one §1.6 names and not one entry more.
200
+ */
201
+ const EVENTS = `
202
+ CREATE TABLE events (
203
+ -- Counts upward because it doubles as an "everything since here" cursor.
204
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
205
+ -- For an expiry this is not when the lease lapsed; that is claims.expired_at
206
+ -- and 2.4a says why it is recorded separately.
207
+ at TEXT NOT NULL DEFAULT (${NOW}),
208
+ kind TEXT NOT NULL
209
+ CHECK (kind IN (
210
+ 'claim_requested', 'claim_granted', 'claim_queued', 'claim_promoted',
211
+ 'claim_renewed', 'claim_released', 'claim_expired', 'claim_revoked',
212
+ 'tab_opening', 'tab_open_failed', 'tab_closing',
213
+ 'navigate', 'act', 'read', 'evaluate', 'capture', 'compare',
214
+ 'browser_launched', 'browser_adopted', 'browser_exited',
215
+ 'launch_race_lost', 'sweep'
216
+ )),
217
+ -- Separate from kind so "how often is this refused" is one question rather
218
+ -- than a set of parallel event names that have to be kept in step.
219
+ outcome TEXT NOT NULL
220
+ CHECK (outcome IN ('allow', 'deny')),
221
+ -- Which rule refused, named from section 7's list.
222
+ guard TEXT,
223
+ claim_id TEXT REFERENCES claims (id),
224
+ tab_id TEXT REFERENCES tabs (id),
225
+ -- The one denormalisation in the schema that earns its place outright: a
226
+ -- refused request never becomes a lease, so without this column every
227
+ -- refusal on the busiest rule in the service is anonymous.
228
+ session_id TEXT,
229
+ -- Which door the call came in through. The last value is work the service
230
+ -- did on its own behalf inside somebody else's call — not a background job,
231
+ -- because with no long-lived process there is nothing running in the
232
+ -- background, so a sweep is attributed to the call that performed it.
233
+ adapter TEXT NOT NULL
234
+ CHECK (adapter IN ('tool-stdio', 'tool-http', 'cli', 'internal')),
235
+ -- A record, not a restriction: nothing refuses anything on the strength of
236
+ -- this column.
237
+ browser_id TEXT REFERENCES browsers (id),
238
+ -- The rest, shaped per kind. One queryable stream: a column per kind would
239
+ -- be a wide, mostly-empty table, and a table per kind would turn every read
240
+ -- of the ledger into a fifteen-way union.
241
+ detail TEXT,
242
+ -- A guard names the rule that refused, so it belongs on a denial and means
243
+ -- nothing on an allow.
244
+ CHECK ((outcome = 'deny') = (guard IS NOT NULL))
245
+ ) STRICT
246
+ `;
247
+ /**
248
+ * `captures` — what a picture cost (§1.7).
249
+ *
250
+ * `path` is **relative to the artifact root, never absolute** (§1.7a). The
251
+ * root can move, and an absolute path pins every row to one machine's layout
252
+ * the moment it is written.
253
+ */
254
+ const CAPTURES = `
255
+ CREATE TABLE captures (
256
+ id TEXT PRIMARY KEY,
257
+ -- Who took it. Survives the lease ending, which is the point.
258
+ claim_id TEXT NOT NULL REFERENCES claims (id),
259
+ tab_id TEXT NOT NULL REFERENCES tabs (id),
260
+ taken_at TEXT NOT NULL DEFAULT (${NOW}),
261
+ kind TEXT NOT NULL
262
+ CHECK (kind IN ('viewport', 'element', 'full_page')),
263
+ -- Which resolution rung was asked for. Stored rather than inferred from the
264
+ -- dimensions, because the rungs are configuration and can move between
265
+ -- installations.
266
+ tier TEXT NOT NULL
267
+ CHECK (tier IN ('default', 'detail', 'max')),
268
+ -- Free text, and required only on the top tier. The entire mechanism by
269
+ -- which anyone learns why callers escalate; an enum could only report which
270
+ -- of the author's guesses somebody picked.
271
+ reason TEXT,
272
+ source_width INTEGER NOT NULL CHECK (source_width > 0),
273
+ source_height INTEGER NOT NULL CHECK (source_height > 0),
274
+ -- Equal to the pair above when nothing was shrunk, which is how "was this
275
+ -- downscaled" is answered without a flag that could disagree with the
276
+ -- numbers beside it.
277
+ width INTEGER NOT NULL CHECK (width > 0),
278
+ height INTEGER NOT NULL CHECK (height > 0),
279
+ bytes INTEGER NOT NULL CHECK (bytes >= 0),
280
+ path TEXT NOT NULL,
281
+ selector TEXT,
282
+ -- The breakpoint, stored as a number rather than a name because a named set
283
+ -- of breakpoints is a vocabulary the service would have to own.
284
+ viewport_width INTEGER NOT NULL CHECK (viewport_width > 0),
285
+ -- What page this was a picture of. Nothing else records it, and it is a
286
+ -- different fact from where a tab is — for which there is no column at all.
287
+ url TEXT,
288
+ warned INTEGER NOT NULL DEFAULT 0
289
+ CHECK (warned IN (0, 1)),
290
+ created_at TEXT NOT NULL DEFAULT (${NOW}),
291
+ updated_at TEXT NOT NULL DEFAULT (${NOW}),
292
+ -- A written reason is owed on the top tier (3.11).
293
+ CHECK (tier <> 'max' OR reason IS NOT NULL),
294
+ -- An element capture is the one that names an element.
295
+ CHECK ((kind = 'element') = (selector IS NOT NULL)),
296
+ -- Never absolute (1.7a). Both spellings of a root, because the check has to
297
+ -- hold whichever platform wrote the row.
298
+ CHECK (path NOT LIKE '/%' AND path NOT LIKE '_:%' AND path NOT LIKE '\\%')
299
+ ) STRICT
300
+ `;
301
+ /**
302
+ * `comparisons` — what one diff did, under the numbers in force at the time
303
+ * (§1.9).
304
+ *
305
+ * A table rather than a ledger entry for three reasons, and the third is the
306
+ * one that settles it: the ledger is the one thing in this design that may be
307
+ * trimmed (§1.1), so folding this in would let a future decision to trim it
308
+ * silently destroy the tuning history. The other two: a rerun answers a
309
+ * different question once any of the three settings has moved, and the
310
+ * references to a capture, a target and a lease are real foreign keys here and
311
+ * unenforceable inside a blob on a ledger row.
312
+ *
313
+ * **All three settings are copied rather than referenced**, because all three
314
+ * are mutable and all three determined the output — snapshotting one and
315
+ * referencing the others would be a record that is half-true.
316
+ */
317
+ const COMPARISONS = `
318
+ CREATE TABLE comparisons (
319
+ id TEXT PRIMARY KEY,
320
+ source_capture_id TEXT NOT NULL REFERENCES captures (id),
321
+ -- The capture the caller named. A missing target returns the picture with
322
+ -- an explanation rather than a refusal (1.9), so a row is only written when
323
+ -- a diff actually ran and the target was found.
324
+ target_capture_id TEXT NOT NULL REFERENCES captures (id),
325
+ claim_id TEXT NOT NULL REFERENCES claims (id),
326
+ at TEXT NOT NULL DEFAULT (${NOW}),
327
+ -- The three settings actually applied.
328
+ colour_tolerance REAL NOT NULL CHECK (colour_tolerance >= 0),
329
+ minimum_region_area INTEGER NOT NULL CHECK (minimum_region_area >= 0),
330
+ maximum_regions INTEGER NOT NULL CHECK (maximum_regions > 0),
331
+ -- The raw count and its share, before regions are worked out. What
332
+ -- distinguishes "nothing moved" from "the threshold ate it".
333
+ changed_pixels INTEGER NOT NULL CHECK (changed_pixels >= 0),
334
+ changed_ratio REAL NOT NULL CHECK (changed_ratio BETWEEN 0 AND 1),
335
+ -- True when at least one region survives filtering, not when any pixel
336
+ -- differs. Stored rather than derived from the region list because the
337
+ -- definition is the thing every caller branches on and it has to have one
338
+ -- answer.
339
+ changed INTEGER NOT NULL
340
+ CHECK (changed IN (0, 1)),
341
+ -- One entry per changed area with its position, size and two crop paths.
342
+ -- No separate region count: it is the length of this list.
343
+ regions TEXT NOT NULL,
344
+ overlay_path TEXT NOT NULL,
345
+ -- A truncated result that does not say so is a lie about completeness.
346
+ truncated INTEGER NOT NULL DEFAULT 0
347
+ CHECK (truncated IN (0, 1)),
348
+ created_at TEXT NOT NULL DEFAULT (${NOW}),
349
+ updated_at TEXT NOT NULL DEFAULT (${NOW}),
350
+ CHECK (overlay_path NOT LIKE '/%' AND overlay_path NOT LIKE '_:%'
351
+ AND overlay_path NOT LIKE '\\%')
352
+ ) STRICT
353
+ `;
354
+ /**
355
+ * `feedback` — the tenth tool's table, and the one built to be removed
356
+ * (§3.16).
357
+ *
358
+ * Its own table rather than an event kind, on three arguments: the ledger
359
+ * records what the service *did* and this records what a caller *thought*, so
360
+ * folding it in would make every count over `events` start by excluding a kind
361
+ * that is not an event; the planned removal has to stay a deletion rather than
362
+ * becoming a migration over a retired kind other rows still use; and the two
363
+ * have opposite lifecycles — the ledger is written on every call by every
364
+ * process and trimmed, this is written rarely and read by hand.
365
+ *
366
+ * **No lease is required, and that is the point rather than a convenience.** A
367
+ * caller whose claim was refused is the caller most likely to have something
368
+ * worth recording, so requiring a lease would silence exactly the population
369
+ * the tool exists to hear from.
370
+ */
371
+ const FEEDBACK = `
372
+ CREATE TABLE feedback (
373
+ -- Borrows the ledger's cursor discipline: a counter key, so reading
374
+ -- "everything since I last looked" is the same one query it is everywhere.
375
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
376
+ at TEXT NOT NULL DEFAULT (${NOW}),
377
+ session_id TEXT,
378
+ claim_id TEXT REFERENCES claims (id),
379
+ -- The caller's last operation, read from the ledger, so it names what was
380
+ -- actually attempted rather than what the caller remembers attempting.
381
+ last_event_id INTEGER REFERENCES events (id),
382
+ -- The refusal that was hit, if the caller's last event was a denial.
383
+ last_guard TEXT,
384
+ -- The axis is not satisfaction: it is whether the service moved the
385
+ -- caller's actual work forward or got in its way (3.16).
386
+ rating INTEGER NOT NULL
387
+ CHECK (rating BETWEEN 1 AND 5),
388
+ -- A small fixed set, chosen to be greppable. 'worked-well' is in it
389
+ -- deliberately: a list with no positive value collects only complaints.
390
+ category TEXT NOT NULL
391
+ CHECK (category IN (
392
+ 'refusal-unclear', 'no-path', 'worked-around',
393
+ 'surprised-me', 'worked-well'
394
+ )),
395
+ -- The floor is deliberate: twenty characters is roughly the shortest useful
396
+ -- sentence, and it stops a reflexive one-word row.
397
+ note TEXT NOT NULL
398
+ CHECK (length(note) BETWEEN 20 AND 2000),
399
+ created_at TEXT NOT NULL DEFAULT (${NOW}),
400
+ updated_at TEXT NOT NULL DEFAULT (${NOW})
401
+ ) STRICT
402
+ `;
403
+ /**
404
+ * The two partial indexes, and they are two rather than three.
405
+ *
406
+ * **A change in a count, stated rather than corrected quietly** (`SCHEMA.md`
407
+ * §1.11, §9.2). Three partial indexes were built and exercised on the SQLite
408
+ * version this design targets, and that measurement stands — it covered one
409
+ * index more than the design contains. The third enforced one canonical
410
+ * picture per view, browser, kind and breakpoint, and it went with the concept
411
+ * it enforced. A reader who remembers "three, verified" and finds two with no
412
+ * explanation has to work out whether a measurement failed, whether an index
413
+ * was dropped for being wrong, or whether somebody miscounted; none of those
414
+ * happened.
415
+ *
416
+ * **Two rules a reader may look for and will not find.** "One live lease per
417
+ * tab" is structural — a tab's lease reference is set when the row is created,
418
+ * is never null and never changes, so there is nothing for an index to refuse.
419
+ * And **"one live lease per session" is gone entirely**, not enforced
420
+ * differently: a lease is one tab, so a session that wants three tabs holds
421
+ * three leases, and the rule is incompatible with that model. What it would
422
+ * have caught as a side effect — two callers accidentally sharing one session
423
+ * identity — is not caught anywhere, and that is named as lost rather than
424
+ * quietly dropped.
425
+ */
426
+ const PARTIAL_INDEXES = `
427
+ -- One live tab row per physical driver tab. The rule lives at the write
428
+ -- because the staleness is in the read that came before it: two callers both
429
+ -- read "nothing there", both reads were true when made, and nothing re-checks
430
+ -- the second by the time its write lands. Across separate processes this is
431
+ -- not one option among several — there is no shared process to hold a lock in.
432
+ CREATE UNIQUE INDEX one_row_per_physical_tab
433
+ ON tabs (browser_id, driver_tab_id) WHERE state IN ('opening', 'open', 'closing');
434
+
435
+ -- Not a uniqueness rule: this makes the capacity count an index-only scan.
436
+ -- The count of live claims is read inside the transaction every arbitration
437
+ -- call opens, with every other caller on the machine waiting behind it, so
438
+ -- the answer coming out of the index without touching the table is what keeps
439
+ -- the serialised section short.
440
+ CREATE INDEX live_claims
441
+ ON claims (state) WHERE state IN ('queued', 'active');
442
+ `;
443
+ /**
444
+ * The ordinary indexes (`MILESTONES.md` #7).
445
+ *
446
+ * At a tab budget of fifteen the live set is tens of rows, so most of these
447
+ * change nothing measurable on live data. They exist for the historical rows,
448
+ * which are the part that grows without bound — with one exception, the
449
+ * sweep's scan, which is hot rather than historical.
450
+ */
451
+ const INDEXES = `
452
+ -- Not a query index: the target of the composite foreign key on tabs that
453
+ -- stops a tab naming a browser its own lease did not. Free, given id is
454
+ -- already unique.
455
+ CREATE UNIQUE INDEX claims_id_browser ON claims (id, browser_id);
456
+
457
+ -- The sweep's scan: everything live and past its expiry, in one index range.
458
+ -- Read on every arbitration call.
459
+ CREATE INDEX claims_state_expires ON claims (state, expires_at);
460
+
461
+ -- Head of queue, first in first out. Separate from the sweep's index because
462
+ -- they order by different columns, and a scan that has to sort gets slower as
463
+ -- history accumulates.
464
+ CREATE INDEX claims_state_created ON claims (state, created_at);
465
+
466
+ -- A session's own history, and the query the admission transaction needs when
467
+ -- it reads a session's other live leases.
468
+ CREATE INDEX claims_session_created ON claims (session_id, created_at DESC);
469
+
470
+ -- The ownership check, and everything release and the sweep do.
471
+ CREATE INDEX tabs_claim ON tabs (claim_id);
472
+
473
+ -- A slice read; one lease's whole history; the capture and diff rollups; and
474
+ -- which rule refuses most, small because denials are rare.
475
+ CREATE INDEX events_at ON events (at);
476
+ CREATE INDEX events_claim_id ON events (claim_id, id);
477
+ CREATE INDEX events_kind_at ON events (kind, at);
478
+ CREATE INDEX events_guard ON events (guard) WHERE guard IS NOT NULL;
479
+
480
+ -- Listing, and the rollup.
481
+ CREATE INDEX captures_claim ON captures (claim_id);
482
+ CREATE INDEX captures_taken_at ON captures (taken_at);
483
+
484
+ -- The diffs run from one capture, and the diffs run against one — which is
485
+ -- what tuning reads.
486
+ CREATE INDEX comparisons_source ON comparisons (source_capture_id, at DESC);
487
+ CREATE INDEX comparisons_target ON comparisons (target_capture_id);
488
+
489
+ -- Reading it back, most recent first, filtered by kind.
490
+ CREATE INDEX feedback_at ON feedback (at DESC);
491
+ CREATE INDEX feedback_category_at ON feedback (category, at DESC);
492
+ `;
493
+ /**
494
+ * The two browser rows, created by the first schema step (§1.2d).
495
+ *
496
+ * This is what makes "exactly two" hold from the bottom: the check constraint
497
+ * and the primary key cap the table at two, and this seed floors it at two.
498
+ */
499
+ const SEED_BROWSERS = `
500
+ INSERT INTO browsers (id, state) VALUES ('regular', 'stopped'), ('private', 'stopped');
501
+ `;
502
+ /** Every statement this step runs, in the order it runs them. */
503
+ export const STEP_ONE_SQL = [
504
+ BROWSERS,
505
+ CLAIMS,
506
+ TABS,
507
+ EVENTS,
508
+ CAPTURES,
509
+ COMPARISONS,
510
+ FEEDBACK,
511
+ PARTIAL_INDEXES,
512
+ INDEXES,
513
+ SEED_BROWSERS,
514
+ ];
515
+ export const stepOne = {
516
+ version: 1,
517
+ summary: 'The whole schema: seven tables, the two partial indexes, and the two browser rows.',
518
+ apply: (db) => {
519
+ for (const statement of STEP_ONE_SQL) {
520
+ db.exec(statement);
521
+ }
522
+ },
523
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Step two: the one row that is not an environment variable.
3
+ *
4
+ * **A new step rather than an edit to step one**, which is the rule rather
5
+ * than a preference (`CLAUDE.md`, `SCHEMA.md` §1.2d): step one has run
6
+ * somewhere, and editing a step that has run means two installations
7
+ * reporting the same version with different schemas — a difference nothing
8
+ * reports until something breaks far from the cause.
9
+ *
10
+ * ── Why this table exists at all, when §1.10 deletes the settings table ──
11
+ *
12
+ * It is a **check**, not a configuration surface, and the difference is that
13
+ * nothing can write to it through any caller-reachable path. §1.10:
14
+ *
15
+ * > Several processes arbitrate against the tab budget simultaneously
16
+ * > (§1.0a) [...] Each admits callers against its own belief, each is
17
+ * > internally consistent, and **the ceiling silently stops being a
18
+ * > ceiling.**
19
+ *
20
+ * The budget stays an environment variable. This row is what makes several
21
+ * processes' beliefs about it comparable, and `src/store/budget.ts` is the
22
+ * only thing that reads or writes it.
23
+ *
24
+ * **One row, kept by the database rather than by a convention.** `only_row`
25
+ * is pinned to a single value by a check and made unique by the primary key,
26
+ * so a second row cannot be inserted even by a statement written somewhere
27
+ * this design never sees. That is what stops a one-row check quietly becoming
28
+ * a key-value store, which is the shape §1.10 rejected.
29
+ */
30
+ const TAB_BUDGET = `
31
+ CREATE TABLE tab_budget (
32
+ only_row INTEGER PRIMARY KEY
33
+ CHECK (only_row = 1),
34
+ -- The same bound the environment carries, recorded by whichever process
35
+ -- opened this store first. Never adopted and never overwritten after that:
36
+ -- a later process that disagrees refuses to start (7.2).
37
+ tabs INTEGER NOT NULL
38
+ CHECK (tabs > 0),
39
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
40
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
41
+ ) STRICT
42
+ `;
43
+ /** Every statement this step runs. */
44
+ export const STEP_TWO_SQL = [TAB_BUDGET];
45
+ export const stepTwo = {
46
+ version: 2,
47
+ summary: 'The tab-budget agreement row: one value, one row, no caller-reachable write path.',
48
+ apply: (db) => {
49
+ for (const statement of STEP_TWO_SQL) {
50
+ db.exec(statement);
51
+ }
52
+ },
53
+ };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Step three: the queue's order becomes a counter rather than a clock.
3
+ *
4
+ * **A new step rather than an edit to step one**, per the rule that a step
5
+ * which has run somewhere is history (`CLAUDE.md`, `SCHEMA.md` §1.2d).
6
+ *
7
+ * ── The defect this fixes, because it is not obvious from the outside ────
8
+ *
9
+ * §1.5 orders the queue by `created_at`, tie-broken by `id`, and says exactly
10
+ * what the tie-break buys: "two requests in the same millisecond share a
11
+ * `created_at`, and without a tie-break the front of the queue flips between
12
+ * reads". That reasoning is sound and the tie-break does deliver a **stable**
13
+ * order.
14
+ *
15
+ * What it does not deliver is the other promise §2.5 makes:
16
+ *
17
+ * > **A caller's position only ever improves**, and it improves by exactly
18
+ * > one each time a tab comes free.
19
+ *
20
+ * **Measured, and it is reproducible in a few hundred rounds:** three callers
21
+ * arriving inside one millisecond share a `created_at`, so the order between
22
+ * them is decided by `id` — which is a random identifier, unrelated to
23
+ * arrival. A caller whose identifier sorts low is told *position 1* while
24
+ * a caller that genuinely arrived earlier is later told *position 1* as well,
25
+ * and the first caller's next answer is *position 2*. **Its position got
26
+ * worse**, which is the one thing §2.5 promises cannot happen, and it costs
27
+ * exactly the trust the queued response is trying to build when it asks a
28
+ * caller to schedule a check and wait.
29
+ *
30
+ * ── Why a counter and not a finer clock ─────────────────────────────────
31
+ *
32
+ * A higher-resolution timestamp makes ties rarer without making them
33
+ * impossible, which turns a reproducible defect into a rare one — the worst
34
+ * of the available outcomes, because the promise stays false and the evidence
35
+ * goes away. A counter allocated by the database at insert has no ties by
36
+ * construction, in the same way and for the same reason the ledger's cursor
37
+ * has none (§1.6).
38
+ *
39
+ * **`created_at` keeps its own job.** It is when the lease was asked for,
40
+ * which is a fact about the lease that a person reads; it simply stops being
41
+ * the thing that decides who is next.
42
+ */
43
+ const ARRIVAL = `
44
+ ALTER TABLE claims ADD COLUMN arrival INTEGER
45
+ `;
46
+ /**
47
+ * Existing rows get an arrival consistent with the order they would have been
48
+ * served in under the previous rule.
49
+ *
50
+ * A store stepped from version two has live queue entries whose relative
51
+ * order callers have already been told. Backfilling in `created_at`, `id`
52
+ * order preserves every answer already given; assigning arbitrarily would
53
+ * reorder a queue that people are waiting in.
54
+ */
55
+ const BACKFILL = `
56
+ UPDATE claims
57
+ SET arrival = (
58
+ SELECT count(*)
59
+ FROM claims AS earlier
60
+ WHERE (earlier.created_at, earlier.id) <= (claims.created_at, claims.id)
61
+ )
62
+ `;
63
+ /**
64
+ * The counter itself: one row, holding the highest arrival handed out.
65
+ *
66
+ * A table rather than `AUTOINCREMENT` on the column, because `arrival` is
67
+ * being added to a table that already has a primary key — a second
68
+ * autoincrementing column is not something the engine offers — and because
69
+ * the allocation has to happen inside the arbitration transaction, where it
70
+ * is serialised with everything else by construction (§1.0a).
71
+ */
72
+ const SEQUENCE = `
73
+ CREATE TABLE claim_arrival (
74
+ only_row INTEGER PRIMARY KEY
75
+ CHECK (only_row = 1),
76
+ next INTEGER NOT NULL
77
+ CHECK (next >= 0)
78
+ ) STRICT
79
+ `;
80
+ /** Start above whatever the backfill used, so no arrival is ever reused. */
81
+ const SEED_SEQUENCE = `
82
+ INSERT INTO claim_arrival (only_row, next)
83
+ VALUES (1, (SELECT coalesce(max(arrival), 0) FROM claims))
84
+ `;
85
+ /**
86
+ * The order the queue is read in, now with no ties to break.
87
+ *
88
+ * It supersedes nothing at read time — `claims_state_created` still serves
89
+ * the historical queries that order by when a lease was asked for.
90
+ */
91
+ const INDEX = `
92
+ CREATE INDEX claims_state_arrival ON claims (state, arrival)
93
+ `;
94
+ /** Every statement this step runs, in order. */
95
+ export const STEP_THREE_SQL = [
96
+ ARRIVAL,
97
+ BACKFILL,
98
+ SEQUENCE,
99
+ SEED_SEQUENCE,
100
+ INDEX,
101
+ ];
102
+ export const stepThree = {
103
+ version: 3,
104
+ summary: 'The queue orders by an arrival counter, so a position can never get worse.',
105
+ apply: (db) => {
106
+ for (const statement of STEP_THREE_SQL) {
107
+ db.exec(statement);
108
+ }
109
+ },
110
+ };