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.
- package/.env.example +173 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/RELEASES.md +97 -0
- package/dist/package.json +58 -0
- package/dist/src/adapter/conformance/case.js +1 -0
- package/dist/src/adapter/conformance/cases.js +429 -0
- package/dist/src/adapter/conformance/discovery.js +156 -0
- package/dist/src/adapter/conformance/driver.js +1 -0
- package/dist/src/adapter/conformance/drivers.js +36 -0
- package/dist/src/adapter/conformance/run.js +224 -0
- package/dist/src/adapter/conformance/service-subject.js +165 -0
- package/dist/src/adapter/contract.js +24 -0
- package/dist/src/adapter/operations.js +114 -0
- package/dist/src/adapter/service-seam.js +1 -0
- package/dist/src/artifacts/names.js +229 -0
- package/dist/src/artifacts/store.js +174 -0
- package/dist/src/bin/broker-tool.js +63 -0
- package/dist/src/bin/broker.js +111 -0
- package/dist/src/browser/adoption.js +143 -0
- package/dist/src/browser/automation-probe.js +113 -0
- package/dist/src/browser/conformance/case.js +1 -0
- package/dist/src/browser/conformance/cases.js +192 -0
- package/dist/src/browser/conformance/run.js +102 -0
- package/dist/src/browser/conformance/subjects.js +19 -0
- package/dist/src/browser/discovery.js +226 -0
- package/dist/src/browser/driver.js +195 -0
- package/dist/src/browser/fake.js +585 -0
- package/dist/src/browser/launch.js +504 -0
- package/dist/src/browser/real.js +1425 -0
- package/dist/src/browser/setup.js +161 -0
- package/dist/src/capture/accounting.js +59 -0
- package/dist/src/capture/image.js +112 -0
- package/dist/src/capture/ladder.js +72 -0
- package/dist/src/capture/legibility.js +195 -0
- package/dist/src/capture/pipeline.js +153 -0
- package/dist/src/capture/tiers.js +166 -0
- package/dist/src/cli/adapter.js +233 -0
- package/dist/src/cli/commands.js +270 -0
- package/dist/src/cli/conformance-driver.js +119 -0
- package/dist/src/cli/diffs.js +122 -0
- package/dist/src/cli/image.js +274 -0
- package/dist/src/cli/index.js +895 -0
- package/dist/src/cli/login-command.js +401 -0
- package/dist/src/cli/operations-commands.js +186 -0
- package/dist/src/cli/reconcile-command.js +137 -0
- package/dist/src/cli/sign-in.js +134 -0
- package/dist/src/cli/telemetry.js +222 -0
- package/dist/src/config/environment.js +446 -0
- package/dist/src/diff/artifact-path.js +77 -0
- package/dist/src/diff/crops.js +102 -0
- package/dist/src/diff/geometry.js +122 -0
- package/dist/src/diff/image.js +132 -0
- package/dist/src/diff/mask.js +46 -0
- package/dist/src/diff/regions.js +263 -0
- package/dist/src/diff/settings.js +135 -0
- package/dist/src/doctor/checks.js +588 -0
- package/dist/src/doctor/report.js +152 -0
- package/dist/src/doctor/session.js +161 -0
- package/dist/src/errors.js +36 -0
- package/dist/src/feedback/read.js +119 -0
- package/dist/src/feedback/record.js +199 -0
- package/dist/src/operations/addresses.js +175 -0
- package/dist/src/operations/derive.js +109 -0
- package/dist/src/operations/ledger.js +194 -0
- package/dist/src/operations/status.js +197 -0
- package/dist/src/operations/telemetry.js +280 -0
- package/dist/src/report/document.js +419 -0
- package/dist/src/report/escape.js +68 -0
- package/dist/src/report/snapshot.js +97 -0
- package/dist/src/service/arbitration.js +537 -0
- package/dist/src/service/artifacts.js +85 -0
- package/dist/src/service/bridge.js +577 -0
- package/dist/src/service/broker.js +120 -0
- package/dist/src/service/browser-session.js +269 -0
- package/dist/src/service/capacity.js +62 -0
- package/dist/src/service/capture-seam.js +83 -0
- package/dist/src/service/capture-store.js +91 -0
- package/dist/src/service/comparison-store.js +101 -0
- package/dist/src/service/comparison.js +173 -0
- package/dist/src/service/events.js +93 -0
- package/dist/src/service/keys.js +68 -0
- package/dist/src/service/leases.js +147 -0
- package/dist/src/service/nudge.js +66 -0
- package/dist/src/service/operations/claim.js +692 -0
- package/dist/src/service/operations/give-back.js +131 -0
- package/dist/src/service/operations/pages.js +771 -0
- package/dist/src/service/operations/sign-in.js +915 -0
- package/dist/src/service/operations/status.js +62 -0
- package/dist/src/service/ownership.js +93 -0
- package/dist/src/service/pages.js +616 -0
- package/dist/src/service/pending-seeds.js +20 -0
- package/dist/src/service/queue.js +233 -0
- package/dist/src/service/reconcile.js +220 -0
- package/dist/src/service/refusals.js +262 -0
- package/dist/src/service/runtime.js +131 -0
- package/dist/src/service/signin-recovery.js +148 -0
- package/dist/src/service/storage-seed.js +239 -0
- package/dist/src/service/tabs.js +123 -0
- package/dist/src/store/budget.js +99 -0
- package/dist/src/store/location.js +42 -0
- package/dist/src/store/network-path.js +182 -0
- package/dist/src/store/network-volume.js +92 -0
- package/dist/src/store/open.js +226 -0
- package/dist/src/store/schema/step-001-initial.js +523 -0
- package/dist/src/store/schema/step-002-tab-budget.js +53 -0
- package/dist/src/store/schema/step-003-queue-order.js +110 -0
- package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
- package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
- package/dist/src/store/schema/step-006-signin-events.js +104 -0
- package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
- package/dist/src/store/schema/step-008-signin-owner.js +76 -0
- package/dist/src/store/schema/step-009-named-browsers.js +138 -0
- package/dist/src/store/schema/step-010-signin-request.js +135 -0
- package/dist/src/store/schema/step.js +172 -0
- package/dist/src/store/schema/steps.js +58 -0
- package/dist/src/store/transaction.js +37 -0
- package/dist/src/tool/adapter.js +90 -0
- package/dist/src/tool/conformance-driver.js +184 -0
- package/dist/src/tool/protocol.js +310 -0
- package/dist/src/tool/session.js +351 -0
- package/dist/src/tool/tools.js +310 -0
- package/package.json +58 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step ten: a sign-in can be **asked for by a caller**, and one asked for
|
|
3
|
+
* that way has a deadline and a lease behind it.
|
|
4
|
+
*
|
|
5
|
+
* ── The gap this closes, stated as the thing that happened ──────────────
|
|
6
|
+
*
|
|
7
|
+
* §1.2 records **25 sessions that hand-seeded authentication tokens into an
|
|
8
|
+
* isolated browser while the signed-in browser sat unused**. That measurement
|
|
9
|
+
* is usually read as a browser-choice problem, and `DECISIONS.md` §13i reads
|
|
10
|
+
* it that way when it makes `browser` optional. **It has a second half.** A
|
|
11
|
+
* caller that picked the right browser and still landed on a login page had
|
|
12
|
+
* no call available to it that asks a person to sign in: `broker login` is a
|
|
13
|
+
* command a person types, and the ten tools contain nothing that moves a
|
|
14
|
+
* browser into `signing-in`. So the only moves left to such a caller were to
|
|
15
|
+
* abandon the task or to fabricate a session — and fabricating a session is
|
|
16
|
+
* precisely what those 25 sessions did.
|
|
17
|
+
*
|
|
18
|
+
* ── Why the existing two columns do not cover this ──────────────────────
|
|
19
|
+
*
|
|
20
|
+
* Step eight added `signin_owner_pid` so that a sign-in whose *command*
|
|
21
|
+
* died is recoverable. That column answers **"is the person's command still
|
|
22
|
+
* running"**, and it is exactly right for `broker login`, where a person is
|
|
23
|
+
* sitting in front of a process that stays up for the duration.
|
|
24
|
+
*
|
|
25
|
+
* **A requested sign-in has no such process.** The caller that asked is a
|
|
26
|
+
* connected session which returns from the call immediately and goes back to
|
|
27
|
+
* polling; nothing on the machine is blocked on the person, so there is no
|
|
28
|
+
* identifier whose death means *nobody is signing in any more*. Recording the
|
|
29
|
+
* requesting service's own identifier would be worse than recording nothing,
|
|
30
|
+
* because that process stays alive for the whole session: the row would look
|
|
31
|
+
* permanently live, and the recovery step eight exists to provide would never
|
|
32
|
+
* fire for the one path that most needs it.
|
|
33
|
+
*
|
|
34
|
+
* So the two ways in are recorded differently on purpose, and the columns are
|
|
35
|
+
* separate rather than one column meaning two things:
|
|
36
|
+
*
|
|
37
|
+
* | Began by | Held by | Ends when |
|
|
38
|
+
* |---|---|---|
|
|
39
|
+
* | `broker login` | a process (`signin_owner_pid`) | the person closes the window, or the process dies |
|
|
40
|
+
* | a caller asking | a deadline (`signin_deadline`) and the asking lease (`signin_claim_id`) | the person confirms, or the deadline passes |
|
|
41
|
+
*
|
|
42
|
+
* ── Why a deadline at all, when §5.5.1 says a sign-in has no expiry ─────
|
|
43
|
+
*
|
|
44
|
+
* §5.5.1's *"a person takes as long as they take"* is a statement about a
|
|
45
|
+
* person **who is already at the keyboard**, and it is right for the command:
|
|
46
|
+
* a timeout there would end a sign-in that was going fine while somebody was
|
|
47
|
+
* halfway through a password, and the process holding it is evidence enough
|
|
48
|
+
* that somebody is still there.
|
|
49
|
+
*
|
|
50
|
+
* A requested sign-in has no such evidence, and the failure it makes possible
|
|
51
|
+
* is new. The request is relayed by an agent to a person **who may not be at
|
|
52
|
+
* the keyboard at all** — who may not read the message for an hour, or ever.
|
|
53
|
+
* Left unbounded, one such request holds the browser in `signing-in` against
|
|
54
|
+
* every other caller indefinitely, which is the same unrecoverable state step
|
|
55
|
+
* eight was written to remove, arriving through a door step eight cannot
|
|
56
|
+
* watch. **The deadline is the evidence-substitute**: no process to ask about,
|
|
57
|
+
* so a number instead.
|
|
58
|
+
*
|
|
59
|
+
* ── Why the deadline is a stored column rather than a computed one ──────
|
|
60
|
+
*
|
|
61
|
+
* §1.12's rule is that a value is stored when it cannot be derived from what
|
|
62
|
+
* is already there. `browsers.updated_at` moves on every write to the row, so
|
|
63
|
+
* *"when did this sign-in start"* is not recoverable from it after any other
|
|
64
|
+
* update, and a deadline computed from a moving column is a deadline that
|
|
65
|
+
* moves. The alternative — reading the last `browser_signin_began` ledger row
|
|
66
|
+
* — makes an operational decision depend on scanning an append-only log whose
|
|
67
|
+
* whole design is that it is written and never read back for control flow
|
|
68
|
+
* (§1.6). So the moment is written down once, at the instant it is decided.
|
|
69
|
+
*
|
|
70
|
+
* ── Why the asking lease is recorded ────────────────────────────────────
|
|
71
|
+
*
|
|
72
|
+
* Two reasons, and the second is the load-bearing one.
|
|
73
|
+
*
|
|
74
|
+
* 1. **So the requester is not refused by its own request.** §5.5.1's first
|
|
75
|
+
* step refuses a sign-in while any live lease holds a tab on that browser,
|
|
76
|
+
* and the requesting caller *is* such a lease — it is holding the very tab
|
|
77
|
+
* sitting on the login page. Without knowing which lease asked, the
|
|
78
|
+
* operation cannot tell the caller it is serving from the callers it is
|
|
79
|
+
* protecting, and the request refuses itself every time.
|
|
80
|
+
* 2. **So the exemption is exactly one lease wide.** Recording the identifier
|
|
81
|
+
* means the refusal still fires for every *other* live lease, which is the
|
|
82
|
+
* property §5.5.1 calls "why signing in is a service operation": a person
|
|
83
|
+
* driving a window by hand underneath somebody else's work corrupts it.
|
|
84
|
+
* An exemption expressed as a flag — *"this sign-in was requested, so skip
|
|
85
|
+
* the check"* — would be an exemption for all of them.
|
|
86
|
+
*
|
|
87
|
+
* ── Nullable, because the older path sets neither ───────────────────────
|
|
88
|
+
*
|
|
89
|
+
* Both columns are null for a `broker login` sign-in, and that is the shape
|
|
90
|
+
* rather than a gap: a null deadline means **this sign-in is held by a
|
|
91
|
+
* process, ask step eight's column about it**. The two are not made mutually
|
|
92
|
+
* exclusive by a check constraint, and the reason is worth recording rather
|
|
93
|
+
* than being discovered as an omission: a constraint spanning them would
|
|
94
|
+
* have to be rebuilt to change, and the invariant it would express — that a
|
|
95
|
+
* sign-in has exactly one kind of owner — is one the operations enforce by
|
|
96
|
+
* refusing a second sign-in at all. There is no path that writes both.
|
|
97
|
+
*
|
|
98
|
+
* ── A new step, and no rebuild ──────────────────────────────────────────
|
|
99
|
+
*
|
|
100
|
+
* Two `ALTER TABLE ... ADD COLUMN` statements, which SQLite performs in place.
|
|
101
|
+
* Step nine had to rebuild because it dropped a check; nothing here changes a
|
|
102
|
+
* constraint, so nothing here needs the table copied. Adding a step rather
|
|
103
|
+
* than editing step nine is the rule in `steps.ts`: a step that has run
|
|
104
|
+
* somewhere is history.
|
|
105
|
+
*/
|
|
106
|
+
const ADDITIONS = [
|
|
107
|
+
// When this sign-in stops being somebody's and starts being nobody's.
|
|
108
|
+
// ISO-8601 in the same spelling every other instant in this store uses, so
|
|
109
|
+
// the comparison against `strftime('%Y-%m-%dT%H:%M:%fZ', 'now')` that the
|
|
110
|
+
// sweep already runs against `claims.expires_at` compares like with like.
|
|
111
|
+
`ALTER TABLE browsers ADD COLUMN signin_deadline TEXT`,
|
|
112
|
+
// Which lease asked. **Deliberately not a foreign key**, and this is the one
|
|
113
|
+
// decision in the step that is not obvious.
|
|
114
|
+
//
|
|
115
|
+
// `claims` rows are never deleted — a lease ends by moving to a terminal
|
|
116
|
+
// state (§2.2) — so a reference would not dangle. What a foreign key would
|
|
117
|
+
// do is make the *order* of two writes load-bearing: clearing this column
|
|
118
|
+
// and ending the claim would have to happen in an order the constraint
|
|
119
|
+
// permits, on a path whose whole job is to run during a sweep that is
|
|
120
|
+
// already updating claims. The column is read for exactly one comparison —
|
|
121
|
+
// is this the lease that asked — and a value naming a claim absent from the
|
|
122
|
+
// table gives that comparison the same answer as a null would.
|
|
123
|
+
`ALTER TABLE browsers ADD COLUMN signin_claim_id TEXT`,
|
|
124
|
+
];
|
|
125
|
+
/** Every statement this step runs, in order. */
|
|
126
|
+
export const STEP_TEN_SQL = ADDITIONS;
|
|
127
|
+
export const stepTen = {
|
|
128
|
+
version: 10,
|
|
129
|
+
summary: 'A sign-in can be requested by a caller: a deadline and the asking lease (§5.5.2, DECISIONS.md §13j).',
|
|
130
|
+
apply: (db) => {
|
|
131
|
+
for (const statement of STEP_TEN_SQL) {
|
|
132
|
+
db.exec(statement);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { StartupRefusal } from "../../errors.js";
|
|
2
|
+
import { immediate } from "../transaction.js";
|
|
3
|
+
import { EXPECTED_VERSION, STEPS } from "./steps.js";
|
|
4
|
+
/**
|
|
5
|
+
* Step a store from whatever version it is at to the version this build
|
|
6
|
+
* expects.
|
|
7
|
+
*
|
|
8
|
+
* `SCHEMA.md` §1.2d: the schema is a version stepper the service applies to
|
|
9
|
+
* its own store **on every spawn**, rather than a migration tool somebody
|
|
10
|
+
* runs as a deployment step. There is no deployment moment at which "run the
|
|
11
|
+
* migrations" could be a separate act: a caller that has just upgraded and a
|
|
12
|
+
* caller that has not may both spawn within the same minute, so the check
|
|
13
|
+
* belongs on every spawn or it belongs nowhere. A store already at the right
|
|
14
|
+
* version is left untouched.
|
|
15
|
+
*
|
|
16
|
+
* The steps between the two versions are applied **in order, in one
|
|
17
|
+
* transaction**.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* `startup.schema_stepped` (§7.2): **a store at a version this build does not
|
|
21
|
+
* understand is a refusal, not an attempted downgrade.** Two callers on
|
|
22
|
+
* different builds against one store is an ordinary situation here, and
|
|
23
|
+
* guessing is how one of them corrupts it.
|
|
24
|
+
*
|
|
25
|
+
* This refusal lands before any step exists, deliberately. A stepper that
|
|
26
|
+
* only knows how to step *up* is the one somebody later extends with a
|
|
27
|
+
* well-meaning downgrade path; landing the refusal first makes that a change
|
|
28
|
+
* to existing behaviour rather than a gap somebody fills.
|
|
29
|
+
*/
|
|
30
|
+
export function readStoreVersion(db) {
|
|
31
|
+
const row = db.pragma('user_version', { simple: true });
|
|
32
|
+
return typeof row === 'number' ? row : 0;
|
|
33
|
+
}
|
|
34
|
+
export async function stepSchema(db, steps = STEPS, expected = EXPECTED_VERSION) {
|
|
35
|
+
// ── The version is read OUTSIDE the transaction only to decide whether to
|
|
36
|
+
// open one, and is read AGAIN INSIDE it before anything is applied ────
|
|
37
|
+
//
|
|
38
|
+
// **This read decides nothing on its own.** It exists so that the ordinary
|
|
39
|
+
// case — a store already at the right version, which is every spawn after
|
|
40
|
+
// the first — costs a pragma rather than a transaction. Every decision that
|
|
41
|
+
// matters is taken again below, inside the transaction, against a version
|
|
42
|
+
// re-read there.
|
|
43
|
+
//
|
|
44
|
+
// A reader tempted to trust this value and delete the re-read should know
|
|
45
|
+
// what that costs: it is the defect this function was changed to fix.
|
|
46
|
+
const observed = readStoreVersion(db);
|
|
47
|
+
if (observed > expected) {
|
|
48
|
+
throw new StartupRefusal('startup.schema_stepped', `The store is at schema version ${String(observed)} and this build understands version ${String(expected)}. A store newer than the build is refused rather than downgraded: two callers on different builds against one store is ordinary here, and guessing is how one of them corrupts it. Upgrade this installation.`);
|
|
49
|
+
}
|
|
50
|
+
if (observed === expected) {
|
|
51
|
+
return { from: observed, to: expected, applied: [] };
|
|
52
|
+
}
|
|
53
|
+
// ── Foreign keys are suspended for the duration of the steps ────────────
|
|
54
|
+
//
|
|
55
|
+
// **This is SQLite's documented table-rebuild procedure, and it is required
|
|
56
|
+
// rather than convenient.** A step that changes a check constraint cannot
|
|
57
|
+
// alter it in place: the table has to be rebuilt, which means dropping a
|
|
58
|
+
// table that another table references — and that violates the reference
|
|
59
|
+
// *even when the rebuild is going to restore every row*.
|
|
60
|
+
//
|
|
61
|
+
// The failure this prevents is total and was reproduced on this path before
|
|
62
|
+
// the guard existed: `feedback.last_event_id` references `events (id)`, so
|
|
63
|
+
// rebuilding `events` failed with a foreign-key violation on **any store
|
|
64
|
+
// that had feedback naming an event**. `prepareStore` rethrows and there is
|
|
65
|
+
// no long-lived process, so every spawn failed — an installation
|
|
66
|
+
// permanently unable to start, and specifically the installations used
|
|
67
|
+
// enough to have collected feedback.
|
|
68
|
+
//
|
|
69
|
+
// **Why it is here rather than at the four call sites.** The pragma is a
|
|
70
|
+
// **no-op inside a transaction** — SQLite silently ignores it and it reads
|
|
71
|
+
// back unchanged — so it has to be set before `BEGIN`, which is inside this
|
|
72
|
+
// function. Putting it in the callers would mean four places to get right
|
|
73
|
+
// and a fifth caller that misses it; this function owns the transaction, so
|
|
74
|
+
// it owns the pragma that has to wrap it.
|
|
75
|
+
//
|
|
76
|
+
// **What is given up, stated plainly:** during the steps the database does
|
|
77
|
+
// not enforce references, so a step that orphaned a row would not be caught
|
|
78
|
+
// as it ran. That is why the integrity check below is not optional — it is
|
|
79
|
+
// the enforcement, re-applied in one pass before the version is stamped.
|
|
80
|
+
const foreignKeysWereOn = db.pragma('foreign_keys', { simple: true }) === 1;
|
|
81
|
+
if (foreignKeysWereOn) {
|
|
82
|
+
db.pragma('foreign_keys = OFF');
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
// **Awaited, not returned.** `immediate` is asynchronous, so returning its
|
|
86
|
+
// promise would run the `finally` below — restoring the pragma — before
|
|
87
|
+
// the transaction had even begun, which puts enforcement back on for the
|
|
88
|
+
// rebuild it was suspended for. The await is what makes the restore
|
|
89
|
+
// happen after the steps rather than during them.
|
|
90
|
+
return await immediate(db, () => {
|
|
91
|
+
// ══════════════════════════════════════════════════════════════════
|
|
92
|
+
// THE VERSION IS READ AGAIN HERE, AND THIS READ IS THE ONE THAT COUNTS
|
|
93
|
+
// ══════════════════════════════════════════════════════════════════
|
|
94
|
+
//
|
|
95
|
+
// **The hazard this closes.** Reading the version once, outside the
|
|
96
|
+
// transaction, and deriving the steps from it lets two processes spawning
|
|
97
|
+
// simultaneously against an empty store both read zero, both decide step
|
|
98
|
+
// one is pending, and the loser run a `CREATE TABLE` the winner has
|
|
99
|
+
// already committed — `table browsers already exists`.
|
|
100
|
+
//
|
|
101
|
+
// That is not an exotic race here. The service is **spawned per session
|
|
102
|
+
// and exits with it**, so two callers starting at once is the ordinary
|
|
103
|
+
// case, and it is exactly what a fresh install hits: the first two
|
|
104
|
+
// agents to reach for a browser on a machine that has never run this.
|
|
105
|
+
//
|
|
106
|
+
// **Why re-reading inside is sufficient.** `immediate` issues
|
|
107
|
+
// `BEGIN IMMEDIATE`, which declares intent to write at the moment it
|
|
108
|
+
// opens, so the store serialises the two writers itself rather than
|
|
109
|
+
// letting both proceed and discovering the conflict at the end (§1.0a).
|
|
110
|
+
// The loser therefore does not enter this callback until the winner has
|
|
111
|
+
// committed, and the version it reads here is the version the winner
|
|
112
|
+
// stamped. It finds nothing pending and applies nothing.
|
|
113
|
+
//
|
|
114
|
+
// This is the same read-then-write window every arbitration path closes
|
|
115
|
+
// the same way. The stepper is not an arbitration path, but it has the
|
|
116
|
+
// identical shape of problem and takes the identical answer.
|
|
117
|
+
const from = readStoreVersion(db);
|
|
118
|
+
// The winner of the race stamped the expected version while this
|
|
119
|
+
// process waited to enter. §1.2d's promise — a store already at the
|
|
120
|
+
// right version is left untouched — now holds for the first two spawns
|
|
121
|
+
// as well as for every later one.
|
|
122
|
+
if (from === expected) {
|
|
123
|
+
return { value: { from, to: expected, applied: [] } };
|
|
124
|
+
}
|
|
125
|
+
// Re-checked inside as well, and not merely for symmetry: between the
|
|
126
|
+
// read above and this one another process may have stepped the store
|
|
127
|
+
// **past** what this build understands. Refusing here rather than
|
|
128
|
+
// stepping onto a store a newer build owns is the same refusal §7.2
|
|
129
|
+
// makes outside, applied to the version that is actually current.
|
|
130
|
+
if (from > expected) {
|
|
131
|
+
throw new StartupRefusal('startup.schema_stepped', `The store is at schema version ${String(from)} and this build understands version ${String(expected)}. A store newer than the build is refused rather than downgraded: two callers on different builds against one store is ordinary here, and guessing is how one of them corrupts it. Upgrade this installation.`);
|
|
132
|
+
}
|
|
133
|
+
// Derived from the version read **inside** the transaction, so the
|
|
134
|
+
// steps applied are the ones actually still pending rather than the
|
|
135
|
+
// ones that were pending when this process first looked.
|
|
136
|
+
const pending = steps
|
|
137
|
+
.filter((step) => step.version > from)
|
|
138
|
+
.sort((a, b) => a.version - b.version);
|
|
139
|
+
const applied = [];
|
|
140
|
+
for (const step of pending) {
|
|
141
|
+
step.apply(db);
|
|
142
|
+
applied.push(step.version);
|
|
143
|
+
}
|
|
144
|
+
// **The enforcement that was suspended, re-applied as a check before
|
|
145
|
+
// anything is committed.** `foreign_key_check` reports every row whose
|
|
146
|
+
// reference does not resolve, and it is read *inside* the transaction
|
|
147
|
+
// so a step that broke a reference rolls back rather than committing a
|
|
148
|
+
// store whose integrity nothing will re-examine.
|
|
149
|
+
//
|
|
150
|
+
// This is the assertion that makes suspending the pragma safe rather
|
|
151
|
+
// than merely quiet: without it, turning enforcement off would trade a
|
|
152
|
+
// loud failure for a silent one.
|
|
153
|
+
const violations = db.pragma('foreign_key_check');
|
|
154
|
+
if (violations.length > 0) {
|
|
155
|
+
throw new StartupRefusal('startup.schema_stepped', `Stepping the schema from version ${String(from)} to ${String(expected)} left ${String(violations.length)} row(s) naming something that does not exist. The store has been left at version ${String(from)} rather than committed in that state.`);
|
|
156
|
+
}
|
|
157
|
+
// `user_version` takes no parameter binding, and the value is a number
|
|
158
|
+
// this module computed rather than anything a caller supplied.
|
|
159
|
+
db.pragma(`user_version = ${String(expected)}`);
|
|
160
|
+
return { value: { from, to: expected, applied } };
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
// Restored on the way out whatever happened, including a step that threw:
|
|
165
|
+
// a caller that opened the store with references enforced gets a handle
|
|
166
|
+
// with them enforced, and a failed upgrade does not silently leave the
|
|
167
|
+
// connection weaker than the caller asked for.
|
|
168
|
+
if (foreignKeysWereOn) {
|
|
169
|
+
db.pragma('foreign_keys = ON');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { stepOne } from "./step-001-initial.js";
|
|
2
|
+
import { stepEight } from "./step-008-signin-owner.js";
|
|
3
|
+
import { stepFive } from "./step-005-storage-seed-event.js";
|
|
4
|
+
import { stepNine } from "./step-009-named-browsers.js";
|
|
5
|
+
import { stepSix } from "./step-006-signin-events.js";
|
|
6
|
+
import { stepTen } from "./step-010-signin-request.js";
|
|
7
|
+
import { stepSeven } from "./step-007-signin-without-process.js";
|
|
8
|
+
import { stepFour } from "./step-004-tab-never-opened.js";
|
|
9
|
+
import { stepThree } from "./step-003-queue-order.js";
|
|
10
|
+
import { stepTwo } from "./step-002-tab-budget.js";
|
|
11
|
+
export const STEPS = [
|
|
12
|
+
stepOne,
|
|
13
|
+
stepTwo,
|
|
14
|
+
stepThree,
|
|
15
|
+
stepFour,
|
|
16
|
+
stepFive,
|
|
17
|
+
stepSix,
|
|
18
|
+
stepSeven,
|
|
19
|
+
stepEight,
|
|
20
|
+
stepNine,
|
|
21
|
+
stepTen,
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* The version a store must be at for this build to use it.
|
|
25
|
+
*
|
|
26
|
+
* **Stamped, not counted.** A store's version is written into the file and is
|
|
27
|
+
* then compared against by every later build, so it is a fact about what has
|
|
28
|
+
* been applied rather than a fact about this array's shape. Deriving it from
|
|
29
|
+
* the length couples the two: a step deleted or a placeholder appended by
|
|
30
|
+
* somebody who has not read the rule above moves the number every installed
|
|
31
|
+
* store is compared against, and the refusal that protects a store from a
|
|
32
|
+
* build it does not understand starts firing — or stops firing — for a reason
|
|
33
|
+
* nobody wrote down.
|
|
34
|
+
*
|
|
35
|
+
* So the number is written here and the check below is what keeps it honest.
|
|
36
|
+
*/
|
|
37
|
+
export const EXPECTED_VERSION = 10;
|
|
38
|
+
/**
|
|
39
|
+
* The list is consistent with the version above, asserted where the list is
|
|
40
|
+
* defined rather than in a test.
|
|
41
|
+
*
|
|
42
|
+
* A test would prove this on a machine running the tests. This proves it on
|
|
43
|
+
* every spawn, which is the only moment that matters for a store somebody is
|
|
44
|
+
* about to open — and it is the moment the mistake is cheapest to see, because
|
|
45
|
+
* nothing has been written yet.
|
|
46
|
+
*/
|
|
47
|
+
function assertStepsAreWellFormed(steps, expected) {
|
|
48
|
+
steps.forEach((step, index) => {
|
|
49
|
+
if (step.version !== index + 1) {
|
|
50
|
+
throw new Error(`Schema step ${String(index + 1)} declares version ${String(step.version)}. Steps are one-based and contiguous, in order, and a version is never reused.`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const last = steps.at(-1)?.version ?? 0;
|
|
54
|
+
if (last !== expected) {
|
|
55
|
+
throw new Error(`The last schema step is version ${String(last)} and this build expects version ${String(expected)}. A step was added without stamping the version, or the version was stamped without the step.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
assertStepsAreWellFormed(STEPS, EXPECTED_VERSION);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The statement that opens the transaction. Exported so a test can assert the
|
|
3
|
+
* mode by naming the literal rather than by trusting this comment.
|
|
4
|
+
*/
|
|
5
|
+
export const BEGIN_STATEMENT = 'BEGIN IMMEDIATE';
|
|
6
|
+
/**
|
|
7
|
+
* Run `fn` inside an immediate transaction: commit on success, roll back and
|
|
8
|
+
* rethrow on a throw.
|
|
9
|
+
*
|
|
10
|
+
* After-commit actions run once the transaction has ended, and a failure in
|
|
11
|
+
* one does not undo the commit — the work is done and the store says so. They
|
|
12
|
+
* are best effort by design (§2.4b), not by oversight.
|
|
13
|
+
*/
|
|
14
|
+
export async function immediate(db, fn) {
|
|
15
|
+
db.prepare(BEGIN_STATEMENT).run();
|
|
16
|
+
let result;
|
|
17
|
+
try {
|
|
18
|
+
result = await fn({ db });
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
// Rolling back is the point of this branch: the caller must not be able
|
|
22
|
+
// to leave a partial write behind by throwing.
|
|
23
|
+
db.prepare('ROLLBACK').run();
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
db.prepare('COMMIT').run();
|
|
27
|
+
for (const action of result.afterCommit ?? []) {
|
|
28
|
+
try {
|
|
29
|
+
await action();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Best effort, outside the transaction, after the commit. A tab that
|
|
33
|
+
// will not close is not a reason to report failed work that succeeded.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return result.value;
|
|
37
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { TOOLS_BY_NAME, TOOL_OPERATIONS } from "./tools.js";
|
|
2
|
+
/**
|
|
3
|
+
* The tool surface as an adapter: a thin shell over one service call.
|
|
4
|
+
*
|
|
5
|
+
* ── This is the primary route ───────────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* `MILESTONES.md` #27: the service is **spawned by its caller**, serves that
|
|
8
|
+
* session, and exits with it. No port, no daemon, no container. This is the
|
|
9
|
+
* route real callers use, and the command line exists mainly as the second
|
|
10
|
+
* route that makes the parity claim assertable (§5.1).
|
|
11
|
+
*
|
|
12
|
+
* ── What this file may and may not do ───────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* It resolves a tool call into arguments, calls **one** service operation,
|
|
15
|
+
* and shapes the outcome for the wire. It reaches no database and no guard:
|
|
16
|
+
* it is handed a {@link BrokerService} rather than finding one, so there is no
|
|
17
|
+
* store handle in scope to be tempted by. Every rule that decides whether an
|
|
18
|
+
* operation is allowed lives behind that seam, or it holds on this route and
|
|
19
|
+
* not the others (`CLAUDE.md`).
|
|
20
|
+
*
|
|
21
|
+
* **Stated honestly, because the seam cannot enforce it:** nothing structural
|
|
22
|
+
* stops a future edit adding a check here. `db.import_isolated` (§7.3) is a
|
|
23
|
+
* build rule precisely because a type cannot express "and you did not
|
|
24
|
+
* reimplement this". What this file does is keep the correct path the easy
|
|
25
|
+
* one — one call in, one outcome out, no branch between them that could
|
|
26
|
+
* decide anything.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Every operation this route offers — all ten, from the tool table.
|
|
30
|
+
*
|
|
31
|
+
* Read from the table rather than written out again, so the tools a caller
|
|
32
|
+
* can see and the operations the parity suite drives cannot drift.
|
|
33
|
+
*/
|
|
34
|
+
export const TOOL_SURFACE_OPERATIONS = TOOL_OPERATIONS;
|
|
35
|
+
/**
|
|
36
|
+
* Operations this route does not offer, with the reason each is absent.
|
|
37
|
+
*
|
|
38
|
+
* **Empty, and that is the claim.** §3.1 lists twelve tools and §5.3 lists twelve
|
|
39
|
+
* commands for them, so every operation is on both routes. The array exists
|
|
40
|
+
* so that a later row removing a tool has somewhere to write down why — and
|
|
41
|
+
* so the runner's waiver rule has something to check rather than an absence
|
|
42
|
+
* to interpret.
|
|
43
|
+
*
|
|
44
|
+
* Note what a waiver could *not* buy here: this route declares `readOnly:
|
|
45
|
+
* false`, and the runner refuses a write waiver from a route that is not
|
|
46
|
+
* read-only. Declaring it read-only to buy waivers would be the loophole, so
|
|
47
|
+
* the declaration is made honestly.
|
|
48
|
+
*/
|
|
49
|
+
export const TOOL_OPERATION_WAIVERS = [];
|
|
50
|
+
/**
|
|
51
|
+
* The tool-surface adapter.
|
|
52
|
+
*
|
|
53
|
+
* Its `id` is `tool-stdio`, which is also the value §1.6 names for the
|
|
54
|
+
* ledger's `adapter` column — the column that turns "the same rules apply on
|
|
55
|
+
* every route" from a claim into a query. One spelling, used by the registry,
|
|
56
|
+
* the ledger and the conformance report alike.
|
|
57
|
+
*/
|
|
58
|
+
export const toolStdioAdapter = {
|
|
59
|
+
id: 'tool-stdio',
|
|
60
|
+
description: 'The tool surface over standard input and output. Spawned by its caller, serves that session, exits with it.',
|
|
61
|
+
readOnly: false,
|
|
62
|
+
operations: TOOL_SURFACE_OPERATIONS,
|
|
63
|
+
waivers: TOOL_OPERATION_WAIVERS,
|
|
64
|
+
invoke: async (service, operation, input) => {
|
|
65
|
+
// The route's own vocabulary is a tool-call object: a name and an
|
|
66
|
+
// arguments record. Anything else is a caller reaching past the
|
|
67
|
+
// transport, so it is refused rather than coerced.
|
|
68
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
69
|
+
throw new TypeError('the tool-surface adapter takes a tool-call object');
|
|
70
|
+
}
|
|
71
|
+
const call = input;
|
|
72
|
+
if (typeof call.name !== 'string') {
|
|
73
|
+
throw new TypeError('a tool call names the tool it is calling');
|
|
74
|
+
}
|
|
75
|
+
const tool = TOOLS_BY_NAME.get(call.name);
|
|
76
|
+
if (tool === undefined) {
|
|
77
|
+
throw new TypeError(`no tool named "${call.name}"`);
|
|
78
|
+
}
|
|
79
|
+
// A tool maps to exactly one operation, so a call naming a tool whose
|
|
80
|
+
// operation is not the one asked for is a wiring bug in the caller rather
|
|
81
|
+
// than something to reconcile silently.
|
|
82
|
+
if (tool.operation !== operation) {
|
|
83
|
+
throw new TypeError(`${call.name} performs "${tool.operation}", not "${String(operation)}"`);
|
|
84
|
+
}
|
|
85
|
+
const args = call.arguments === null || typeof call.arguments !== 'object' || Array.isArray(call.arguments)
|
|
86
|
+
? {}
|
|
87
|
+
: call.arguments;
|
|
88
|
+
return service.perform({ operation, adapter: 'tool-stdio', arguments: args });
|
|
89
|
+
},
|
|
90
|
+
};
|