@secondlayer/shared 7.6.0 → 7.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/src/archive/copy-loader.d.ts +49 -0
  2. package/dist/src/archive/copy-loader.js +111 -0
  3. package/dist/src/archive/copy-loader.js.map +10 -0
  4. package/dist/src/archive/key-registry.d.ts +52 -0
  5. package/dist/src/archive/key-registry.js +168 -0
  6. package/dist/src/archive/key-registry.js.map +11 -0
  7. package/dist/src/archive/progress.d.ts +36 -0
  8. package/dist/src/archive/progress.js +75 -0
  9. package/dist/src/archive/progress.js.map +10 -0
  10. package/dist/src/archive/range-digest.d.ts +1062 -0
  11. package/dist/src/archive/range-digest.js +142 -0
  12. package/dist/src/archive/range-digest.js.map +10 -0
  13. package/dist/src/archive/status.d.ts +92 -0
  14. package/dist/src/archive/status.js +106 -0
  15. package/dist/src/archive/status.js.map +10 -0
  16. package/dist/src/db/index.d.ts +27 -1
  17. package/dist/src/db/index.js +2 -1
  18. package/dist/src/db/index.js.map +3 -3
  19. package/dist/src/db/queries/chain-reorgs.d.ts +25 -0
  20. package/dist/src/db/queries/chain-reorgs.js +2 -1
  21. package/dist/src/db/queries/chain-reorgs.js.map +3 -3
  22. package/dist/src/db/queries/contracts.d.ts +25 -0
  23. package/dist/src/db/queries/integrity.d.ts +25 -0
  24. package/dist/src/db/queries/subgraph-gaps.d.ts +25 -0
  25. package/dist/src/db/queries/subgraph-operations.d.ts +25 -0
  26. package/dist/src/db/queries/subgraphs.d.ts +25 -0
  27. package/dist/src/db/queries/subgraphs.js +2 -1
  28. package/dist/src/db/queries/subgraphs.js.map +3 -3
  29. package/dist/src/db/queries/subscriptions.d.ts +25 -0
  30. package/dist/src/db/schema.d.ts +26 -1
  31. package/dist/src/index.d.ts +27 -1
  32. package/dist/src/index.js +2 -1
  33. package/dist/src/index.js.map +3 -3
  34. package/dist/src/node/local-client.d.ts +25 -0
  35. package/migrations/0114_observer_journal.ts +54 -0
  36. package/package.json +22 -2
@@ -0,0 +1,49 @@
1
+ import { Writable } from "node:stream";
2
+ /**
3
+ * Streaming `COPY ... FROM STDIN` loader for canonical archive rows.
4
+ *
5
+ * Restoring history row-by-row is not viable at chain scale: batched INSERTs
6
+ * measured ~2k rows/s on this schema, which is days for a full genesis restore.
7
+ * COPY moves the same data at ~64k rows/s because it is one statement per
8
+ * partition with no per-row round-trip.
9
+ *
10
+ * This module deliberately knows nothing about Parquet or about where rows come
11
+ * from — it accepts the archive's row shape as an async iterable. That keeps the
12
+ * heavy file-format dependency out of every consumer that only needs to write
13
+ * rows, and lets the CLI and the indexer share one loader rather than growing
14
+ * two subtly different CSV encoders.
15
+ */
16
+ type ArchiveDataset = "blocks" | "transactions" | "events";
17
+ /** Row shapes exactly as the archive's Parquet columns are named. */
18
+ type ArchiveRow = Record<string, unknown>;
19
+ /**
20
+ * CSV encoding for `WITH (FORMAT csv, NULL '\N')`: every present value is
21
+ * quoted with embedded quotes doubled, so a value that happens to equal the
22
+ * NULL marker cannot be misread as SQL NULL. Only a genuinely absent value is
23
+ * written unquoted as `\N`.
24
+ */
25
+ declare function csvField(value: string | number | null | undefined): string;
26
+ declare function toCsvLine(dataset: ArchiveDataset, row: ArchiveRow): string;
27
+ /**
28
+ * `canonical` is omitted from the blocks column list: every archived block is
29
+ * canonical by definition, and the schema defaults it to true, so carrying it
30
+ * through every row would be dead weight.
31
+ */
32
+ declare function copyStatement(dataset: ArchiveDataset): string;
33
+ /**
34
+ * Stream rows onto an open COPY writable. The caller owns opening it (drivers
35
+ * differ in how a COPY stream is obtained) and this owns the wire format,
36
+ * backpressure, and failure semantics.
37
+ *
38
+ * On error the stream is destroyed rather than ended, so Postgres aborts the
39
+ * COPY instead of committing a partial one — an interrupted load must leave
40
+ * nothing behind, not half a partition.
41
+ */
42
+ declare function writeRowsToCopyStream(params: {
43
+ writable: Writable
44
+ dataset: ArchiveDataset
45
+ rows: AsyncIterable<ArchiveRow>
46
+ onProgress?: (rowsWritten: number) => void
47
+ progressEvery?: number
48
+ }): Promise<number>;
49
+ export { writeRowsToCopyStream, toCsvLine, csvField, copyStatement, ArchiveRow, ArchiveDataset };
@@ -0,0 +1,111 @@
1
+ import { createRequire } from "node:module";
2
+ var __defProp = Object.defineProperty;
3
+ var __returnValue = (v) => v;
4
+ function __exportSetter(name, newValue) {
5
+ this[name] = __returnValue.bind(null, newValue);
6
+ }
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true,
12
+ configurable: true,
13
+ set: __exportSetter.bind(all, name)
14
+ });
15
+ };
16
+
17
+ // src/archive/copy-loader.ts
18
+ import { once } from "node:events";
19
+ function csvField(value) {
20
+ if (value === null || value === undefined)
21
+ return "\\N";
22
+ const text = typeof value === "number" ? String(value) : value;
23
+ return `"${text.replace(/"/g, '""')}"`;
24
+ }
25
+ function asNumber(value) {
26
+ return typeof value === "bigint" ? Number(value) : Number(value);
27
+ }
28
+ function asText(value) {
29
+ return Buffer.isBuffer(value) ? value.toString("utf8") : String(value);
30
+ }
31
+ function asOptionalText(value) {
32
+ return value === null || value === undefined ? null : asText(value);
33
+ }
34
+ function toCsvLine(dataset, row) {
35
+ if (dataset === "blocks") {
36
+ return [
37
+ csvField(asNumber(row.height)),
38
+ csvField(asText(row.hash)),
39
+ csvField(asText(row.parent_hash)),
40
+ csvField(asNumber(row.burn_block_height)),
41
+ csvField(asOptionalText(row.burn_block_hash)),
42
+ csvField(asOptionalText(row.index_block_hash)),
43
+ csvField(asNumber(row.timestamp))
44
+ ].join(",");
45
+ }
46
+ if (dataset === "transactions") {
47
+ return [
48
+ csvField(asText(row.tx_id)),
49
+ csvField(asNumber(row.block_height)),
50
+ csvField(asNumber(row.tx_index)),
51
+ csvField(asText(row.type)),
52
+ csvField(asText(row.sender)),
53
+ csvField(asText(row.status)),
54
+ csvField(asOptionalText(row.contract_id)),
55
+ csvField(asOptionalText(row.function_name)),
56
+ csvField(asOptionalText(row.function_args_json)),
57
+ csvField(asOptionalText(row.raw_result)),
58
+ csvField(asText(row.raw_tx))
59
+ ].join(",");
60
+ }
61
+ return [
62
+ csvField(asText(row.tx_id)),
63
+ csvField(asNumber(row.block_height)),
64
+ csvField(asNumber(row.event_index)),
65
+ csvField(asText(row.event_type)),
66
+ csvField(asText(row.data_json))
67
+ ].join(",");
68
+ }
69
+ function copyStatement(dataset) {
70
+ if (dataset === "blocks") {
71
+ return "COPY blocks (height, hash, parent_hash, burn_block_height, burn_block_hash, index_block_hash, timestamp) FROM STDIN WITH (FORMAT csv, NULL '\\N')";
72
+ }
73
+ if (dataset === "transactions") {
74
+ return "COPY transactions (tx_id, block_height, tx_index, type, sender, status, contract_id, function_name, function_args, raw_result, raw_tx) FROM STDIN WITH (FORMAT csv, NULL '\\N')";
75
+ }
76
+ return "COPY events (tx_id, block_height, event_index, type, data) FROM STDIN WITH (FORMAT csv, NULL '\\N')";
77
+ }
78
+ async function writeRowsToCopyStream(params) {
79
+ const { writable, dataset, rows } = params;
80
+ const progressEvery = params.progressEvery ?? 500000;
81
+ let count = 0;
82
+ try {
83
+ for await (const row of rows) {
84
+ const line = `${toCsvLine(dataset, row)}
85
+ `;
86
+ if (!writable.write(line)) {
87
+ await once(writable, "drain");
88
+ }
89
+ count++;
90
+ if (params.onProgress && count % progressEvery === 0) {
91
+ params.onProgress(count);
92
+ }
93
+ }
94
+ await new Promise((resolve, reject) => {
95
+ writable.end((error) => error ? reject(error) : resolve());
96
+ });
97
+ } catch (error) {
98
+ writable.destroy(error instanceof Error ? error : new Error(String(error)));
99
+ throw error;
100
+ }
101
+ return count;
102
+ }
103
+ export {
104
+ writeRowsToCopyStream,
105
+ toCsvLine,
106
+ csvField,
107
+ copyStatement
108
+ };
109
+
110
+ //# debugId=2B81DFB47D25D63C64756E2164756E21
111
+ //# sourceMappingURL=copy-loader.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/archive/copy-loader.ts"],
4
+ "sourcesContent": [
5
+ "import { once } from \"node:events\";\nimport type { Writable } from \"node:stream\";\n\n/**\n * Streaming `COPY ... FROM STDIN` loader for canonical archive rows.\n *\n * Restoring history row-by-row is not viable at chain scale: batched INSERTs\n * measured ~2k rows/s on this schema, which is days for a full genesis restore.\n * COPY moves the same data at ~64k rows/s because it is one statement per\n * partition with no per-row round-trip.\n *\n * This module deliberately knows nothing about Parquet or about where rows come\n * from — it accepts the archive's row shape as an async iterable. That keeps the\n * heavy file-format dependency out of every consumer that only needs to write\n * rows, and lets the CLI and the indexer share one loader rather than growing\n * two subtly different CSV encoders.\n */\n\nexport type ArchiveDataset = \"blocks\" | \"transactions\" | \"events\";\n\n/** Row shapes exactly as the archive's Parquet columns are named. */\nexport type ArchiveRow = Record<string, unknown>;\n\n/**\n * CSV encoding for `WITH (FORMAT csv, NULL '\\N')`: every present value is\n * quoted with embedded quotes doubled, so a value that happens to equal the\n * NULL marker cannot be misread as SQL NULL. Only a genuinely absent value is\n * written unquoted as `\\N`.\n */\nexport function csvField(value: string | number | null | undefined): string {\n\tif (value === null || value === undefined) return \"\\\\N\";\n\tconst text = typeof value === \"number\" ? String(value) : value;\n\treturn `\"${text.replace(/\"/g, '\"\"')}\"`;\n}\n\nfunction asNumber(value: unknown): number {\n\treturn typeof value === \"bigint\" ? Number(value) : Number(value as number);\n}\n\nfunction asText(value: unknown): string {\n\treturn Buffer.isBuffer(value) ? value.toString(\"utf8\") : String(value);\n}\n\nfunction asOptionalText(value: unknown): string | null {\n\treturn value === null || value === undefined ? null : asText(value);\n}\n\nexport function toCsvLine(dataset: ArchiveDataset, row: ArchiveRow): string {\n\tif (dataset === \"blocks\") {\n\t\treturn [\n\t\t\tcsvField(asNumber(row.height)),\n\t\t\tcsvField(asText(row.hash)),\n\t\t\tcsvField(asText(row.parent_hash)),\n\t\t\tcsvField(asNumber(row.burn_block_height)),\n\t\t\tcsvField(asOptionalText(row.burn_block_hash)),\n\t\t\tcsvField(asOptionalText(row.index_block_hash)),\n\t\t\tcsvField(asNumber(row.timestamp)),\n\t\t].join(\",\");\n\t}\n\tif (dataset === \"transactions\") {\n\t\treturn [\n\t\t\tcsvField(asText(row.tx_id)),\n\t\t\tcsvField(asNumber(row.block_height)),\n\t\t\tcsvField(asNumber(row.tx_index)),\n\t\t\tcsvField(asText(row.type)),\n\t\t\tcsvField(asText(row.sender)),\n\t\t\tcsvField(asText(row.status)),\n\t\t\tcsvField(asOptionalText(row.contract_id)),\n\t\t\tcsvField(asOptionalText(row.function_name)),\n\t\t\tcsvField(asOptionalText(row.function_args_json)),\n\t\t\tcsvField(asOptionalText(row.raw_result)),\n\t\t\tcsvField(asText(row.raw_tx)),\n\t\t].join(\",\");\n\t}\n\treturn [\n\t\tcsvField(asText(row.tx_id)),\n\t\tcsvField(asNumber(row.block_height)),\n\t\tcsvField(asNumber(row.event_index)),\n\t\tcsvField(asText(row.event_type)),\n\t\tcsvField(asText(row.data_json)),\n\t].join(\",\");\n}\n\n/**\n * `canonical` is omitted from the blocks column list: every archived block is\n * canonical by definition, and the schema defaults it to true, so carrying it\n * through every row would be dead weight.\n */\nexport function copyStatement(dataset: ArchiveDataset): string {\n\tif (dataset === \"blocks\") {\n\t\treturn \"COPY blocks (height, hash, parent_hash, burn_block_height, burn_block_hash, index_block_hash, timestamp) FROM STDIN WITH (FORMAT csv, NULL '\\\\N')\";\n\t}\n\tif (dataset === \"transactions\") {\n\t\treturn \"COPY transactions (tx_id, block_height, tx_index, type, sender, status, contract_id, function_name, function_args, raw_result, raw_tx) FROM STDIN WITH (FORMAT csv, NULL '\\\\N')\";\n\t}\n\treturn \"COPY events (tx_id, block_height, event_index, type, data) FROM STDIN WITH (FORMAT csv, NULL '\\\\N')\";\n}\n\n/**\n * Stream rows onto an open COPY writable. The caller owns opening it (drivers\n * differ in how a COPY stream is obtained) and this owns the wire format,\n * backpressure, and failure semantics.\n *\n * On error the stream is destroyed rather than ended, so Postgres aborts the\n * COPY instead of committing a partial one — an interrupted load must leave\n * nothing behind, not half a partition.\n */\nexport async function writeRowsToCopyStream(params: {\n\twritable: Writable;\n\tdataset: ArchiveDataset;\n\trows: AsyncIterable<ArchiveRow>;\n\tonProgress?: (rowsWritten: number) => void;\n\tprogressEvery?: number;\n}): Promise<number> {\n\tconst { writable, dataset, rows } = params;\n\tconst progressEvery = params.progressEvery ?? 500_000;\n\tlet count = 0;\n\ttry {\n\t\tfor await (const row of rows) {\n\t\t\tconst line = `${toCsvLine(dataset, row)}\\n`;\n\t\t\tif (!writable.write(line)) {\n\t\t\t\tawait once(writable, \"drain\");\n\t\t\t}\n\t\t\tcount++;\n\t\t\tif (params.onProgress && count % progressEvery === 0) {\n\t\t\t\tparams.onProgress(count);\n\t\t\t}\n\t\t}\n\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\twritable.end((error?: Error | null) =>\n\t\t\t\terror ? reject(error) : resolve(),\n\t\t\t);\n\t\t});\n\t} catch (error) {\n\t\twritable.destroy(error instanceof Error ? error : new Error(String(error)));\n\t\tthrow error;\n\t}\n\treturn count;\n}\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "2B81DFB47D25D63C64756E2164756E21",
9
+ "names": []
10
+ }
@@ -0,0 +1,52 @@
1
+ /**\\n* Trust root for the canonical archive.\\n*\\n* Day-to-day signing uses an ONLINE key that lives on the publishing host, so\\n* it is exposed to every risk that host is. The archive's actual trust anchor\\n* is an OFFLINE ROOT key that never touches a server: it signs a registry of\\n* online keys, and nothing else. A consumer pins the root public key, verifies\\n* the registry against it, and only then decides whether a manifest's signature\\n* means anything.\\n*\\n* The distinction that makes rotation survivable:\\n*\\n* retired / rotated → the key stops signing NEW objects, but everything it\\n* signed while valid stays valid. Without this, every\\n* rotation would invalidate the entire published history.\\n* compromised → every signature by that key is suspect, including\\n* historical ones, because an attacker holding the key\\n* could have backdated anything.\\n*\\n* Those two cases look identical in a naive "is this key current?" check, and\\n* conflating them either destroys your history on every rotation or accepts\\n* forgeries after a leak.\\n*/
2
+ declare const KEY_REGISTRY_SCHEMA_VERSION = 1;
3
+ type KeyStatus = "active" | "retired" | "compromised";
4
+ type RegisteredKey = {
5
+ key_id: string
6
+ public_key_pem: string
7
+ status: KeyStatus
8
+ /** ISO timestamp this key became valid for signing. */
9
+ valid_from: string
10
+ /** ISO timestamp it stopped signing. Null while active. */
11
+ valid_until: string | null
12
+ /** For `compromised`: the moment trust is withdrawn. Everything this key
13
+ * signed is suspect regardless of when, so this is provenance, not a
14
+ * validity bound. */
15
+ compromised_at?: string
16
+ };
17
+ type KeyRegistry = {
18
+ schema_version: typeof KEY_REGISTRY_SCHEMA_VERSION
19
+ network: string
20
+ keys: RegisteredKey[]
21
+ updated_at: string
22
+ /** Root signature over the registry minus this envelope. */
23
+ signature?: string
24
+ root_key_id?: string
25
+ };
26
+ type KeyTrustResult = {
27
+ trusted: boolean
28
+ /** Machine-readable so callers can map to exit codes and messages. */
29
+ reason: "trusted" | "registry-unsigned" | "registry-signature-invalid" | "unknown-key" | "key-compromised" | "signed-outside-validity"
30
+ detail: string
31
+ };
32
+ /** The exact bytes the root signature covers: the registry minus its envelope. */
33
+ declare function canonicalRegistryPayload(registry: KeyRegistry): string;
34
+ /**
35
+ * Verify the registry itself against the pinned root public key. Everything
36
+ * downstream is meaningless if this fails — an attacker who can serve a
37
+ * registry can otherwise nominate their own signing key.
38
+ */
39
+ declare function verifyRegistry(registry: KeyRegistry, rootPublicKeyPem: string): KeyTrustResult;
40
+ /**
41
+ * Decide whether an object signed by `keyId` at `signedAt` should be trusted.
42
+ *
43
+ * `signedAt` is the object's own generation time, NOT now — that is what lets a
44
+ * rotated key keep vouching for what it signed while it was valid.
45
+ */
46
+ declare function checkKeyTrust(registry: KeyRegistry, keyId: string, signedAt: string): KeyTrustResult;
47
+ /** Look up a key's public PEM for signature verification. */
48
+ declare function publicKeyFor(registry: KeyRegistry, keyId: string): string | undefined;
49
+ /** Derive the registry entry id for a public key, so operators cannot mislabel
50
+ * a key by hand-editing the registry. */
51
+ declare function keyIdFor(publicKeyPem: string): string;
52
+ export { verifyRegistry, publicKeyFor, keyIdFor, checkKeyTrust, canonicalRegistryPayload, RegisteredKey, KeyTrustResult, KeyStatus, KeyRegistry, KEY_REGISTRY_SCHEMA_VERSION };
@@ -0,0 +1,168 @@
1
+ import { createRequire } from "node:module";
2
+ var __defProp = Object.defineProperty;
3
+ var __returnValue = (v) => v;
4
+ function __exportSetter(name, newValue) {
5
+ this[name] = __returnValue.bind(null, newValue);
6
+ }
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true,
12
+ configurable: true,
13
+ set: __exportSetter.bind(all, name)
14
+ });
15
+ };
16
+
17
+ // src/crypto/ed25519.ts
18
+ var exports_ed25519 = {};
19
+ __export(exports_ed25519, {
20
+ verifyEd25519: () => verifyEd25519,
21
+ signEd25519: () => signEd25519,
22
+ publicKeyPemFromPrivate: () => publicKeyPemFromPrivate,
23
+ loadEd25519PublicKey: () => loadEd25519PublicKey,
24
+ loadEd25519PrivateKey: () => loadEd25519PrivateKey,
25
+ generateEd25519KeyPair: () => generateEd25519KeyPair,
26
+ ed25519KeyId: () => ed25519KeyId
27
+ });
28
+ import {
29
+ createHash,
30
+ createPrivateKey,
31
+ createPublicKey,
32
+ generateKeyPairSync,
33
+ sign as nodeSign,
34
+ verify as nodeVerify
35
+ } from "node:crypto";
36
+ function generateEd25519KeyPair() {
37
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
38
+ return {
39
+ privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
40
+ publicKeyPem: publicKey.export({ format: "pem", type: "spki" }).toString()
41
+ };
42
+ }
43
+ function loadEd25519PrivateKey(pem) {
44
+ return createPrivateKey(pem);
45
+ }
46
+ function loadEd25519PublicKey(pem) {
47
+ return createPublicKey(pem);
48
+ }
49
+ function publicKeyPemFromPrivate(privateKeyPem) {
50
+ return createPublicKey(createPrivateKey(privateKeyPem)).export({ format: "pem", type: "spki" }).toString();
51
+ }
52
+ function ed25519KeyId(publicKeyPem) {
53
+ const der = createPublicKey(publicKeyPem).export({
54
+ format: "der",
55
+ type: "spki"
56
+ });
57
+ return createHash("sha256").update(der).digest("base64url").slice(0, 16);
58
+ }
59
+ function signEd25519(payload, privateKey) {
60
+ return nodeSign(null, Buffer.from(payload, "utf8"), privateKey).toString("base64");
61
+ }
62
+ function verifyEd25519(payload, signatureBase64, publicKey) {
63
+ try {
64
+ return nodeVerify(null, Buffer.from(payload, "utf8"), publicKey, Buffer.from(signatureBase64, "base64"));
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ // src/archive/key-registry.ts
71
+ var KEY_REGISTRY_SCHEMA_VERSION = 1;
72
+ function canonicalRegistryPayload(registry) {
73
+ const { signature: _s, root_key_id: _r, ...rest } = registry;
74
+ return JSON.stringify(rest);
75
+ }
76
+ function verifyRegistry(registry, rootPublicKeyPem) {
77
+ if (!registry.signature) {
78
+ return {
79
+ trusted: false,
80
+ reason: "registry-unsigned",
81
+ detail: "key registry carries no root signature"
82
+ };
83
+ }
84
+ let valid = false;
85
+ try {
86
+ valid = verifyEd25519(canonicalRegistryPayload(registry), registry.signature, loadEd25519PublicKey(rootPublicKeyPem));
87
+ } catch (err) {
88
+ return {
89
+ trusted: false,
90
+ reason: "registry-signature-invalid",
91
+ detail: err instanceof Error ? err.message : "root verification failed"
92
+ };
93
+ }
94
+ return valid ? {
95
+ trusted: true,
96
+ reason: "trusted",
97
+ detail: "registry verified against root"
98
+ } : {
99
+ trusted: false,
100
+ reason: "registry-signature-invalid",
101
+ detail: "registry signature does not match the pinned root key"
102
+ };
103
+ }
104
+ function checkKeyTrust(registry, keyId, signedAt) {
105
+ const key = registry.keys.find((k) => k.key_id === keyId);
106
+ if (!key) {
107
+ return {
108
+ trusted: false,
109
+ reason: "unknown-key",
110
+ detail: `key ${keyId} is not in the registry`
111
+ };
112
+ }
113
+ if (key.status === "compromised") {
114
+ return {
115
+ trusted: false,
116
+ reason: "key-compromised",
117
+ detail: `key ${keyId} was compromised${key.compromised_at ? ` (${key.compromised_at})` : ""}; every signature it made is suspect`
118
+ };
119
+ }
120
+ const signedTime = Date.parse(signedAt);
121
+ const from = Date.parse(key.valid_from);
122
+ if (Number.isNaN(signedTime) || Number.isNaN(from)) {
123
+ return {
124
+ trusted: false,
125
+ reason: "signed-outside-validity",
126
+ detail: "unparseable timestamp on the object or the key"
127
+ };
128
+ }
129
+ if (signedTime < from) {
130
+ return {
131
+ trusted: false,
132
+ reason: "signed-outside-validity",
133
+ detail: `object predates key validity (${signedAt} < ${key.valid_from})`
134
+ };
135
+ }
136
+ if (key.valid_until !== null) {
137
+ const until = Date.parse(key.valid_until);
138
+ if (!Number.isNaN(until) && signedTime > until) {
139
+ return {
140
+ trusted: false,
141
+ reason: "signed-outside-validity",
142
+ detail: `object postdates key retirement (${signedAt} > ${key.valid_until})`
143
+ };
144
+ }
145
+ }
146
+ return {
147
+ trusted: true,
148
+ reason: "trusted",
149
+ detail: key.status === "active" ? `signed by active key ${keyId}` : `signed by ${keyId} while it was valid (now ${key.status})`
150
+ };
151
+ }
152
+ function publicKeyFor(registry, keyId) {
153
+ return registry.keys.find((k) => k.key_id === keyId)?.public_key_pem;
154
+ }
155
+ function keyIdFor(publicKeyPem) {
156
+ return ed25519KeyId(publicKeyPem);
157
+ }
158
+ export {
159
+ verifyRegistry,
160
+ publicKeyFor,
161
+ keyIdFor,
162
+ checkKeyTrust,
163
+ canonicalRegistryPayload,
164
+ KEY_REGISTRY_SCHEMA_VERSION
165
+ };
166
+
167
+ //# debugId=06FAC7E08C33616364756E2164756E21
168
+ //# sourceMappingURL=key-registry.js.map
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/crypto/ed25519.ts", "../src/archive/key-registry.ts"],
4
+ "sourcesContent": [
5
+ "import {\n\ttype KeyObject,\n\tcreateHash,\n\tcreatePrivateKey,\n\tcreatePublicKey,\n\tgenerateKeyPairSync,\n\tsign as nodeSign,\n\tverify as nodeVerify,\n} from \"node:crypto\";\n\n/**\n * Asymmetric ed25519 signing for Streams response proofs.\n *\n * Asymmetric (not HMAC) so the proof is real: only the server holds the private\n * key, and any consumer verifies with the published public key — no shared\n * secret to leak. ed25519 uses node's `sign`/`verify` with a `null` algorithm.\n * Keys are PEM (PKCS8 private / SPKI public) for env transport; load once and\n * reuse the KeyObject on hot paths.\n */\n\nexport function generateEd25519KeyPair(): {\n\tprivateKeyPem: string;\n\tpublicKeyPem: string;\n} {\n\tconst { privateKey, publicKey } = generateKeyPairSync(\"ed25519\");\n\treturn {\n\t\tprivateKeyPem: privateKey\n\t\t\t.export({ format: \"pem\", type: \"pkcs8\" })\n\t\t\t.toString(),\n\t\tpublicKeyPem: publicKey.export({ format: \"pem\", type: \"spki\" }).toString(),\n\t};\n}\n\nexport function loadEd25519PrivateKey(pem: string): KeyObject {\n\treturn createPrivateKey(pem);\n}\n\nexport function loadEd25519PublicKey(pem: string): KeyObject {\n\treturn createPublicKey(pem);\n}\n\nexport function publicKeyPemFromPrivate(privateKeyPem: string): string {\n\treturn createPublicKey(createPrivateKey(privateKeyPem))\n\t\t.export({ format: \"pem\", type: \"spki\" })\n\t\t.toString();\n}\n\n/** Stable short id for a public key (rotation hint via X-Signature-KeyId). */\nexport function ed25519KeyId(publicKeyPem: string): string {\n\tconst der = createPublicKey(publicKeyPem).export({\n\t\tformat: \"der\",\n\t\ttype: \"spki\",\n\t});\n\treturn createHash(\"sha256\").update(der).digest(\"base64url\").slice(0, 16);\n}\n\nexport function signEd25519(payload: string, privateKey: KeyObject): string {\n\treturn nodeSign(null, Buffer.from(payload, \"utf8\"), privateKey).toString(\n\t\t\"base64\",\n\t);\n}\n\nexport function verifyEd25519(\n\tpayload: string,\n\tsignatureBase64: string,\n\tpublicKey: KeyObject,\n): boolean {\n\ttry {\n\t\treturn nodeVerify(\n\t\t\tnull,\n\t\t\tBuffer.from(payload, \"utf8\"),\n\t\t\tpublicKey,\n\t\t\tBuffer.from(signatureBase64, \"base64\"),\n\t\t);\n\t} catch {\n\t\treturn false;\n\t}\n}\n",
6
+ "import {\n\ted25519KeyId,\n\tloadEd25519PublicKey,\n\tverifyEd25519,\n} from \"../crypto/ed25519.ts\";\n\n/**\n * Trust root for the canonical archive.\n *\n * Day-to-day signing uses an ONLINE key that lives on the publishing host, so\n * it is exposed to every risk that host is. The archive's actual trust anchor\n * is an OFFLINE ROOT key that never touches a server: it signs a registry of\n * online keys, and nothing else. A consumer pins the root public key, verifies\n * the registry against it, and only then decides whether a manifest's signature\n * means anything.\n *\n * The distinction that makes rotation survivable:\n *\n * retired / rotated → the key stops signing NEW objects, but everything it\n * signed while valid stays valid. Without this, every\n * rotation would invalidate the entire published history.\n * compromised → every signature by that key is suspect, including\n * historical ones, because an attacker holding the key\n * could have backdated anything.\n *\n * Those two cases look identical in a naive \"is this key current?\" check, and\n * conflating them either destroys your history on every rotation or accepts\n * forgeries after a leak.\n */\n\nexport const KEY_REGISTRY_SCHEMA_VERSION = 1;\n\nexport type KeyStatus = \"active\" | \"retired\" | \"compromised\";\n\nexport type RegisteredKey = {\n\tkey_id: string;\n\tpublic_key_pem: string;\n\tstatus: KeyStatus;\n\t/** ISO timestamp this key became valid for signing. */\n\tvalid_from: string;\n\t/** ISO timestamp it stopped signing. Null while active. */\n\tvalid_until: string | null;\n\t/** For `compromised`: the moment trust is withdrawn. Everything this key\n\t * signed is suspect regardless of when, so this is provenance, not a\n\t * validity bound. */\n\tcompromised_at?: string;\n};\n\nexport type KeyRegistry = {\n\tschema_version: typeof KEY_REGISTRY_SCHEMA_VERSION;\n\tnetwork: string;\n\tkeys: RegisteredKey[];\n\tupdated_at: string;\n\t/** Root signature over the registry minus this envelope. */\n\tsignature?: string;\n\troot_key_id?: string;\n};\n\nexport type KeyTrustResult = {\n\ttrusted: boolean;\n\t/** Machine-readable so callers can map to exit codes and messages. */\n\treason:\n\t\t| \"trusted\"\n\t\t| \"registry-unsigned\"\n\t\t| \"registry-signature-invalid\"\n\t\t| \"unknown-key\"\n\t\t| \"key-compromised\"\n\t\t| \"signed-outside-validity\";\n\tdetail: string;\n};\n\n/** The exact bytes the root signature covers: the registry minus its envelope. */\nexport function canonicalRegistryPayload(registry: KeyRegistry): string {\n\tconst { signature: _s, root_key_id: _r, ...rest } = registry;\n\treturn JSON.stringify(rest);\n}\n\n/**\n * Verify the registry itself against the pinned root public key. Everything\n * downstream is meaningless if this fails — an attacker who can serve a\n * registry can otherwise nominate their own signing key.\n */\nexport function verifyRegistry(\n\tregistry: KeyRegistry,\n\trootPublicKeyPem: string,\n): KeyTrustResult {\n\tif (!registry.signature) {\n\t\treturn {\n\t\t\ttrusted: false,\n\t\t\treason: \"registry-unsigned\",\n\t\t\tdetail: \"key registry carries no root signature\",\n\t\t};\n\t}\n\tlet valid = false;\n\ttry {\n\t\tvalid = verifyEd25519(\n\t\t\tcanonicalRegistryPayload(registry),\n\t\t\tregistry.signature,\n\t\t\tloadEd25519PublicKey(rootPublicKeyPem),\n\t\t);\n\t} catch (err) {\n\t\treturn {\n\t\t\ttrusted: false,\n\t\t\treason: \"registry-signature-invalid\",\n\t\t\tdetail: err instanceof Error ? err.message : \"root verification failed\",\n\t\t};\n\t}\n\treturn valid\n\t\t? {\n\t\t\t\ttrusted: true,\n\t\t\t\treason: \"trusted\",\n\t\t\t\tdetail: \"registry verified against root\",\n\t\t\t}\n\t\t: {\n\t\t\t\ttrusted: false,\n\t\t\t\treason: \"registry-signature-invalid\",\n\t\t\t\tdetail: \"registry signature does not match the pinned root key\",\n\t\t\t};\n}\n\n/**\n * Decide whether an object signed by `keyId` at `signedAt` should be trusted.\n *\n * `signedAt` is the object's own generation time, NOT now — that is what lets a\n * rotated key keep vouching for what it signed while it was valid.\n */\nexport function checkKeyTrust(\n\tregistry: KeyRegistry,\n\tkeyId: string,\n\tsignedAt: string,\n): KeyTrustResult {\n\tconst key = registry.keys.find((k) => k.key_id === keyId);\n\tif (!key) {\n\t\treturn {\n\t\t\ttrusted: false,\n\t\t\treason: \"unknown-key\",\n\t\t\tdetail: `key ${keyId} is not in the registry`,\n\t\t};\n\t}\n\tif (key.status === \"compromised\") {\n\t\treturn {\n\t\t\ttrusted: false,\n\t\t\treason: \"key-compromised\",\n\t\t\tdetail: `key ${keyId} was compromised${key.compromised_at ? ` (${key.compromised_at})` : \"\"}; every signature it made is suspect`,\n\t\t};\n\t}\n\n\tconst signedTime = Date.parse(signedAt);\n\tconst from = Date.parse(key.valid_from);\n\tif (Number.isNaN(signedTime) || Number.isNaN(from)) {\n\t\treturn {\n\t\t\ttrusted: false,\n\t\t\treason: \"signed-outside-validity\",\n\t\t\tdetail: \"unparseable timestamp on the object or the key\",\n\t\t};\n\t}\n\tif (signedTime < from) {\n\t\treturn {\n\t\t\ttrusted: false,\n\t\t\treason: \"signed-outside-validity\",\n\t\t\tdetail: `object predates key validity (${signedAt} < ${key.valid_from})`,\n\t\t};\n\t}\n\tif (key.valid_until !== null) {\n\t\tconst until = Date.parse(key.valid_until);\n\t\tif (!Number.isNaN(until) && signedTime > until) {\n\t\t\treturn {\n\t\t\t\ttrusted: false,\n\t\t\t\treason: \"signed-outside-validity\",\n\t\t\t\tdetail: `object postdates key retirement (${signedAt} > ${key.valid_until})`,\n\t\t\t};\n\t\t}\n\t}\n\n\t// A retired key that was valid at signing time is fine — that is the whole\n\t// point of recording a validity window rather than a boolean.\n\treturn {\n\t\ttrusted: true,\n\t\treason: \"trusted\",\n\t\tdetail:\n\t\t\tkey.status === \"active\"\n\t\t\t\t? `signed by active key ${keyId}`\n\t\t\t\t: `signed by ${keyId} while it was valid (now ${key.status})`,\n\t};\n}\n\n/** Look up a key's public PEM for signature verification. */\nexport function publicKeyFor(\n\tregistry: KeyRegistry,\n\tkeyId: string,\n): string | undefined {\n\treturn registry.keys.find((k) => k.key_id === keyId)?.public_key_pem;\n}\n\n/** Derive the registry entry id for a public key, so operators cannot mislabel\n * a key by hand-editing the registry. */\nexport function keyIdFor(publicKeyPem: string): string {\n\treturn ed25519KeyId(publicKeyPem);\n}\n"
7
+ ],
8
+ "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,UAMC;AAAA,YACA;AAAA;AAaM,SAAS,sBAAsB,GAGpC;AAAA,EACD,QAAQ,YAAY,cAAc,oBAAoB,SAAS;AAAA,EAC/D,OAAO;AAAA,IACN,eAAe,WACb,OAAO,EAAE,QAAQ,OAAO,MAAM,QAAQ,CAAC,EACvC,SAAS;AAAA,IACX,cAAc,UAAU,OAAO,EAAE,QAAQ,OAAO,MAAM,OAAO,CAAC,EAAE,SAAS;AAAA,EAC1E;AAAA;AAGM,SAAS,qBAAqB,CAAC,KAAwB;AAAA,EAC7D,OAAO,iBAAiB,GAAG;AAAA;AAGrB,SAAS,oBAAoB,CAAC,KAAwB;AAAA,EAC5D,OAAO,gBAAgB,GAAG;AAAA;AAGpB,SAAS,uBAAuB,CAAC,eAA+B;AAAA,EACtE,OAAO,gBAAgB,iBAAiB,aAAa,CAAC,EACpD,OAAO,EAAE,QAAQ,OAAO,MAAM,OAAO,CAAC,EACtC,SAAS;AAAA;AAIL,SAAS,YAAY,CAAC,cAA8B;AAAA,EAC1D,MAAM,MAAM,gBAAgB,YAAY,EAAE,OAAO;AAAA,IAChD,QAAQ;AAAA,IACR,MAAM;AAAA,EACP,CAAC;AAAA,EACD,OAAO,WAAW,QAAQ,EAAE,OAAO,GAAG,EAAE,OAAO,WAAW,EAAE,MAAM,GAAG,EAAE;AAAA;AAGjE,SAAS,WAAW,CAAC,SAAiB,YAA+B;AAAA,EAC3E,OAAO,SAAS,MAAM,OAAO,KAAK,SAAS,MAAM,GAAG,UAAU,EAAE,SAC/D,QACD;AAAA;AAGM,SAAS,aAAa,CAC5B,SACA,iBACA,WACU;AAAA,EACV,IAAI;AAAA,IACH,OAAO,WACN,MACA,OAAO,KAAK,SAAS,MAAM,GAC3B,WACA,OAAO,KAAK,iBAAiB,QAAQ,CACtC;AAAA,IACC,MAAM;AAAA,IACP,OAAO;AAAA;AAAA;;;AC7CF,IAAM,8BAA8B;AA0CpC,SAAS,wBAAwB,CAAC,UAA+B;AAAA,EACvE,QAAQ,WAAW,IAAI,aAAa,OAAO,SAAS;AAAA,EACpD,OAAO,KAAK,UAAU,IAAI;AAAA;AAQpB,SAAS,cAAc,CAC7B,UACA,kBACiB;AAAA,EACjB,IAAI,CAAC,SAAS,WAAW;AAAA,IACxB,OAAO;AAAA,MACN,SAAS;AAAA,MACT,QAAQ;AAAA,MACR,QAAQ;AAAA,IACT;AAAA,EACD;AAAA,EACA,IAAI,QAAQ;AAAA,EACZ,IAAI;AAAA,IACH,QAAQ,cACP,yBAAyB,QAAQ,GACjC,SAAS,WACT,qBAAqB,gBAAgB,CACtC;AAAA,IACC,OAAO,KAAK;AAAA,IACb,OAAO;AAAA,MACN,SAAS;AAAA,MACT,QAAQ;AAAA,MACR,QAAQ,eAAe,QAAQ,IAAI,UAAU;AAAA,IAC9C;AAAA;AAAA,EAED,OAAO,QACJ;AAAA,IACA,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,QAAQ;AAAA,EACT,IACC;AAAA,IACA,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,QAAQ;AAAA,EACT;AAAA;AASI,SAAS,aAAa,CAC5B,UACA,OACA,UACiB;AAAA,EACjB,MAAM,MAAM,SAAS,KAAK,KAAK,CAAC,MAAM,EAAE,WAAW,KAAK;AAAA,EACxD,IAAI,CAAC,KAAK;AAAA,IACT,OAAO;AAAA,MACN,SAAS;AAAA,MACT,QAAQ;AAAA,MACR,QAAQ,OAAO;AAAA,IAChB;AAAA,EACD;AAAA,EACA,IAAI,IAAI,WAAW,eAAe;AAAA,IACjC,OAAO;AAAA,MACN,SAAS;AAAA,MACT,QAAQ;AAAA,MACR,QAAQ,OAAO,wBAAwB,IAAI,iBAAiB,KAAK,IAAI,oBAAoB;AAAA,IAC1F;AAAA,EACD;AAAA,EAEA,MAAM,aAAa,KAAK,MAAM,QAAQ;AAAA,EACtC,MAAM,OAAO,KAAK,MAAM,IAAI,UAAU;AAAA,EACtC,IAAI,OAAO,MAAM,UAAU,KAAK,OAAO,MAAM,IAAI,GAAG;AAAA,IACnD,OAAO;AAAA,MACN,SAAS;AAAA,MACT,QAAQ;AAAA,MACR,QAAQ;AAAA,IACT;AAAA,EACD;AAAA,EACA,IAAI,aAAa,MAAM;AAAA,IACtB,OAAO;AAAA,MACN,SAAS;AAAA,MACT,QAAQ;AAAA,MACR,QAAQ,iCAAiC,cAAc,IAAI;AAAA,IAC5D;AAAA,EACD;AAAA,EACA,IAAI,IAAI,gBAAgB,MAAM;AAAA,IAC7B,MAAM,QAAQ,KAAK,MAAM,IAAI,WAAW;AAAA,IACxC,IAAI,CAAC,OAAO,MAAM,KAAK,KAAK,aAAa,OAAO;AAAA,MAC/C,OAAO;AAAA,QACN,SAAS;AAAA,QACT,QAAQ;AAAA,QACR,QAAQ,oCAAoC,cAAc,IAAI;AAAA,MAC/D;AAAA,IACD;AAAA,EACD;AAAA,EAIA,OAAO;AAAA,IACN,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,QACC,IAAI,WAAW,WACZ,wBAAwB,UACxB,aAAa,iCAAiC,IAAI;AAAA,EACvD;AAAA;AAIM,SAAS,YAAY,CAC3B,UACA,OACqB;AAAA,EACrB,OAAO,SAAS,KAAK,KAAK,CAAC,MAAM,EAAE,WAAW,KAAK,GAAG;AAAA;AAKhD,SAAS,QAAQ,CAAC,cAA8B;AAAA,EACtD,OAAO,aAAa,YAAY;AAAA;",
9
+ "debugId": "06FAC7E08C33616364756E2164756E21",
10
+ "names": []
11
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Progress reporting for multi-hour archive jobs.
3
+ *
4
+ * A job that prints nothing until it finishes is indistinguishable from a hung
5
+ * one. That is not a cosmetic problem: on 2026-08-12 an ran silently for
6
+ * 2.5 hours and progress had to be inferred by listing partition filenames on
7
+ * disk, and a killed upload left a log whose single line could not distinguish
8
+ * "died immediately" from "died at 90%".
9
+ *
10
+ * Two rules make the output trustworthy:
11
+ *
12
+ * - **Time-based, not count-based.** Reporting every N items goes quiet for
13
+ * an hour when items are large and floods when they are small. Reporting
14
+ * every N seconds behaves the same for a sparse range and a dense one, so
15
+ * silence always means the same thing: something is wrong.
16
+ * - **stderr only.** Progress is chrome. Putting it on stdout would corrupt
17
+ * `--json` output for every caller that pipes it.
18
+ */
19
+ type ProgressReporter = {
20
+ /** Record cumulative progress. Emits at most once per interval. */
21
+ tick: (completed: number, detail?: string) => void
22
+ /** Emit a final line regardless of interval. */
23
+ finish: (detail?: string) => void
24
+ };
25
+ type ProgressOptions = {
26
+ label: string
27
+ /** Total units, when known. Enables percentage and ETA. */
28
+ total?: number
29
+ intervalMs?: number
30
+ /** Defaults to stderr. Injectable so tests capture without spying. */
31
+ write?: (line: string) => void
32
+ now?: () => number
33
+ };
34
+ declare function formatDuration(seconds: number): string;
35
+ declare function createProgressReporter(options: ProgressOptions): ProgressReporter;
36
+ export { formatDuration, createProgressReporter, ProgressReporter, ProgressOptions };
@@ -0,0 +1,75 @@
1
+ import { createRequire } from "node:module";
2
+ var __defProp = Object.defineProperty;
3
+ var __returnValue = (v) => v;
4
+ function __exportSetter(name, newValue) {
5
+ this[name] = __returnValue.bind(null, newValue);
6
+ }
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true,
12
+ configurable: true,
13
+ set: __exportSetter.bind(all, name)
14
+ });
15
+ };
16
+
17
+ // src/archive/progress.ts
18
+ var DEFAULT_INTERVAL_MS = 30000;
19
+ function formatDuration(seconds) {
20
+ if (!Number.isFinite(seconds) || seconds < 0)
21
+ return "unknown";
22
+ if (seconds < 90)
23
+ return `${Math.round(seconds)}s`;
24
+ if (seconds < 5400)
25
+ return `${Math.round(seconds / 60)}m`;
26
+ if (seconds < 172800)
27
+ return `${(seconds / 3600).toFixed(1)}h`;
28
+ return `${(seconds / 86400).toFixed(1)}d`;
29
+ }
30
+ function createProgressReporter(options) {
31
+ const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
32
+ const now = options.now ?? (() => Date.now());
33
+ const write = options.write ?? ((line2) => process.stderr.write(`${line2}
34
+ `));
35
+ const startedAt = now();
36
+ let lastEmit = startedAt;
37
+ function line(completed, detail) {
38
+ const elapsed = (now() - startedAt) / 1000;
39
+ const parts = [`${options.label}:`];
40
+ if (options.total !== undefined && options.total > 0) {
41
+ const pct = Math.floor(completed / options.total * 100);
42
+ parts.push(`${completed}/${options.total} (${pct}%)`);
43
+ if (completed > 0 && elapsed > 0) {
44
+ const remaining = (options.total - completed) / completed * elapsed;
45
+ parts.push(`eta ${formatDuration(remaining)}`);
46
+ }
47
+ } else {
48
+ parts.push(String(completed));
49
+ }
50
+ parts.push(`elapsed ${formatDuration(elapsed)}`);
51
+ if (detail)
52
+ parts.push(`· ${detail}`);
53
+ return ` ${parts.join(" ")}`;
54
+ }
55
+ return {
56
+ tick(completed, detail) {
57
+ const current = now();
58
+ if (current - lastEmit < intervalMs)
59
+ return;
60
+ lastEmit = current;
61
+ write(line(completed, detail));
62
+ },
63
+ finish(detail) {
64
+ lastEmit = now();
65
+ write(line(options.total ?? 0, detail));
66
+ }
67
+ };
68
+ }
69
+ export {
70
+ formatDuration,
71
+ createProgressReporter
72
+ };
73
+
74
+ //# debugId=C1E6E800BC29361764756E2164756E21
75
+ //# sourceMappingURL=progress.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/archive/progress.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * Progress reporting for multi-hour archive jobs.\n *\n * A job that prints nothing until it finishes is indistinguishable from a hung\n * one. That is not a cosmetic problem: on 2026-08-12 an export ran silently for\n * 2.5 hours and progress had to be inferred by listing partition filenames on\n * disk, and a killed upload left a log whose single line could not distinguish\n * \"died immediately\" from \"died at 90%\".\n *\n * Two rules make the output trustworthy:\n *\n * - **Time-based, not count-based.** Reporting every N items goes quiet for\n * an hour when items are large and floods when they are small. Reporting\n * every N seconds behaves the same for a sparse range and a dense one, so\n * silence always means the same thing: something is wrong.\n * - **stderr only.** Progress is chrome. Putting it on stdout would corrupt\n * `--json` output for every caller that pipes it.\n */\n\nexport type ProgressReporter = {\n\t/** Record cumulative progress. Emits at most once per interval. */\n\ttick: (completed: number, detail?: string) => void;\n\t/** Emit a final line regardless of interval. */\n\tfinish: (detail?: string) => void;\n};\n\nexport type ProgressOptions = {\n\tlabel: string;\n\t/** Total units, when known. Enables percentage and ETA. */\n\ttotal?: number;\n\tintervalMs?: number;\n\t/** Defaults to stderr. Injectable so tests capture without spying. */\n\twrite?: (line: string) => void;\n\tnow?: () => number;\n};\n\nconst DEFAULT_INTERVAL_MS = 30_000;\n\nexport function formatDuration(seconds: number): string {\n\tif (!Number.isFinite(seconds) || seconds < 0) return \"unknown\";\n\tif (seconds < 90) return `${Math.round(seconds)}s`;\n\tif (seconds < 5_400) return `${Math.round(seconds / 60)}m`;\n\tif (seconds < 172_800) return `${(seconds / 3_600).toFixed(1)}h`;\n\treturn `${(seconds / 86_400).toFixed(1)}d`;\n}\n\nexport function createProgressReporter(\n\toptions: ProgressOptions,\n): ProgressReporter {\n\tconst intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;\n\tconst now = options.now ?? (() => Date.now());\n\tconst write =\n\t\toptions.write ?? ((line: string) => process.stderr.write(`${line}\\n`));\n\n\tconst startedAt = now();\n\tlet lastEmit = startedAt;\n\n\tfunction line(completed: number, detail?: string): string {\n\t\tconst elapsed = (now() - startedAt) / 1000;\n\t\tconst parts = [`${options.label}:`];\n\n\t\tif (options.total !== undefined && options.total > 0) {\n\t\t\tconst pct = Math.floor((completed / options.total) * 100);\n\t\t\tparts.push(`${completed}/${options.total} (${pct}%)`);\n\t\t\t// ETA only once there is a rate to extrapolate from; a prediction made\n\t\t\t// from zero progress is noise dressed as information.\n\t\t\tif (completed > 0 && elapsed > 0) {\n\t\t\t\tconst remaining = ((options.total - completed) / completed) * elapsed;\n\t\t\t\tparts.push(`eta ${formatDuration(remaining)}`);\n\t\t\t}\n\t\t} else {\n\t\t\tparts.push(String(completed));\n\t\t}\n\n\t\tparts.push(`elapsed ${formatDuration(elapsed)}`);\n\t\tif (detail) parts.push(`· ${detail}`);\n\t\treturn ` ${parts.join(\" \")}`;\n\t}\n\n\treturn {\n\t\ttick(completed, detail) {\n\t\t\tconst current = now();\n\t\t\tif (current - lastEmit < intervalMs) return;\n\t\t\tlastEmit = current;\n\t\t\twrite(line(completed, detail));\n\t\t},\n\t\tfinish(detail) {\n\t\t\tlastEmit = now();\n\t\t\twrite(line(options.total ?? 0, detail));\n\t\t},\n\t};\n}\n"
6
+ ],
7
+ "mappings": ";;;;;;;;;;;;;;;;;AAoCA,IAAM,sBAAsB;AAErB,SAAS,cAAc,CAAC,SAAyB;AAAA,EACvD,IAAI,CAAC,OAAO,SAAS,OAAO,KAAK,UAAU;AAAA,IAAG,OAAO;AAAA,EACrD,IAAI,UAAU;AAAA,IAAI,OAAO,GAAG,KAAK,MAAM,OAAO;AAAA,EAC9C,IAAI,UAAU;AAAA,IAAO,OAAO,GAAG,KAAK,MAAM,UAAU,EAAE;AAAA,EACtD,IAAI,UAAU;AAAA,IAAS,OAAO,IAAI,UAAU,MAAO,QAAQ,CAAC;AAAA,EAC5D,OAAO,IAAI,UAAU,OAAQ,QAAQ,CAAC;AAAA;AAGhC,SAAS,sBAAsB,CACrC,SACmB;AAAA,EACnB,MAAM,aAAa,QAAQ,cAAc;AAAA,EACzC,MAAM,MAAM,QAAQ,QAAQ,MAAM,KAAK,IAAI;AAAA,EAC3C,MAAM,QACL,QAAQ,UAAU,CAAC,UAAiB,QAAQ,OAAO,MAAM,GAAG;AAAA,CAAQ;AAAA,EAErE,MAAM,YAAY,IAAI;AAAA,EACtB,IAAI,WAAW;AAAA,EAEf,SAAS,IAAI,CAAC,WAAmB,QAAyB;AAAA,IACzD,MAAM,WAAW,IAAI,IAAI,aAAa;AAAA,IACtC,MAAM,QAAQ,CAAC,GAAG,QAAQ,QAAQ;AAAA,IAElC,IAAI,QAAQ,UAAU,aAAa,QAAQ,QAAQ,GAAG;AAAA,MACrD,MAAM,MAAM,KAAK,MAAO,YAAY,QAAQ,QAAS,GAAG;AAAA,MACxD,MAAM,KAAK,GAAG,aAAa,QAAQ,UAAU,OAAO;AAAA,MAGpD,IAAI,YAAY,KAAK,UAAU,GAAG;AAAA,QACjC,MAAM,aAAc,QAAQ,QAAQ,aAAa,YAAa;AAAA,QAC9D,MAAM,KAAK,OAAO,eAAe,SAAS,GAAG;AAAA,MAC9C;AAAA,IACD,EAAO;AAAA,MACN,MAAM,KAAK,OAAO,SAAS,CAAC;AAAA;AAAA,IAG7B,MAAM,KAAK,WAAW,eAAe,OAAO,GAAG;AAAA,IAC/C,IAAI;AAAA,MAAQ,MAAM,KAAK,KAAI,QAAQ;AAAA,IACnC,OAAO,KAAK,MAAM,KAAK,GAAG;AAAA;AAAA,EAG3B,OAAO;AAAA,IACN,IAAI,CAAC,WAAW,QAAQ;AAAA,MACvB,MAAM,UAAU,IAAI;AAAA,MACpB,IAAI,UAAU,WAAW;AAAA,QAAY;AAAA,MACrC,WAAW;AAAA,MACX,MAAM,KAAK,WAAW,MAAM,CAAC;AAAA;AAAA,IAE9B,MAAM,CAAC,QAAQ;AAAA,MACd,WAAW,IAAI;AAAA,MACf,MAAM,KAAK,QAAQ,SAAS,GAAG,MAAM,CAAC;AAAA;AAAA,EAExC;AAAA;",
8
+ "debugId": "C1E6E800BC29361764756E2164756E21",
9
+ "names": []
10
+ }