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,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where each leased tab actually is, asked of the browsers at generation time
|
|
3
|
+
* (`MILESTONES.md` #70, `SCHEMA.md` §4.2a).
|
|
4
|
+
*
|
|
5
|
+
* ── Why this is a live read and why no column caches it ─────────────────
|
|
6
|
+
*
|
|
7
|
+
* §1.4: **there is no column recording where a tab is**, and its absence is
|
|
8
|
+
* "the single largest privacy improvement in this design". A table of
|
|
9
|
+
* addresses kept over months is a browsing history — with a retention setting
|
|
10
|
+
* to get wrong and a clear-history command to build. §4.2a completes the
|
|
11
|
+
* argument from the other side: the process generating this document **is
|
|
12
|
+
* attached to both browsers**, so the one moment anybody wants the answer is
|
|
13
|
+
* a moment when the source is right there. Asking is strictly better than
|
|
14
|
+
* storing, and it leaves nothing behind.
|
|
15
|
+
*
|
|
16
|
+
* **Do not reintroduce a stored address column.** Nothing in this module
|
|
17
|
+
* writes, and there is no store handle in scope to write with.
|
|
18
|
+
*
|
|
19
|
+
* ── The two rules, and neither is optional ──────────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* §4.2a states them as requirements rather than suggestions:
|
|
22
|
+
*
|
|
23
|
+
* 1. **Every read carries a timeout.** A browser can hang — it accepts the
|
|
24
|
+
* request and never answers (§2.4b) — and a generator that inherits that
|
|
25
|
+
* hang produces nothing at all, which is worse than an incomplete
|
|
26
|
+
* document. The timeout is **per tab**, so one wedged page costs one entry
|
|
27
|
+
* rather than the whole run.
|
|
28
|
+
* 2. **A browser that does not answer renders as an explicit word.** Not
|
|
29
|
+
* blank, not omitted, not a placeholder address. "A missing address and an
|
|
30
|
+
* unanswered one are different facts, and the second is the one that
|
|
31
|
+
* indicates something wrong."
|
|
32
|
+
*
|
|
33
|
+
* The timeout is **not defaultable to absent**: {@link readAddresses} takes
|
|
34
|
+
* it as a required field on a required options object, so a caller cannot
|
|
35
|
+
* omit it and get an unbounded read. That is as far as a type can carry the
|
|
36
|
+
* rule; what it cannot prevent is somebody passing a number so large it is a
|
|
37
|
+
* timeout in name only, and saying so is better than implying otherwise.
|
|
38
|
+
*
|
|
39
|
+
* ── This is outside every transaction, and that is structural here ──────
|
|
40
|
+
*
|
|
41
|
+
* §2.4b: browser work never happens inside the arbitration transaction. This
|
|
42
|
+
* module is reachable only from the document generator, which runs it after
|
|
43
|
+
* the status read has returned and closed. Nothing in this file imports a
|
|
44
|
+
* store handle or a transaction, so there is no transaction in scope to be
|
|
45
|
+
* inside of.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* The word an unanswered read renders as (§4.2a).
|
|
49
|
+
*
|
|
50
|
+
* A constant rather than a literal at each use, so the document, the tests
|
|
51
|
+
* and any later reader all name the same string — and so the mutation that
|
|
52
|
+
* removes it has exactly one place to be removed from and is caught.
|
|
53
|
+
*/
|
|
54
|
+
export const UNREACHABLE = 'unreachable';
|
|
55
|
+
/** The default timer, wrapped so the injected one and the real one are one shape. */
|
|
56
|
+
function realTimer(fn, ms) {
|
|
57
|
+
const handle = setTimeout(fn, ms);
|
|
58
|
+
return {
|
|
59
|
+
cancel: () => {
|
|
60
|
+
clearTimeout(handle);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Race one read against the bound.
|
|
66
|
+
*
|
|
67
|
+
* `Promise.race` rather than an abort signal, because the seam's operations
|
|
68
|
+
* do not take one and inventing a parallel cancellation protocol here would
|
|
69
|
+
* be a change to somebody else's interface made from the outside. **The
|
|
70
|
+
* consequence is stated rather than hidden:** the underlying read is not
|
|
71
|
+
* cancelled, it is abandoned. A hung browser keeps whatever it was doing;
|
|
72
|
+
* this process stops waiting for it, which is the property the rule actually
|
|
73
|
+
* asks for — the generator must not inherit the hang. The abandoned promise's
|
|
74
|
+
* eventual rejection is swallowed so it cannot surface as an unhandled
|
|
75
|
+
* rejection long after the document was written.
|
|
76
|
+
*/
|
|
77
|
+
async function withTimeout(work, timeoutMs, setTimer) {
|
|
78
|
+
let timer;
|
|
79
|
+
const timeout = new Promise((resolve) => {
|
|
80
|
+
timer = setTimer(() => {
|
|
81
|
+
resolve({ ok: false, reason: `no answer within ${String(timeoutMs)}ms` });
|
|
82
|
+
}, timeoutMs);
|
|
83
|
+
});
|
|
84
|
+
const answered = work.then((value) => ({ ok: true, value }), (error) => ({
|
|
85
|
+
ok: false,
|
|
86
|
+
reason: error instanceof Error ? error.message : 'the read failed',
|
|
87
|
+
}));
|
|
88
|
+
try {
|
|
89
|
+
return await Promise.race([answered, timeout]);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
timer?.cancel();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Read every requested tab's address, each under its own timeout.
|
|
97
|
+
*
|
|
98
|
+
* **Sequential rather than concurrent**, and the reason is the bound: run in
|
|
99
|
+
* parallel, one wedged tab still costs only its own timeout, but so does a
|
|
100
|
+
* browser that is merely slow for all of them at once, and the document's
|
|
101
|
+
* total generation time stops being predictable from the number of tabs. At a
|
|
102
|
+
* tab budget of fifteen (§6.2) the worst case is fifteen timeouts, which is a
|
|
103
|
+
* bounded and explainable wait. If that ever becomes the wrong trade, it is a
|
|
104
|
+
* change here and nowhere else.
|
|
105
|
+
*
|
|
106
|
+
* **Never throws.** Every failure becomes an `unreachable` entry, because the
|
|
107
|
+
* one outcome §4.2a rules out is producing nothing at all.
|
|
108
|
+
*/
|
|
109
|
+
export async function readAddresses(source, requests, options) {
|
|
110
|
+
const setTimer = options.setTimer ?? realTimer;
|
|
111
|
+
const addresses = new Map();
|
|
112
|
+
for (const request of requests) {
|
|
113
|
+
const result = await withTimeout(
|
|
114
|
+
// Invoked inside a promise rather than called directly, because a
|
|
115
|
+
// source that throws **synchronously** — before any promise exists —
|
|
116
|
+
// would otherwise escape this loop entirely and the run would produce
|
|
117
|
+
// no document at all. That is the one outcome §4.2a rules out, and a
|
|
118
|
+
// synchronous throw is an ordinary way for a driver to report a handle
|
|
119
|
+
// it does not recognise.
|
|
120
|
+
(async () => source.addressOf(request.handle))(), options.timeoutMs, setTimer);
|
|
121
|
+
addresses.set(request.tabId, result.ok
|
|
122
|
+
? { kind: 'address', url: result.value.url, title: result.value.title }
|
|
123
|
+
: { kind: 'unreachable', reason: result.reason });
|
|
124
|
+
}
|
|
125
|
+
return addresses;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* What the document prints for one tab.
|
|
129
|
+
*
|
|
130
|
+
* The single place the three outcomes become text, so the rule "never blank,
|
|
131
|
+
* never omitted, never a placeholder" is enforced at one point rather than at
|
|
132
|
+
* each use. A tab with no entry at all — which is what happens when the
|
|
133
|
+
* generator could not reach a browser to ask in the first place — renders as
|
|
134
|
+
* `unreachable` too: an address that was never obtained is not an address,
|
|
135
|
+
* and the absence of an entry is exactly the "omitted" case §4.2a forbids.
|
|
136
|
+
*/
|
|
137
|
+
export function renderAddress(address) {
|
|
138
|
+
if (address === undefined) {
|
|
139
|
+
return UNREACHABLE;
|
|
140
|
+
}
|
|
141
|
+
switch (address.kind) {
|
|
142
|
+
case 'address':
|
|
143
|
+
return address.url;
|
|
144
|
+
case 'unreachable':
|
|
145
|
+
return UNREACHABLE;
|
|
146
|
+
case 'none':
|
|
147
|
+
// A lease with no tab. Not an address and not a failure — the word is
|
|
148
|
+
// about the lease's state, and the document's queue section already
|
|
149
|
+
// says why there is no page.
|
|
150
|
+
return 'no tab';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Turn a browser session into an {@link AddressSource}.
|
|
155
|
+
*
|
|
156
|
+
* Kept here rather than in the generator so that the one expression evaluated
|
|
157
|
+
* against a page in order to build this document lives beside the rule that
|
|
158
|
+
* bounds it. It reads two properties and nothing else.
|
|
159
|
+
*/
|
|
160
|
+
export function addressSourceFromSession(session) {
|
|
161
|
+
return {
|
|
162
|
+
addressOf: async (tab) => {
|
|
163
|
+
const result = await session.evaluate(tab, '({ url: document.location.href, title: document.title })');
|
|
164
|
+
const value = result.value;
|
|
165
|
+
if (value === null || typeof value !== 'object') {
|
|
166
|
+
throw new Error('the page did not report an address');
|
|
167
|
+
}
|
|
168
|
+
const record = value;
|
|
169
|
+
return {
|
|
170
|
+
url: typeof record.url === 'string' ? record.url : '',
|
|
171
|
+
title: typeof record.title === 'string' ? record.title : '',
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The expiry derivation, on its own, so that everything reporting state
|
|
3
|
+
* applies the same one.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this is a module and not three lines inlined at each reader ──────
|
|
6
|
+
*
|
|
7
|
+
* `SCHEMA.md` §2.4 states the standing rule this whole file exists to serve:
|
|
8
|
+
*
|
|
9
|
+
* > **Stored state is provisional, derived state is the truth.**
|
|
10
|
+
*
|
|
11
|
+
* Nothing expires on a timer, because there is nothing running to hold one
|
|
12
|
+
* (§1.0a). A row saying `active` whose expiry has elapsed is **not** an
|
|
13
|
+
* active lease — it is a lease that lapsed and has not been swept yet, and
|
|
14
|
+
* **the difference is invisible in the row.** So a reader that renders
|
|
15
|
+
* `claims.state` reports leases that do not exist, and does it *most* on the
|
|
16
|
+
* busiest installation, where the gap between lapsing and being noticed is
|
|
17
|
+
* exactly the interval somebody is looking at the document.
|
|
18
|
+
*
|
|
19
|
+
* §2.4 names this as "the rule most likely to be broken by a well-meaning
|
|
20
|
+
* addition, because reading a column is the obvious thing to do and it is
|
|
21
|
+
* wrong here", and §4.5 binds the generated operations document to it
|
|
22
|
+
* explicitly: it "is a reader like any other".
|
|
23
|
+
*
|
|
24
|
+
* Keeping the derivation in one named function is what makes that rule
|
|
25
|
+
* **checkable** rather than merely written down: a reader either calls
|
|
26
|
+
* {@link deriveClaimState} or it does not, and a test can delete this
|
|
27
|
+
* function's comparison and watch a rendering test fail.
|
|
28
|
+
*
|
|
29
|
+
* ── What this does not do, said plainly ─────────────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* **It does not sweep, and it writes nothing.** Sweeping is the arbitration
|
|
32
|
+
* transaction's job (§2.4) and belongs to the service layer. This is the
|
|
33
|
+
* read-side half: given a row and an instant, say what is *true* about that
|
|
34
|
+
* row at that instant. The two must agree, and they agree because they apply
|
|
35
|
+
* the same comparison — but a document generator must never be the thing that
|
|
36
|
+
* expires somebody's lease as a side effect of being looked at.
|
|
37
|
+
*
|
|
38
|
+
* **It cannot stop a reader going around it.** Nothing in a type signature
|
|
39
|
+
* prevents a module selecting `state` and printing it. What this buys is that
|
|
40
|
+
* the correct path is one import away and named for what it does, and that
|
|
41
|
+
* the mutation tests over the document renderer have a single line to kill.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* Comparing two of the store's timestamps.
|
|
45
|
+
*
|
|
46
|
+
* `SCHEMA.md` §1.1: time is "stored in a single fixed textual form that sorts
|
|
47
|
+
* in chronological order, so a comparison is a comparison and needs no
|
|
48
|
+
* conversion". So this is a string comparison **on purpose** — parsing to a
|
|
49
|
+
* date here would introduce a second interpretation of the same column, and a
|
|
50
|
+
* time zone for it to be wrong in.
|
|
51
|
+
*/
|
|
52
|
+
function hasElapsed(expiresAt, at) {
|
|
53
|
+
return expiresAt <= at;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* What a lease's state actually is at instant `at`.
|
|
57
|
+
*
|
|
58
|
+
* **A final state is final** (§2.1): released, expired and revoked are
|
|
59
|
+
* outcomes that already happened, and no passage of time changes one. Only
|
|
60
|
+
* the two live states — `queued` and `active` — can have lapsed without
|
|
61
|
+
* anybody noticing.
|
|
62
|
+
*
|
|
63
|
+
* The boundary is **inclusive**: a lease whose expiry is exactly now has
|
|
64
|
+
* elapsed. That matches the sweep, which selects the live rows at or past
|
|
65
|
+
* their expiry, and the two must not disagree by one instant — a document
|
|
66
|
+
* saying `active` for a lease the very next call will expire is the failure
|
|
67
|
+
* this module exists to prevent, in miniature.
|
|
68
|
+
*/
|
|
69
|
+
export function deriveClaimState(claim, at) {
|
|
70
|
+
if (claim.state !== 'queued' && claim.state !== 'active') {
|
|
71
|
+
return claim.state;
|
|
72
|
+
}
|
|
73
|
+
return hasElapsed(claim.expires_at, at) ? 'expired' : claim.state;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Whether a lease is live at instant `at` — holding a tab, or holding a
|
|
77
|
+
* place in the queue.
|
|
78
|
+
*
|
|
79
|
+
* This is the predicate the budget count is over, and it is derived for the
|
|
80
|
+
* same reason everything else here is: counting rows whose stored state is
|
|
81
|
+
* live would count leases that have lapsed, and would report an installation
|
|
82
|
+
* as fuller than it is.
|
|
83
|
+
*/
|
|
84
|
+
export function isLive(claim, at) {
|
|
85
|
+
const derived = deriveClaimState(claim, at);
|
|
86
|
+
return derived === 'queued' || derived === 'active';
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Whole seconds between two of the store's timestamps, `later - earlier`.
|
|
90
|
+
*
|
|
91
|
+
* Negative when `later` is before `earlier`, which is not guarded against:
|
|
92
|
+
* the callers here are reporting how long something has been waiting or how
|
|
93
|
+
* long until it lapses, and a negative number is the honest answer to "how
|
|
94
|
+
* long until an expiry that has already passed". Clamping it would hide
|
|
95
|
+
* exactly the case §2.4 is about.
|
|
96
|
+
*
|
|
97
|
+
* Parsing is safe here in a way it is not in {@link hasElapsed}, because the
|
|
98
|
+
* question is arithmetic rather than ordering — but the values still come
|
|
99
|
+
* from one clock (the database's own, §1.1), so the subtraction is between
|
|
100
|
+
* two readings of the same clock and not across two machines'.
|
|
101
|
+
*/
|
|
102
|
+
export function secondsBetween(earlier, later) {
|
|
103
|
+
const from = Date.parse(earlier);
|
|
104
|
+
const to = Date.parse(later);
|
|
105
|
+
if (Number.isNaN(from) || Number.isNaN(to)) {
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
return Math.round((to - from) / 1000);
|
|
109
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A view of the ledger (`MILESTONES.md` #47, `SCHEMA.md` §1.6).
|
|
3
|
+
*
|
|
4
|
+
* **One query.** §1.6: "The ledger is one stream with a cursor, so a page
|
|
5
|
+
* over it is a page over one query" — and the table's shape is what keeps it
|
|
6
|
+
* that way. A column per kind would be a wide, mostly-empty table; a table
|
|
7
|
+
* per kind would turn every read into a fifteen-way union. So a slice is a
|
|
8
|
+
* `WHERE` over one table, and this module's whole job is to build that
|
|
9
|
+
* `WHERE` without ever building it out of caller-supplied SQL.
|
|
10
|
+
*
|
|
11
|
+
* ── The cursor is not invented here ─────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* §1.6: `events.id` is "**also** the 'everything since here' cursor for
|
|
14
|
+
* anything reading a slice of the ledger". It counts upward, so a reader that
|
|
15
|
+
* remembers the highest id it has seen can ask for everything after it, and
|
|
16
|
+
* the answer is stable regardless of what has been written in between. There
|
|
17
|
+
* is no separate cursor table, no opaque token to encode, and no offset —
|
|
18
|
+
* an offset re-reads rows when the earlier ones have grown, which is the
|
|
19
|
+
* failure a counter key exists to avoid.
|
|
20
|
+
*
|
|
21
|
+
* ── Read-only, and it does not sweep ────────────────────────────────────
|
|
22
|
+
*
|
|
23
|
+
* §5.2 records that "any command that goes through arbitration performs the
|
|
24
|
+
* lazy sweep", which is correct and surprising the first time a listing
|
|
25
|
+
* command closes somebody's tabs. **Reading the ledger is not an arbitration
|
|
26
|
+
* path**: it decides nothing, grants nothing and refuses nothing, so it
|
|
27
|
+
* neither needs the reconciled state nor is entitled to expire anybody's
|
|
28
|
+
* lease. It is a plain read over history — and history is the one thing in
|
|
29
|
+
* this store that is already settled.
|
|
30
|
+
*
|
|
31
|
+
* That is a narrower claim than it may look, and it is worth stating: the
|
|
32
|
+
* *ledger* needs no derivation because every row in it records a decision
|
|
33
|
+
* that was made at a moment that has passed. The **claims** do need one, and
|
|
34
|
+
* that is `derive.ts`'s job. Do not read this file as a precedent for
|
|
35
|
+
* reading `claims.state` directly.
|
|
36
|
+
*/
|
|
37
|
+
/** The event kinds, exactly as the schema's check constraint spells them (§1.6). */
|
|
38
|
+
export const EVENT_KINDS = [
|
|
39
|
+
'claim_requested',
|
|
40
|
+
'claim_granted',
|
|
41
|
+
'claim_queued',
|
|
42
|
+
'claim_promoted',
|
|
43
|
+
'claim_renewed',
|
|
44
|
+
'claim_released',
|
|
45
|
+
'claim_expired',
|
|
46
|
+
'claim_revoked',
|
|
47
|
+
'tab_opening',
|
|
48
|
+
'tab_open_failed',
|
|
49
|
+
'tab_closing',
|
|
50
|
+
'navigate',
|
|
51
|
+
'act',
|
|
52
|
+
'read',
|
|
53
|
+
'evaluate',
|
|
54
|
+
'capture',
|
|
55
|
+
'compare',
|
|
56
|
+
'browser_launched',
|
|
57
|
+
'browser_adopted',
|
|
58
|
+
'browser_exited',
|
|
59
|
+
'launch_race_lost',
|
|
60
|
+
'sweep',
|
|
61
|
+
];
|
|
62
|
+
/** `allow` or `deny`, separate from the kind (§1.6). */
|
|
63
|
+
export const EVENT_OUTCOMES = ['allow', 'deny'];
|
|
64
|
+
/** The default page. Small enough to read, large enough to be a page. */
|
|
65
|
+
export const DEFAULT_LEDGER_LIMIT = 20;
|
|
66
|
+
/**
|
|
67
|
+
* The most rows one call will return.
|
|
68
|
+
*
|
|
69
|
+
* A bound rather than an unbounded read because the ledger is kept forever by
|
|
70
|
+
* default (§6.2) and one of its two callers is assembling a self-contained
|
|
71
|
+
* HTML file — a document that inlined a million rows would be unopenable, and
|
|
72
|
+
* the failure would arrive long after the decision that caused it.
|
|
73
|
+
*/
|
|
74
|
+
export const MAXIMUM_LEDGER_LIMIT = 500;
|
|
75
|
+
/**
|
|
76
|
+
* Build the `WHERE` clause.
|
|
77
|
+
*
|
|
78
|
+
* **Every caller value is a bound parameter and none is interpolated.** The
|
|
79
|
+
* only strings that reach the SQL text are column names written in this file.
|
|
80
|
+
* That is not a general-purpose defence so much as a structural one: there is
|
|
81
|
+
* no code path here that concatenates a caller's string into a statement, so
|
|
82
|
+
* there is nothing to review for whether a particular value was escaped.
|
|
83
|
+
*/
|
|
84
|
+
function buildConditions(query) {
|
|
85
|
+
const clauses = [];
|
|
86
|
+
const parameters = [];
|
|
87
|
+
const kinds = query.kinds?.filter((kind) => kind !== '') ?? [];
|
|
88
|
+
if (kinds.length > 0) {
|
|
89
|
+
clauses.push(`kind IN (${kinds.map(() => '?').join(', ')})`);
|
|
90
|
+
parameters.push(...kinds);
|
|
91
|
+
}
|
|
92
|
+
if (query.outcome !== undefined) {
|
|
93
|
+
clauses.push('outcome = ?');
|
|
94
|
+
parameters.push(query.outcome);
|
|
95
|
+
}
|
|
96
|
+
if (query.guard !== undefined) {
|
|
97
|
+
clauses.push('guard = ?');
|
|
98
|
+
parameters.push(query.guard);
|
|
99
|
+
}
|
|
100
|
+
if (query.sessionId !== undefined) {
|
|
101
|
+
clauses.push('session_id = ?');
|
|
102
|
+
parameters.push(query.sessionId);
|
|
103
|
+
}
|
|
104
|
+
if (query.claimId !== undefined) {
|
|
105
|
+
clauses.push('claim_id = ?');
|
|
106
|
+
parameters.push(query.claimId);
|
|
107
|
+
}
|
|
108
|
+
if (query.since !== undefined) {
|
|
109
|
+
clauses.push('id > ?');
|
|
110
|
+
parameters.push(query.since);
|
|
111
|
+
}
|
|
112
|
+
if (query.before !== undefined) {
|
|
113
|
+
clauses.push('id < ?');
|
|
114
|
+
parameters.push(query.before);
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
sql: clauses.length === 0 ? '' : ` WHERE ${clauses.join(' AND ')}`,
|
|
118
|
+
parameters,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function toEntry(row) {
|
|
122
|
+
return {
|
|
123
|
+
id: row.id,
|
|
124
|
+
at: row.at,
|
|
125
|
+
kind: row.kind,
|
|
126
|
+
outcome: row.outcome,
|
|
127
|
+
guard: row.guard,
|
|
128
|
+
claimId: row.claim_id,
|
|
129
|
+
tabId: row.tab_id,
|
|
130
|
+
sessionId: row.session_id,
|
|
131
|
+
adapter: row.adapter,
|
|
132
|
+
browserId: row.browser_id,
|
|
133
|
+
detail: row.detail,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Clamp the page size.
|
|
138
|
+
*
|
|
139
|
+
* A limit below one is a caller asking for nothing, which is almost always a
|
|
140
|
+
* mistake in arithmetic rather than an intention, so it becomes the default
|
|
141
|
+
* rather than an empty page that looks like an empty ledger. Above the
|
|
142
|
+
* maximum it is capped rather than refused: a slice is a convenience read and
|
|
143
|
+
* failing a report over a page size would be a poor trade.
|
|
144
|
+
*/
|
|
145
|
+
export function clampLimit(limit) {
|
|
146
|
+
if (limit === undefined || !Number.isFinite(limit) || limit < 1) {
|
|
147
|
+
return DEFAULT_LEDGER_LIMIT;
|
|
148
|
+
}
|
|
149
|
+
return Math.min(Math.floor(limit), MAXIMUM_LEDGER_LIMIT);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Read a slice of the ledger.
|
|
153
|
+
*
|
|
154
|
+
* Two statements rather than one, and the second is the count. That is a
|
|
155
|
+
* deliberate departure from "one query" in the narrow sense and not in the
|
|
156
|
+
* sense §1.6 means it: the *slice* is one query over one table with no joins
|
|
157
|
+
* and no union, which is the property the table's shape was designed to buy.
|
|
158
|
+
* The count answers "how many matched" — which a page of twenty cannot say
|
|
159
|
+
* about a filter over a ledger kept forever — and it runs over the same
|
|
160
|
+
* predicate and the same indexes.
|
|
161
|
+
*/
|
|
162
|
+
export function readLedger(db, query = {}) {
|
|
163
|
+
const conditions = buildConditions(query);
|
|
164
|
+
const limit = clampLimit(query.limit);
|
|
165
|
+
const direction = query.order === 'oldest' ? 'ASC' : 'DESC';
|
|
166
|
+
const rows = db
|
|
167
|
+
.prepare(`SELECT id, at, kind, outcome, guard, claim_id, tab_id, session_id, adapter, browser_id, detail
|
|
168
|
+
FROM events${conditions.sql}
|
|
169
|
+
ORDER BY id ${direction}
|
|
170
|
+
LIMIT ?`)
|
|
171
|
+
.all(...conditions.parameters, limit);
|
|
172
|
+
const counted = db
|
|
173
|
+
.prepare(`SELECT COUNT(*) AS total FROM events${conditions.sql}`)
|
|
174
|
+
.get(...conditions.parameters);
|
|
175
|
+
const entries = rows.map(toEntry);
|
|
176
|
+
const cursor = entries.reduce((highest, entry) => (highest === null || entry.id > highest ? entry.id : highest), null);
|
|
177
|
+
return { entries, cursor, total: counted?.total ?? 0 };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* How often each rule has refused something.
|
|
181
|
+
*
|
|
182
|
+
* §1.6: "the question that *is* asked — 'has this rule ever fired' — is
|
|
183
|
+
* answered by the refusals", and the partial index on `guard` exists for
|
|
184
|
+
* exactly this read. Denials are rare, so the index is small and this is
|
|
185
|
+
* cheap; that is why the ledger records allows and denials alike rather than
|
|
186
|
+
* only the interesting half.
|
|
187
|
+
*/
|
|
188
|
+
export function countByGuard(db, query = {}) {
|
|
189
|
+
const conditions = buildConditions({ ...query, guard: undefined });
|
|
190
|
+
const where = conditions.sql === '' ? ' WHERE guard IS NOT NULL' : `${conditions.sql} AND guard IS NOT NULL`;
|
|
191
|
+
return db
|
|
192
|
+
.prepare(`SELECT guard, COUNT(*) AS count FROM events${where} GROUP BY guard ORDER BY count DESC, guard ASC`)
|
|
193
|
+
.all(...conditions.parameters);
|
|
194
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { deriveClaimState, isLive, secondsBetween } from "./derive.js";
|
|
2
|
+
import { countByGuard, readLedger } from "./ledger.js";
|
|
3
|
+
/**
|
|
4
|
+
* Read the database's own clock in the same textual form every timestamp
|
|
5
|
+
* column defaults to.
|
|
6
|
+
*
|
|
7
|
+
* The format string is repeated from the schema step rather than imported
|
|
8
|
+
* from it, and that is a considered choice: the step is **history the moment
|
|
9
|
+
* it has run anywhere** and must never be edited, so importing a constant out
|
|
10
|
+
* of it would make a file that may not change a dependency of one that will.
|
|
11
|
+
* A test asserts the two agree, which is the check that keeps a repetition
|
|
12
|
+
* honest.
|
|
13
|
+
*/
|
|
14
|
+
export const STORE_CLOCK_SQL = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
|
|
15
|
+
export function readStoreClock(db) {
|
|
16
|
+
const row = db.prepare(`SELECT ${STORE_CLOCK_SQL} AS now`).get();
|
|
17
|
+
if (row === undefined) {
|
|
18
|
+
throw new Error('The store returned no answer when asked for its own clock.');
|
|
19
|
+
}
|
|
20
|
+
return row.now;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Read the recorded tab budget, or null when nothing has recorded one.
|
|
24
|
+
*
|
|
25
|
+
* The row §1.10 describes is written by the first process to open the store —
|
|
26
|
+
* `agreeOnTabBudget`, on the spawn path — into the `tab_budget` table that
|
|
27
|
+
* `schema/step-002-tab-budget.ts` owns. Null means no process has opened this
|
|
28
|
+
* store yet, which the document renders as "not recorded".
|
|
29
|
+
*
|
|
30
|
+
* ── Why this names `tab_budget` and probes for nothing ──────────────────
|
|
31
|
+
*
|
|
32
|
+
* §1.10 **deletes** the settings table rather than giving the budget a key in
|
|
33
|
+
* one, so `tab_budget` is the only place the value lives and this is a direct
|
|
34
|
+
* read of it. A read pointed anywhere else is worse than wrong: it returns
|
|
35
|
+
* null, which this function's own contract renders as "nothing has recorded a
|
|
36
|
+
* budget", and that is a plausible sentence about a healthy store. It would
|
|
37
|
+
* silence two things without either looking broken — `broker doctor`'s
|
|
38
|
+
* `config.tab_budget_agrees` would answer `unknown` forever, so **the one
|
|
39
|
+
* check that exists to catch a budget disagreement could never catch one**,
|
|
40
|
+
* and `status.limit` below would report null instead of the ceiling in force.
|
|
41
|
+
*
|
|
42
|
+
* **The table's absence is deliberately not tolerated.** `tab_budget` arrives
|
|
43
|
+
* in `schema/step-002-tab-budget.ts` and every caller here holds a stepped
|
|
44
|
+
* store, so a missing table is a broken installation and this raises rather
|
|
45
|
+
* than answering null about it — an existence probe here converts that fault
|
|
46
|
+
* into the same believable "not recorded". An **empty** table does read as
|
|
47
|
+
* null, because a store nothing has opened yet is an ordinary state.
|
|
48
|
+
*
|
|
49
|
+
* A test for this has to let the product create the table. One that creates a
|
|
50
|
+
* budget table itself is a fixture seeding a state the product cannot reach,
|
|
51
|
+
* and it passes whether or not this read names the right one.
|
|
52
|
+
*/
|
|
53
|
+
export function readTabBudget(db) {
|
|
54
|
+
const row = db.prepare(`SELECT tabs FROM tab_budget WHERE only_row = 1`).get();
|
|
55
|
+
if (row === undefined) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const value = Number(row.tabs);
|
|
59
|
+
return Number.isFinite(value) ? value : null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Take the picture.
|
|
63
|
+
*
|
|
64
|
+
* Every claim-derived number in it goes through `derive.ts`. There is no
|
|
65
|
+
* branch in this file that reads `claims.state` and reports it, and that is
|
|
66
|
+
* the property the mutation tests are aimed at.
|
|
67
|
+
*/
|
|
68
|
+
export function readOperationsStatus(db, options = {}) {
|
|
69
|
+
const at = options.now ?? readStoreClock(db);
|
|
70
|
+
const browserRows = db
|
|
71
|
+
.prepare(`SELECT id, state, restart_count, pid, launched_at, endpoint, browser_uuid
|
|
72
|
+
FROM browsers ORDER BY id`)
|
|
73
|
+
.all();
|
|
74
|
+
// Every claim whose *stored* state is live. A claim whose stored state is
|
|
75
|
+
// final cannot become live again, so this is the complete candidate set —
|
|
76
|
+
// the derivation can only move a row out of the live set, never into it.
|
|
77
|
+
const claimRows = db
|
|
78
|
+
.prepare(`SELECT c.id, c.session_id, c.browser_id, c.state, c.purpose, c.expires_at,
|
|
79
|
+
c.renew_count, c.created_at, c.activated_at,
|
|
80
|
+
(SELECT t.id FROM tabs t
|
|
81
|
+
WHERE t.claim_id = c.id AND t.state IN ('opening', 'open', 'closing')
|
|
82
|
+
ORDER BY t.created_at LIMIT 1) AS tab_id
|
|
83
|
+
FROM claims c
|
|
84
|
+
WHERE c.state IN ('queued', 'active')
|
|
85
|
+
ORDER BY c.created_at, c.id`)
|
|
86
|
+
.all();
|
|
87
|
+
const live = claimRows.filter((claim) => isLive(claim, at));
|
|
88
|
+
const leases = live
|
|
89
|
+
.filter((claim) => deriveClaimState(claim, at) === 'active')
|
|
90
|
+
.map((claim) => ({
|
|
91
|
+
claimId: claim.id,
|
|
92
|
+
sessionId: claim.session_id,
|
|
93
|
+
browserId: claim.browser_id,
|
|
94
|
+
purpose: claim.purpose,
|
|
95
|
+
state: deriveClaimState(claim, at),
|
|
96
|
+
expiresAt: claim.expires_at,
|
|
97
|
+
secondsUntilExpiry: secondsBetween(at, claim.expires_at),
|
|
98
|
+
renewCount: claim.renew_count,
|
|
99
|
+
createdAt: claim.created_at,
|
|
100
|
+
activatedAt: claim.activated_at,
|
|
101
|
+
tabId: claim.tab_id,
|
|
102
|
+
}));
|
|
103
|
+
const sessions = groupBySession(leases);
|
|
104
|
+
// Ordered by created_at then id, which is §1.5's ordering exactly. The
|
|
105
|
+
// SELECT above already applies it, so position is the index in that order
|
|
106
|
+
// among the entries still queued once derived.
|
|
107
|
+
const queue = live
|
|
108
|
+
.filter((claim) => deriveClaimState(claim, at) === 'queued')
|
|
109
|
+
.map((claim, index) => ({
|
|
110
|
+
claimId: claim.id,
|
|
111
|
+
sessionId: claim.session_id,
|
|
112
|
+
browserId: claim.browser_id,
|
|
113
|
+
purpose: claim.purpose,
|
|
114
|
+
position: index + 1,
|
|
115
|
+
waitedSeconds: secondsBetween(claim.created_at, at),
|
|
116
|
+
expiresAt: claim.expires_at,
|
|
117
|
+
secondsUntilExpiry: secondsBetween(at, claim.expires_at),
|
|
118
|
+
}));
|
|
119
|
+
const liveTabsByBrowser = new Map();
|
|
120
|
+
for (const lease of leases) {
|
|
121
|
+
if (lease.tabId !== null) {
|
|
122
|
+
liveTabsByBrowser.set(lease.browserId, (liveTabsByBrowser.get(lease.browserId) ?? 0) + 1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const browsers = browserRows.map((row) => ({
|
|
126
|
+
id: row.id,
|
|
127
|
+
state: row.state,
|
|
128
|
+
restartCount: row.restart_count,
|
|
129
|
+
pid: row.pid,
|
|
130
|
+
launchedAt: row.launched_at,
|
|
131
|
+
discoveryRecorded: row.endpoint !== null,
|
|
132
|
+
identityRecorded: row.browser_uuid !== null,
|
|
133
|
+
liveTabs: liveTabsByBrowser.get(row.id) ?? 0,
|
|
134
|
+
}));
|
|
135
|
+
const leakedRows = db
|
|
136
|
+
.prepare(`SELECT id, browser_id, claim_id, state, close_attempts, updated_at
|
|
137
|
+
FROM tabs WHERE close_failed = 1 ORDER BY updated_at DESC`)
|
|
138
|
+
.all();
|
|
139
|
+
const feedbackRows = db
|
|
140
|
+
.prepare(`SELECT id, at, session_id, rating, category, note, last_guard
|
|
141
|
+
FROM feedback ORDER BY at DESC, id DESC LIMIT ?`)
|
|
142
|
+
.all(options.feedbackLimit ?? 10);
|
|
143
|
+
return {
|
|
144
|
+
at,
|
|
145
|
+
browsers,
|
|
146
|
+
budget: {
|
|
147
|
+
limit: readTabBudget(db),
|
|
148
|
+
used: live.length,
|
|
149
|
+
active: leases.length,
|
|
150
|
+
queued: queue.length,
|
|
151
|
+
keeperTabsExpected: browserRows.length,
|
|
152
|
+
},
|
|
153
|
+
sessions,
|
|
154
|
+
queue,
|
|
155
|
+
leakedTabs: leakedRows.map((row) => ({
|
|
156
|
+
tabId: row.id,
|
|
157
|
+
browserId: row.browser_id,
|
|
158
|
+
claimId: row.claim_id,
|
|
159
|
+
state: row.state,
|
|
160
|
+
closeAttempts: row.close_attempts,
|
|
161
|
+
updatedAt: row.updated_at,
|
|
162
|
+
})),
|
|
163
|
+
recentEvents: readLedger(db, { limit: options.eventLimit ?? 20 }).entries,
|
|
164
|
+
refusalsByGuard: countByGuard(db),
|
|
165
|
+
feedback: feedbackRows.map((row) => ({
|
|
166
|
+
id: row.id,
|
|
167
|
+
at: row.at,
|
|
168
|
+
sessionId: row.session_id,
|
|
169
|
+
rating: row.rating,
|
|
170
|
+
category: row.category,
|
|
171
|
+
note: row.note,
|
|
172
|
+
lastGuard: row.last_guard,
|
|
173
|
+
})),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Group live leases by session, "so one caller holding several reads as one
|
|
178
|
+
* caller" (§4.2).
|
|
179
|
+
*
|
|
180
|
+
* Insertion order is preserved, which means sessions appear in the order
|
|
181
|
+
* their earliest lease was created — the same ordering the query applied.
|
|
182
|
+
* Sorting by session identity instead would order the document by a value
|
|
183
|
+
* another system minted, which carries no meaning to a reader.
|
|
184
|
+
*/
|
|
185
|
+
function groupBySession(leases) {
|
|
186
|
+
const grouped = new Map();
|
|
187
|
+
for (const lease of leases) {
|
|
188
|
+
const existing = grouped.get(lease.sessionId);
|
|
189
|
+
if (existing === undefined) {
|
|
190
|
+
grouped.set(lease.sessionId, [lease]);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
existing.push(lease);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return [...grouped].map(([sessionId, sessionLeases]) => ({ sessionId, leases: sessionLeases }));
|
|
197
|
+
}
|