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,182 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { StartupRefusal } from "../errors.js";
|
|
4
|
+
import { isNetworkVolumeType, networkFilesystemName, } from "./network-volume.js";
|
|
5
|
+
/**
|
|
6
|
+
* `store.not_on_network_filesystem` (`SCHEMA.md` §7.2, §1.0).
|
|
7
|
+
*
|
|
8
|
+
* The write-ahead log coordinates through a shared-memory index that requires
|
|
9
|
+
* every process using the database to be on the same host. On a network
|
|
10
|
+
* filesystem that requirement is not met, and the failure is not a clean
|
|
11
|
+
* error — it is two hosts each believing they hold the writer's position,
|
|
12
|
+
* which is corruption rather than contention. So this is a refusal to run,
|
|
13
|
+
* not a warning.
|
|
14
|
+
*
|
|
15
|
+
* ── Why one check is not enough ─────────────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* §1.0's table is explicit: a path written as a share directly is caught by
|
|
18
|
+
* reading its root, but **a mapped network drive is lexically identical to a
|
|
19
|
+
* local one**. There is nothing in the string to read. A check that only
|
|
20
|
+
* inspects the string passes on every machine with nothing mapped, which is
|
|
21
|
+
* every machine anybody writes the test on — so a second check has to ask the
|
|
22
|
+
* operating system what the volume actually is.
|
|
23
|
+
*
|
|
24
|
+
* ── The three checks, and why the third is not the second again ─────────
|
|
25
|
+
*
|
|
26
|
+
* | | What it asks | What it catches |
|
|
27
|
+
* |---|---|---|
|
|
28
|
+
* | One | Does the path's root name a share? | A share written out directly, in either separator spelling |
|
|
29
|
+
* | Two | Does the path *resolve* to one? | A mapped drive, on the platform where mappings resolve to the share behind them |
|
|
30
|
+
* | Three | What does the volume's own type code say? | A mount on a platform that has no share spelling to read and no mapping to resolve |
|
|
31
|
+
*
|
|
32
|
+
* **Checks one and two cover exactly one platform's spelling of the problem.**
|
|
33
|
+
* On a platform whose separator is the forward slash, a mounted network volume
|
|
34
|
+
* lives at an ordinary absolute path with no share prefix and nothing to
|
|
35
|
+
* resolve to one — it is a directory as far as every string operation is
|
|
36
|
+
* concerned. That is the same argument the mapped drive makes, transposed, and
|
|
37
|
+
* it needs its own check for the same reason. Check three is in
|
|
38
|
+
* `network-volume.ts` with its own limits written down.
|
|
39
|
+
*
|
|
40
|
+
* **A guard developed on one platform is untested on the other by
|
|
41
|
+
* construction**, so the tests drive all three through injected inputs and
|
|
42
|
+
* refuse on every platform rather than on the one they were written on.
|
|
43
|
+
*
|
|
44
|
+
* ── How check two asks ──────────────────────────────────────────────────
|
|
45
|
+
*
|
|
46
|
+
* By resolving the path to its real location. Where a mapped drive resolves to
|
|
47
|
+
* the share it points at, that turns an invisible case into the visible one
|
|
48
|
+
* check one already handles — so it reduces to check one, applied to the
|
|
49
|
+
* resolved path.
|
|
50
|
+
*
|
|
51
|
+
* The alternative is asking the platform's management interface for the
|
|
52
|
+
* volume's drive-type code in a subprocess. It answers correctly and it was
|
|
53
|
+
* measured, and it is not used here: it costs between roughly 0.7 and 1.1
|
|
54
|
+
* seconds per call, against a process startup this design puts at tens of
|
|
55
|
+
* milliseconds and a service that is spawned once per session. Paying a
|
|
56
|
+
* second on every spawn to learn something a filesystem call answers in a
|
|
57
|
+
* fraction of a millisecond would invalidate the startup measurement the
|
|
58
|
+
* storage decision rests on. It stays documented here as the fallback if a
|
|
59
|
+
* mapping is ever found that does not resolve.
|
|
60
|
+
*
|
|
61
|
+
* Filesystem statistics are **not** a route to that answer on the platform
|
|
62
|
+
* with drive letters, and the reason is worth keeping: there the type field
|
|
63
|
+
* reports the same value for a local volume and a mapped network one, so an
|
|
64
|
+
* implementation built on it looks correct, tests green, and refuses nothing.
|
|
65
|
+
* That is why check three stands beside check two rather than standing in for
|
|
66
|
+
* it — each is blind exactly where the other sees.
|
|
67
|
+
*/
|
|
68
|
+
/** A path that does not exist yet cannot be resolved; walk up to one that does. */
|
|
69
|
+
function nearestExistingAncestor(target) {
|
|
70
|
+
let candidate = path.resolve(target);
|
|
71
|
+
// The loop terminates: `path.dirname` of a root is the root itself.
|
|
72
|
+
for (;;) {
|
|
73
|
+
if (fs.existsSync(candidate)) {
|
|
74
|
+
return candidate;
|
|
75
|
+
}
|
|
76
|
+
const parent = path.dirname(candidate);
|
|
77
|
+
if (parent === candidate) {
|
|
78
|
+
return candidate;
|
|
79
|
+
}
|
|
80
|
+
candidate = parent;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a path to its real location, following any mapping.
|
|
85
|
+
*
|
|
86
|
+
* The store file does not exist on a first spawn, and resolving a path that
|
|
87
|
+
* is not there throws — so what gets resolved is the nearest ancestor that
|
|
88
|
+
* does exist. That ancestor is on the same volume as the file will be, which
|
|
89
|
+
* is the only property this check needs from it.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveRealPath(target) {
|
|
92
|
+
try {
|
|
93
|
+
return fs.realpathSync.native(nearestExistingAncestor(target));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// A path that cannot be resolved at all is left as it was written. The
|
|
97
|
+
// root check below still runs on it, and the store open that follows
|
|
98
|
+
// will fail for its own reasons with a better message than this one
|
|
99
|
+
// could invent.
|
|
100
|
+
return path.resolve(target);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Is this path's root a network share?
|
|
105
|
+
*
|
|
106
|
+
* Both separator spellings are tested. The platform path parser reports the
|
|
107
|
+
* share prefix as the root for the backslash spelling, and reports the
|
|
108
|
+
* forward-slash spelling **unchanged** rather than normalising it — so
|
|
109
|
+
* matching one spelling really does catch only half the cases.
|
|
110
|
+
*/
|
|
111
|
+
export function hasNetworkShareRoot(target) {
|
|
112
|
+
const root = path.win32.parse(target).root;
|
|
113
|
+
if (root === '') {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
const normalised = root.replace(/\//g, '\\');
|
|
117
|
+
// A share root is two separators, then a host, then a share. Two
|
|
118
|
+
// separators alone is a root-relative path on the current drive, which is
|
|
119
|
+
// local.
|
|
120
|
+
return /^\\\\[^\\]+\\/.test(normalised);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Read the volume statistics for a path, walking up to an ancestor that
|
|
124
|
+
* exists for the same reason resolution does — the store file is not there on
|
|
125
|
+
* a first spawn, and the ancestor is on the volume the file will be on.
|
|
126
|
+
*
|
|
127
|
+
* A path whose statistics cannot be read reports nothing rather than throwing.
|
|
128
|
+
* That is a deliberate allow: refusing on an unreadable answer would refuse
|
|
129
|
+
* paths for reasons that have nothing to do with a network, and the store open
|
|
130
|
+
* that follows fails with a better message than this could invent.
|
|
131
|
+
*/
|
|
132
|
+
export function readVolumeStatistics(target) {
|
|
133
|
+
try {
|
|
134
|
+
return fs.statfsSync(nearestExistingAncestor(target));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export const realChecks = {
|
|
141
|
+
resolveRealPath,
|
|
142
|
+
hasNetworkShareRoot,
|
|
143
|
+
readVolumeStatistics,
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Refuse a network location.
|
|
147
|
+
*
|
|
148
|
+
* Returns nothing on purpose. What check two resolves is the nearest
|
|
149
|
+
* *existing ancestor* of the store path, not the store path itself, so the
|
|
150
|
+
* resolved string is an answer to "which volume is this on" and would be
|
|
151
|
+
* wrong used as a location. The caller keeps the path it asked about.
|
|
152
|
+
*
|
|
153
|
+
* **Surrounding blank space is stripped before any check runs.** A value with
|
|
154
|
+
* a leading space is a value somebody typed with a leading space, and every
|
|
155
|
+
* check here reads the front of the string: the share-root test sees a space
|
|
156
|
+
* where it expects a separator and reports no root, and resolution treats the
|
|
157
|
+
* whole thing as a relative name. One invisible character would walk a share
|
|
158
|
+
* past all three, which is a guard defeated by a typing accident rather than
|
|
159
|
+
* by anything anybody meant.
|
|
160
|
+
*/
|
|
161
|
+
export function refuseNetworkLocation(target, checks = realChecks) {
|
|
162
|
+
const candidate = target.trim();
|
|
163
|
+
// Check one: the path as written names a share. Tested before resolution,
|
|
164
|
+
// for the reason above.
|
|
165
|
+
if (checks.hasNetworkShareRoot(candidate)) {
|
|
166
|
+
throw new StartupRefusal('store.not_on_network_filesystem', `The store location is on a network share. The write-ahead log requires every process using the database to be on one host, so a network location is refused rather than risked. Set BROKER_DB to a local path.`);
|
|
167
|
+
}
|
|
168
|
+
// Check two: the path as written looks local, and resolving it says
|
|
169
|
+
// otherwise. This is the mapped-drive case, and it is the reason one check
|
|
170
|
+
// is not enough.
|
|
171
|
+
const real = checks.resolveRealPath(candidate);
|
|
172
|
+
if (checks.hasNetworkShareRoot(real)) {
|
|
173
|
+
throw new StartupRefusal('store.not_on_network_filesystem', `The store location resolves to a network share. A mapped network drive is indistinguishable from a local one by its path alone, and the write-ahead log requires every process using the database to be on one host. Set BROKER_DB to a local path.`);
|
|
174
|
+
}
|
|
175
|
+
// Check three: nothing in the string says share on this platform because
|
|
176
|
+
// this platform has no share spelling — so the volume is asked what it is.
|
|
177
|
+
const statistics = checks.readVolumeStatistics(candidate);
|
|
178
|
+
if (statistics !== undefined && isNetworkVolumeType(statistics.type)) {
|
|
179
|
+
const name = networkFilesystemName(statistics.type) ?? 'a network filesystem';
|
|
180
|
+
throw new StartupRefusal('store.not_on_network_filesystem', `The store location is on a network filesystem (${name}). A mount point is indistinguishable from an ordinary directory by its path alone, and the write-ahead log requires every process using the database to be on one host. Set BROKER_DB to a path on a local disk.`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The network-volume check for platforms whose separator is the forward slash.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this file exists at all ─────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The share-root check reads a path's root and refuses a two-separator
|
|
7
|
+
* spelling. That is the whole of the detection on a platform that spells a
|
|
8
|
+
* share that way. **On a platform that does not, there is nothing in the
|
|
9
|
+
* string to read**: a mount lives at an ordinary absolute path, and a mount
|
|
10
|
+
* point is lexically identical to a directory. The mapped-drive argument of
|
|
11
|
+
* `SCHEMA.md` §1.0 transposes exactly — a check that only inspects the string
|
|
12
|
+
* passes on every machine with nothing mounted, which is every machine
|
|
13
|
+
* anybody writes the test on.
|
|
14
|
+
*
|
|
15
|
+
* The failure it is guarding is the same one and it is not a clean error: the
|
|
16
|
+
* write-ahead log coordinates through a shared-memory index that requires
|
|
17
|
+
* every process using the database to be on one host. Two hosts each believing
|
|
18
|
+
* they hold the writer's position is corruption rather than contention.
|
|
19
|
+
*
|
|
20
|
+
* ── How this asks ───────────────────────────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* Filesystem statistics report a type code for the volume a path is on, and on
|
|
23
|
+
* a platform whose separator is the forward slash that code distinguishes a
|
|
24
|
+
* network filesystem from a local one. So the volume is asked what it is,
|
|
25
|
+
* rather than the string being asked what it looks like.
|
|
26
|
+
*
|
|
27
|
+
* > **The same call is not a route to this answer on the platform with drive
|
|
28
|
+
* > letters, and the reason is worth keeping**: there the type field reports
|
|
29
|
+
* > the same value for a local volume and a mapped network one, so an
|
|
30
|
+
* > implementation built on it looks correct, tests green, and refuses
|
|
31
|
+
* > nothing. That platform is served by resolving the path instead, which
|
|
32
|
+
* > turns its invisible case into the visible one — and this file is not
|
|
33
|
+
* > consulted there.
|
|
34
|
+
*
|
|
35
|
+
* ── Why a list of codes rather than a property ──────────────────────────
|
|
36
|
+
*
|
|
37
|
+
* There is no "is this remote" flag to read. The type code is the only thing
|
|
38
|
+
* reported that distinguishes the filesystems at all, so the check is a
|
|
39
|
+
* membership test against the codes the network filesystems in ordinary use
|
|
40
|
+
* report. That has a known and stated limit: **a network filesystem whose code
|
|
41
|
+
* is not below is not detected.** The list is the mitigation for the common
|
|
42
|
+
* cases rather than a proof over all of them, and saying so is better than
|
|
43
|
+
* implying a completeness this cannot have.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* The type codes network filesystems report.
|
|
47
|
+
*
|
|
48
|
+
* Each is the constant that filesystem's own implementation reports for a
|
|
49
|
+
* mounted volume of its kind. They are magic numbers in the literal sense —
|
|
50
|
+
* fixed values with no derivation — so they are written down with the name of
|
|
51
|
+
* what reports them and nothing else to check them against.
|
|
52
|
+
*/
|
|
53
|
+
export const NETWORK_FILESYSTEM_TYPES = new Map([
|
|
54
|
+
// Server message block, versions one through three — the protocol the
|
|
55
|
+
// drive-letter platform's shares also speak, mounted natively here.
|
|
56
|
+
[0x517b, 'SMB'],
|
|
57
|
+
[0xfe534d42, 'SMB2'],
|
|
58
|
+
[0xff534d42, 'CIFS'],
|
|
59
|
+
// Network file system, versions two through four.
|
|
60
|
+
[0x6969, 'NFS'],
|
|
61
|
+
// Andrew file system, and its open reimplementation.
|
|
62
|
+
[0x5346414f, 'AFS'],
|
|
63
|
+
[0x6b414653, 'AFS (OpenAFS)'],
|
|
64
|
+
// Netware core protocol.
|
|
65
|
+
[0x564c, 'NCP'],
|
|
66
|
+
// A filesystem in user space, which is how most user-mounted network
|
|
67
|
+
// filesystems arrive. Not every one of these is remote — the code says
|
|
68
|
+
// "a program is serving this", not "a program on another host is serving
|
|
69
|
+
// this" — and that is stated plainly below rather than hidden.
|
|
70
|
+
[0x65735546, 'FUSE'],
|
|
71
|
+
[0x65735543, 'FUSE (control)'],
|
|
72
|
+
// Cluster filesystems, which are shared between hosts by definition and so
|
|
73
|
+
// break the one-host requirement for the same reason a mount does.
|
|
74
|
+
[0x47504653, 'GPFS'],
|
|
75
|
+
[0x7461636f, 'OCFS2'],
|
|
76
|
+
]);
|
|
77
|
+
/**
|
|
78
|
+
* Is this path on a filesystem whose type code says it is served over a
|
|
79
|
+
* network?
|
|
80
|
+
*
|
|
81
|
+
* A path whose statistics cannot be read is **not** refused. The store open
|
|
82
|
+
* that follows fails for its own reasons with a better message than this could
|
|
83
|
+
* invent, and refusing on an unreadable answer would refuse every path that
|
|
84
|
+
* does not exist yet — which is every path on a first spawn.
|
|
85
|
+
*/
|
|
86
|
+
export function isNetworkVolumeType(type) {
|
|
87
|
+
return NETWORK_FILESYSTEM_TYPES.has(type);
|
|
88
|
+
}
|
|
89
|
+
/** The name of the filesystem a type code belongs to, for the refusal message. */
|
|
90
|
+
export function networkFilesystemName(type) {
|
|
91
|
+
return NETWORK_FILESYSTEM_TYPES.get(type);
|
|
92
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
// The one place the driver is imported. Keeping it to a single file is what
|
|
4
|
+
// makes the storage choice reversible: the built-in module is a plausible
|
|
5
|
+
// future once it stops being experimental and stops taking its SQLite
|
|
6
|
+
// version from the runtime, and swapping to it is then a change to this file
|
|
7
|
+
// rather than to everything that opens a store.
|
|
8
|
+
//
|
|
9
|
+
// A default import, not a named one: the driver is CommonJS, and under
|
|
10
|
+
// `verbatimModuleSyntax` with node-style resolution the named form
|
|
11
|
+
// type-checks in some configurations and fails at run time.
|
|
12
|
+
import Database from 'better-sqlite3';
|
|
13
|
+
import { agreeOnTabBudget } from "./budget.js";
|
|
14
|
+
import { resolveStoreLocation } from "./location.js";
|
|
15
|
+
import { stepSchema } from "./schema/step.js";
|
|
16
|
+
import { immediate } from "./transaction.js";
|
|
17
|
+
/**
|
|
18
|
+
* Open the store: resolve where it is, refuse a network location, create the
|
|
19
|
+
* directory if it is absent, and set the three pragmas that make many
|
|
20
|
+
* processes on one file safe.
|
|
21
|
+
*
|
|
22
|
+
* There is no connection pool and looking for one is looking for the wrong
|
|
23
|
+
* shape (`MILESTONES.md`): a pool shares connections between concurrent work
|
|
24
|
+
* inside one long-lived process, and here the callers are separate operating
|
|
25
|
+
* system processes, each opening the file, doing its work and exiting.
|
|
26
|
+
*
|
|
27
|
+
* ── There is exactly one way for a spawn to open this store ─────────────
|
|
28
|
+
*
|
|
29
|
+
* `prepareStore` below. It opens, steps the schema, and settles the tab-budget
|
|
30
|
+
* agreement, in that order, and **it is the only export that hands a spawn a
|
|
31
|
+
* handle**. The raw open is deliberately module-private now.
|
|
32
|
+
*
|
|
33
|
+
* **The privacy is the mechanism, and a comment would not be one.** An
|
|
34
|
+
* entry point that a sibling export can bypass is a suggestion; the thing that
|
|
35
|
+
* makes it a path is that there is nothing else to call. Were the raw open
|
|
36
|
+
* exported beside it, each binary could assemble its own open-and-step pair,
|
|
37
|
+
* every one of them would be a place for `agreeOnTabBudget` to go missing, and
|
|
38
|
+
* the omission would be invisible: a store opens, a schema steps, and the one
|
|
39
|
+
* value several processes must agree on (§1.10, §7.2) is simply never compared.
|
|
40
|
+
* The budget suite would keep passing throughout, because it calls
|
|
41
|
+
* `prepareStore` — so the tests would agree with a product that did something
|
|
42
|
+
* else.
|
|
43
|
+
*
|
|
44
|
+
* The one exception is a diagnostic that must be able to observe the states
|
|
45
|
+
* this refuses to return from, and it is exported under a name that says so.
|
|
46
|
+
*
|
|
47
|
+
* Stepping remains a separate asynchronous call inside this module because it
|
|
48
|
+
* goes through the transaction helper; no caller outside can hold the halves
|
|
49
|
+
* apart.
|
|
50
|
+
*/
|
|
51
|
+
/** How long a blocked writer waits before giving up, in milliseconds. */
|
|
52
|
+
export const BUSY_TIMEOUT_MS = 5000;
|
|
53
|
+
/**
|
|
54
|
+
* How many times the conversion to write-ahead-log mode is retried, and how
|
|
55
|
+
* long each attempt waits before the next.
|
|
56
|
+
*
|
|
57
|
+
* Deliberately small. The conversion the retry is waiting on is one pragma on
|
|
58
|
+
* a file with no rows in it yet, so the only thing being waited for is another
|
|
59
|
+
* process finishing something that takes single-digit milliseconds. A budget
|
|
60
|
+
* this size turns the collision into a pause nobody notices; a larger one
|
|
61
|
+
* would turn a genuinely stuck file into a long hang.
|
|
62
|
+
*/
|
|
63
|
+
const WAL_CONVERSION_ATTEMPTS = 10;
|
|
64
|
+
const WAL_CONVERSION_PAUSE_MS = 20;
|
|
65
|
+
/**
|
|
66
|
+
* Put the store into write-ahead-log mode, retrying while another process is
|
|
67
|
+
* doing the same thing.
|
|
68
|
+
*
|
|
69
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
70
|
+
* WHY A RETRY AND NOT A LONGER BUSY TIMEOUT — MEASURED, NOT ASSUMED
|
|
71
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
72
|
+
*
|
|
73
|
+
* Switching a database into write-ahead-log mode **takes an exclusive lock on
|
|
74
|
+
* the file**. On a store already in that mode the pragma is a cheap no-op, so
|
|
75
|
+
* this never shows once an installation is warm. On a **fresh file the first
|
|
76
|
+
* spawn converts it**, and because the service is spawned per session and
|
|
77
|
+
* exits with it, a second spawn arriving during that conversion is the
|
|
78
|
+
* ordinary case on a machine that has never run this.
|
|
79
|
+
*
|
|
80
|
+
* The obvious repair — set `busy_timeout` first and let the second process
|
|
81
|
+
* wait the conversion out — **is not sufficient, and that was measured rather
|
|
82
|
+
* than reasoned about.** The timeout *is* honoured: with the file held by
|
|
83
|
+
* another connection, the conversion waits and then throws `SQLITE_BUSY`
|
|
84
|
+
* anyway, and it waits longer the larger the timeout is (measured at 0, 50,
|
|
85
|
+
* 200 and 1000ms: it threw after 21, 321, 664 and 1787ms respectively, and at
|
|
86
|
+
* five seconds after 7.4). So the timeout buys time and does not buy success —
|
|
87
|
+
* raising it only makes the eventual failure slower.
|
|
88
|
+
*
|
|
89
|
+
* Retrying works because the thing being contended for is transient by
|
|
90
|
+
* construction: the other process is converting the same file to the same
|
|
91
|
+
* mode, and once it has, this call finds the mode already set and returns it
|
|
92
|
+
* without needing any lock at all.
|
|
93
|
+
*
|
|
94
|
+
* **How load-bearing this is, measured:** with the budget cut to a single
|
|
95
|
+
* attempt, two barrier-aligned spawns against an empty directory fail in
|
|
96
|
+
* **9 runs of 10**. It is not a defensive flourish; without it a fresh install
|
|
97
|
+
* where two agents reach for a browser at once usually fails outright.
|
|
98
|
+
*
|
|
99
|
+
* ── One thing this comment will not overclaim ───────────────────────────
|
|
100
|
+
*
|
|
101
|
+
* The `busy_timeout` ordering above is correct and is kept, but **no test
|
|
102
|
+
* fails if it is moved back** — the retry covers that case on its own. It is
|
|
103
|
+
* ordered this way because a timeout configured after the first thing that can
|
|
104
|
+
* block is a timeout that was not configured when it was needed. That reason
|
|
105
|
+
* stands on its own; no assertion rests on it.
|
|
106
|
+
*
|
|
107
|
+
* **A busy error is the only one retried.** Anything else — a directory that
|
|
108
|
+
* cannot be written, a file that is not a database — is returned to the caller
|
|
109
|
+
* immediately, because retrying a permanent failure ten times only delays the
|
|
110
|
+
* message that says what is actually wrong.
|
|
111
|
+
*/
|
|
112
|
+
function convertToWriteAheadLog(db, location) {
|
|
113
|
+
let lastError;
|
|
114
|
+
for (let attempt = 0; attempt < WAL_CONVERSION_ATTEMPTS; attempt += 1) {
|
|
115
|
+
try {
|
|
116
|
+
return db.pragma('journal_mode = WAL', { simple: true });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error.code !== 'SQLITE_BUSY') {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
lastError = error;
|
|
123
|
+
// A synchronous pause, because everything on this path is synchronous
|
|
124
|
+
// and making the open asynchronous to accommodate a rare retry would
|
|
125
|
+
// change the signature of every caller that wants a handle.
|
|
126
|
+
const until = Date.now() + WAL_CONVERSION_PAUSE_MS;
|
|
127
|
+
while (Date.now() < until) {
|
|
128
|
+
/* waiting for the other process to finish converting */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`The store at ${location} could not be put into write-ahead-log mode after ${String(WAL_CONVERSION_ATTEMPTS)} attempts: another process held the file locked throughout. That mode is what lets several processes share this file. The underlying error was: ${String(lastError.message ?? lastError)}`);
|
|
133
|
+
}
|
|
134
|
+
function openStore(environment, options = {}) {
|
|
135
|
+
const location = resolveStoreLocation(environment, options.checks);
|
|
136
|
+
// Created on first spawn, not at install time. An install step that
|
|
137
|
+
// prepares state is a second lifecycle, and installation is the whole of
|
|
138
|
+
// deployment here.
|
|
139
|
+
fs.mkdirSync(path.dirname(location), { recursive: true });
|
|
140
|
+
const db = new Database(location);
|
|
141
|
+
// ── The busy timeout is set BEFORE the journal mode, and that ordering is
|
|
142
|
+
// necessary but on its own not sufficient ─────────────────────────────
|
|
143
|
+
//
|
|
144
|
+
// Ordinary lock contention: a blocked writer waits rather than failing at
|
|
145
|
+
// once. What this does **not** do is worth knowing before somebody reads
|
|
146
|
+
// the line and concludes retries are handled — the busy-snapshot error a
|
|
147
|
+
// deferred transaction raises is not retryable by this setting at all
|
|
148
|
+
// (§1.0a). The transaction mode is what addresses that, not this number.
|
|
149
|
+
//
|
|
150
|
+
// It precedes the conversion below because the conversion is the first
|
|
151
|
+
// thing on this path that can block, and a timeout set after it is a
|
|
152
|
+
// timeout that was not configured at the moment it was needed.
|
|
153
|
+
db.pragma(`busy_timeout = ${String(BUSY_TIMEOUT_MS)}`);
|
|
154
|
+
// The mode that lets several processes read while one writes, which is the
|
|
155
|
+
// whole basis of the concurrency model (§1.0a). Asserted rather than
|
|
156
|
+
// assumed: the pragma returns the mode it actually set.
|
|
157
|
+
const journalMode = convertToWriteAheadLog(db, location);
|
|
158
|
+
if (journalMode !== 'wal') {
|
|
159
|
+
throw new Error(`The store could not be opened in write-ahead-log mode; the journal mode is ${String(journalMode)}. That mode is what lets several processes share this file.`);
|
|
160
|
+
}
|
|
161
|
+
// Set explicitly, and the reason is not that the engine defaults it off.
|
|
162
|
+
// The driver in use is compiled with foreign keys defaulted on, so this
|
|
163
|
+
// pragma is a restatement of what it already does rather than a change to
|
|
164
|
+
// it. It is set because **a correctness guarantee must not rest on a
|
|
165
|
+
// third-party dependency's compile-time flag**: that
|
|
166
|
+
// flag is not part of the driver's public interface, and a rebuild from
|
|
167
|
+
// source, a differently packaged build, or the driver swap this file's own
|
|
168
|
+
// import comment contemplates could all change it with nothing to notice.
|
|
169
|
+
// Row #7's composite foreign key on tabs — the one that stops a tab naming
|
|
170
|
+
// a browser its own lease did not — is the guarantee being protected.
|
|
171
|
+
db.pragma('foreign_keys = ON');
|
|
172
|
+
return {
|
|
173
|
+
location,
|
|
174
|
+
immediate: (fn) => immediate(db, fn),
|
|
175
|
+
pragma: (statement) => db.pragma(statement, { simple: true }),
|
|
176
|
+
close: () => {
|
|
177
|
+
db.close();
|
|
178
|
+
},
|
|
179
|
+
db,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
export async function prepareStore(environment, options = {}) {
|
|
183
|
+
const store = openStore(environment, options);
|
|
184
|
+
try {
|
|
185
|
+
const stepped = await stepSchema(store.db);
|
|
186
|
+
// `budget.agrees_with_store` (§7.2), and it runs **after** stepping
|
|
187
|
+
// because the row it compares against is part of the schema. A process
|
|
188
|
+
// whose environment disagrees with the store refuses here rather than
|
|
189
|
+
// arbitrating against a bound the other processes are not using.
|
|
190
|
+
const budget = agreeOnTabBudget(store.db, environment.tabBudget);
|
|
191
|
+
return { ...store, stepped, budget };
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
store.close();
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Open an **existing** store without stepping it and without settling the
|
|
200
|
+
* budget agreement — for `broker doctor`, and for nothing else.
|
|
201
|
+
*
|
|
202
|
+
* ── Why this exception exists, and why it is a separate name ────────────
|
|
203
|
+
*
|
|
204
|
+
* `doctor`'s whole job is to report the state of an installation, including
|
|
205
|
+
* the two states `prepareStore` refuses to return from: a store behind this
|
|
206
|
+
* build's schema version, and a store whose recorded tab budget disagrees with
|
|
207
|
+
* this process's environment. A diagnostic that used the spawn path would
|
|
208
|
+
* throw on exactly the installations it exists to describe, and the operator
|
|
209
|
+
* would get a refusal instead of the report naming both numbers.
|
|
210
|
+
*
|
|
211
|
+
* **It is a distinct exported name rather than a second call to the raw open**
|
|
212
|
+
* because that is the difference between an exception and a bypass. The
|
|
213
|
+
* defect this module's header describes came from four callers quietly doing
|
|
214
|
+
* their own open; a caller reaching for something spelled
|
|
215
|
+
* `openStoreForDiagnosis` is stating that it is the diagnostic, and anything
|
|
216
|
+
* else calling it is visible in one grep.
|
|
217
|
+
*
|
|
218
|
+
* **Never creates a store.** The caller checks the file exists first, and that
|
|
219
|
+
* ordering is load-bearing: the raw open creates the directory and the file,
|
|
220
|
+
* so a doctor that opened unconditionally would create an empty store at
|
|
221
|
+
* version zero and then truthfully report it at the wrong version — a fault it
|
|
222
|
+
* had itself caused on an installation that was fine a moment earlier.
|
|
223
|
+
*/
|
|
224
|
+
export function openStoreForDiagnosis(environment, options = {}) {
|
|
225
|
+
return openStore(environment, options);
|
|
226
|
+
}
|