@forgezero/runtime 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -5,7 +5,7 @@ themselves, keyed queues, a transactional outbox, a hash-chained audit trail,
5
5
  templated mail, encrypted backups, schema validation, and money that is never a
6
6
  floating-point number.
7
7
 
8
- Thirty-three public modules. Each is its own entry point, so you install one package and
8
+ Thirty-four public modules. Each is its own entry point, so you install one package and
9
9
  your bundler includes only what you imported.
10
10
 
11
11
  ```bash
@@ -24,7 +24,7 @@ import { createScheduler } from '@forgezero/runtime/jobs';
24
24
 
25
25
  | | |
26
26
  |---|---|
27
- | **Spine** | `jobs` `queue` `outbox` |
27
+ | **Spine** | `query` `jobs` `queue` `outbox` |
28
28
  | **Record** | `audit` `backup` `compliance` `calendar` |
29
29
  | **Identity** | `identity` `totp` `notify` `notify/templates` `schema` `schema/typebox` |
30
30
  | **Finance** | `finance/money` `finance/storage` `finance/ledger` `finance/commission` `finance/rates` `finance/transfers` `finance/tax` `finance/chain` `finance/custody` `finance/derive` `finance/venues` `finance/binance` |
@@ -32,6 +32,28 @@ import { createScheduler } from '@forgezero/runtime/jobs';
32
32
  None of it knows what your product does. You bind your own rules to these; you
33
33
  do not fork them.
34
34
 
35
+ ## Typed function queries
36
+
37
+ `query` binds typed input/output codecs to an ordinary function and any context
38
+ you choose. It has no database or ForgeZero realm dependency. Inputs may decode
39
+ wire values; outputs are checked strictly so a wrong handler result cannot be
40
+ coerced into looking correct.
41
+
42
+ ```ts
43
+ import { defineQuery, implementQuery } from '@forgezero/runtime/query';
44
+ import { typeboxQueryCodec, T } from '@forgezero/runtime/schema/typebox';
45
+
46
+ const contract = defineQuery({
47
+ name: 'invoice.by-reference',
48
+ input: typeboxQueryCodec(T.Object({ reference: T.String() })),
49
+ output: typeboxQueryCodec(T.Object({ total: T.String() }))
50
+ });
51
+
52
+ const findInvoice = implementQuery(contract, async (services: MyServices, input, { signal }) =>
53
+ services.invoices.find(input.reference, { signal })
54
+ );
55
+ ```
56
+
35
57
  ## One reusable async queue
36
58
 
37
59
  `queue` is deliberately memory-only: it accepts a function, returns that
package/dist/backup.d.ts CHANGED
@@ -32,11 +32,11 @@
32
32
  * refuse rather than doing their best.
33
33
  */
34
34
  export declare class BackupError extends Error {
35
- readonly code: 'CHUNK_MISSING' | 'DIGEST_MISMATCH' | 'REALM_MISMATCH' | 'BAD_MANIFEST' | 'UNSUPPORTED_FORMAT' | 'DECRYPT_FAILED' | 'EMPTY_SNAPSHOT' | 'RETENTION_WOULD_EMPTY';
36
- constructor(code: 'CHUNK_MISSING' | 'DIGEST_MISMATCH' | 'REALM_MISMATCH' | 'BAD_MANIFEST' | 'UNSUPPORTED_FORMAT' | 'DECRYPT_FAILED' | 'EMPTY_SNAPSHOT' | 'RETENTION_WOULD_EMPTY', message: string);
35
+ readonly code: 'CHUNK_MISSING' | 'DIGEST_MISMATCH' | 'REALM_MISMATCH' | 'BAD_MANIFEST' | 'UNSUPPORTED_FORMAT' | 'DECRYPT_FAILED' | 'MAINTENANCE_REQUIRED' | 'EMPTY_SNAPSHOT' | 'RETENTION_WOULD_EMPTY';
36
+ constructor(code: 'CHUNK_MISSING' | 'DIGEST_MISMATCH' | 'REALM_MISMATCH' | 'BAD_MANIFEST' | 'UNSUPPORTED_FORMAT' | 'DECRYPT_FAILED' | 'MAINTENANCE_REQUIRED' | 'EMPTY_SNAPSHOT' | 'RETENTION_WOULD_EMPTY', message: string);
37
37
  }
38
38
  /** Bumped only for a change that an older reader cannot handle. */
39
- export declare const SNAPSHOT_FORMAT = 1;
39
+ export declare const SNAPSHOT_FORMAT = 2;
40
40
  export interface ChunkRecord {
41
41
  index: number;
42
42
  /** Object key in the store. */
@@ -55,7 +55,11 @@ export interface SnapshotManifest {
55
55
  collections: Record<string, number>;
56
56
  chunks: readonly ChunkRecord[];
57
57
  totalRows: number;
58
- /** Digest over every chunk digest — one value that fixes the whole snapshot. */
58
+ /**
59
+ * HMAC-SHA-256 over the canonical manifest body. Despite the legacy field
60
+ * name this is a keyed authenticator, not a plain digest. Format 1 used an
61
+ * unkeyed digest and is deliberately not accepted by this reader.
62
+ */
59
63
  sealDigest: string;
60
64
  /** Free-form: schema version, application version, who triggered it. */
61
65
  labels?: Record<string, string>;
@@ -76,8 +80,14 @@ export interface ObjectStore {
76
80
  }
77
81
  /** Where the rows come from. Async-iterable so a large collection never lands in memory whole. */
78
82
  export interface RowSource {
83
+ /** Acquire a point-in-time source transaction or equivalent read fence. */
84
+ begin?(): Promise<void>;
79
85
  collections(): Promise<string[]>;
80
86
  rows(collection: string): AsyncIterable<Record<string, unknown>>;
87
+ /** Release the read fence after every row has been captured. */
88
+ commit?(): Promise<void>;
89
+ /** Release it after an interrupted or failed capture. */
90
+ abort?(): Promise<void>;
81
91
  }
82
92
  /** Where they go on the way back. */
83
93
  export interface RowSink {
@@ -90,7 +100,10 @@ export interface RowSink {
90
100
  write(collection: string, rows: Record<string, unknown>[]): Promise<void>;
91
101
  /** Called before anything is written. The sink's chance to refuse or truncate. */
92
102
  begin?(manifest: SnapshotManifest): Promise<void>;
103
+ /** Commit all staged rows atomically. */
93
104
  finish?(manifest: SnapshotManifest): Promise<void>;
105
+ /** Roll back all staged rows after any write or commit failure. */
106
+ abort?(manifest: SnapshotManifest): Promise<void>;
94
107
  }
95
108
  export interface SnapshotOptions {
96
109
  store: ObjectStore;
@@ -174,6 +187,7 @@ export declare function restore(options: RestoreOptions): Promise<RestoreReport>
174
187
  export declare function listSnapshots(args: {
175
188
  store: ObjectStore;
176
189
  realm: string;
190
+ masterSeed: Uint8Array;
177
191
  prefix?: string;
178
192
  }): Promise<SnapshotManifest[]>;
179
193
  export interface RetentionPolicy {
@@ -200,6 +214,7 @@ export declare function selectForDeletion(manifests: readonly SnapshotManifest[]
200
214
  export declare function prune(args: {
201
215
  store: ObjectStore;
202
216
  realm: string;
217
+ masterSeed: Uint8Array;
203
218
  policy?: RetentionPolicy;
204
219
  prefix?: string;
205
220
  /** Report what would go without removing it. */
package/dist/backup.js CHANGED
@@ -7,7 +7,14 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
7
7
  });
8
8
 
9
9
  // src/backup.ts
10
- import { hkdf, seal, open, sha256, timingSafeEqual } from "@forgezero/access/security";
10
+ import {
11
+ hkdf,
12
+ hmacSha256,
13
+ seal,
14
+ open,
15
+ sha256,
16
+ timingSafeEqual
17
+ } from "@forgezero/access/security";
11
18
 
12
19
  class BackupError extends Error {
13
20
  code;
@@ -17,15 +24,145 @@ class BackupError extends Error {
17
24
  this.name = "BackupError";
18
25
  }
19
26
  }
20
- var SNAPSHOT_FORMAT = 1;
27
+ var SNAPSHOT_FORMAT = 2;
21
28
  var keyFor = (masterSeed, realm, snapshotId) => hkdf(masterSeed, `forgezero:backup:v1:${realm}:${snapshotId}`, 32);
29
+ var manifestKeyFor = (masterSeed, realm, snapshotId) => hkdf(masterSeed, `forgezero:backup:manifest:v2:${realm}:${snapshotId}`, 32);
22
30
  var aadFor = (realm, snapshotId, index) => `backup:${realm}:${snapshotId}:${index}`;
23
31
  var objectKey = (prefix, realm, id, part) => `${prefix}${realm}/${id}/${part}`;
32
+ function canonicalManifest(manifest) {
33
+ return JSON.stringify([
34
+ manifest.format,
35
+ manifest.id,
36
+ manifest.realm,
37
+ manifest.createdAtMs,
38
+ Object.entries(manifest.collections).sort(([left], [right]) => left.localeCompare(right)),
39
+ manifest.chunks.map((chunk) => [
40
+ chunk.index,
41
+ chunk.object,
42
+ chunk.digest,
43
+ chunk.rows,
44
+ chunk.bytes
45
+ ]),
46
+ manifest.totalRows,
47
+ manifest.labels ? Object.entries(manifest.labels).sort(([left], [right]) => left.localeCompare(right)) : null
48
+ ]);
49
+ }
50
+ async function sealManifest(manifest, masterSeed) {
51
+ const key = await manifestKeyFor(masterSeed, manifest.realm, manifest.id);
52
+ return hmacSha256(key, canonicalManifest(manifest));
53
+ }
54
+ var MANIFEST_KEYS = new Set([
55
+ "format",
56
+ "id",
57
+ "realm",
58
+ "createdAtMs",
59
+ "collections",
60
+ "chunks",
61
+ "totalRows",
62
+ "sealDigest",
63
+ "labels"
64
+ ]);
65
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
66
+ function manifestProblem(value) {
67
+ if (!isRecord(value))
68
+ return "The manifest is not an object.";
69
+ if (Object.keys(value).some((key) => !MANIFEST_KEYS.has(key)))
70
+ return "The manifest has unknown fields.";
71
+ if (value.format !== SNAPSHOT_FORMAT) {
72
+ return `Snapshot format ${String(value.format)} is not supported by this reader (${SNAPSHOT_FORMAT}).`;
73
+ }
74
+ if (typeof value.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value.id)) {
75
+ return "The snapshot id is invalid.";
76
+ }
77
+ if (typeof value.realm !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value.realm)) {
78
+ return "The snapshot realm is invalid.";
79
+ }
80
+ if (!Number.isSafeInteger(value.createdAtMs) || value.createdAtMs <= 0) {
81
+ return "The snapshot creation time is invalid.";
82
+ }
83
+ if (!isRecord(value.collections) || Object.keys(value.collections).length === 0) {
84
+ return "The manifest has no collection inventory.";
85
+ }
86
+ for (const [collection, count] of Object.entries(value.collections)) {
87
+ if (!/^[a-z][a-z0-9_]*$/.test(collection) || !Number.isSafeInteger(count) || count < 0) {
88
+ return `The collection inventory entry "${collection}" is invalid.`;
89
+ }
90
+ }
91
+ if (!Array.isArray(value.chunks) || value.chunks.length === 0)
92
+ return "The manifest has no chunks.";
93
+ if (!Number.isSafeInteger(value.totalRows) || value.totalRows <= 0) {
94
+ return "The manifest row total is invalid.";
95
+ }
96
+ if (typeof value.sealDigest !== "string" || !/^[a-f0-9]{64}$/.test(value.sealDigest)) {
97
+ return "The manifest authenticator is invalid.";
98
+ }
99
+ if (value.labels !== undefined) {
100
+ if (!isRecord(value.labels))
101
+ return "The manifest labels are invalid.";
102
+ for (const [label, content] of Object.entries(value.labels)) {
103
+ if (label.length === 0 || label.length > 128 || typeof content !== "string" || content.length > 1024) {
104
+ return `The manifest label "${label}" is invalid.`;
105
+ }
106
+ }
107
+ }
108
+ let chunkRows = 0;
109
+ let root;
110
+ for (const [position, candidate] of value.chunks.entries()) {
111
+ if (!isRecord(candidate))
112
+ return `Chunk ${position} is not an object.`;
113
+ const keys = Object.keys(candidate).sort().join(",");
114
+ if (keys !== "bytes,digest,index,object,rows")
115
+ return `Chunk ${position} has an invalid shape.`;
116
+ if (candidate.index !== position)
117
+ return `Chunk indexes are not contiguous at position ${position}.`;
118
+ if (typeof candidate.object !== "string" || candidate.object.length > 1024) {
119
+ return `Chunk ${position} has an invalid object key.`;
120
+ }
121
+ const filename = `chunk-${String(position).padStart(5, "0")}.json`;
122
+ const suffix = `${value.realm}/${value.id}/${filename}`;
123
+ if (!candidate.object.endsWith(suffix))
124
+ return `Chunk ${position} is outside the snapshot prefix.`;
125
+ const candidateRoot = candidate.object.slice(0, -filename.length);
126
+ root ??= candidateRoot;
127
+ if (candidateRoot !== root)
128
+ return `Chunk ${position} does not share the snapshot prefix.`;
129
+ if (typeof candidate.digest !== "string" || !/^[a-f0-9]{64}$/.test(candidate.digest)) {
130
+ return `Chunk ${position} has an invalid digest.`;
131
+ }
132
+ if (!Number.isSafeInteger(candidate.rows) || candidate.rows <= 0) {
133
+ return `Chunk ${position} has an invalid row count.`;
134
+ }
135
+ if (!Number.isSafeInteger(candidate.bytes) || candidate.bytes <= 0) {
136
+ return `Chunk ${position} has an invalid byte count.`;
137
+ }
138
+ chunkRows += candidate.rows;
139
+ }
140
+ if (chunkRows !== value.totalRows)
141
+ return "Chunk row counts do not equal the manifest total.";
142
+ const collectionRows = Object.values(value.collections).reduce((sum, count) => sum + count, 0);
143
+ if (collectionRows !== value.totalRows)
144
+ return "Collection row counts do not equal the manifest total.";
145
+ return null;
146
+ }
24
147
  async function snapshot(options) {
25
148
  const now = options.now ?? Date.now;
26
149
  const chunkRows = options.chunkRows ?? 1000;
27
150
  const prefix = options.prefix ?? "snapshots/";
28
- const id = options.id ?? `snap_${new Date(now()).toISOString().replace(/[-:.]/g, "").slice(0, 15)}`;
151
+ const nonce = crypto.getRandomValues(new Uint8Array(8));
152
+ const randomSuffix = Array.from(nonce, (byte) => byte.toString(16).padStart(2, "0")).join("");
153
+ const id = options.id ?? `snap_${new Date(now()).toISOString().replace(/[-:.TZ]/g, "")}_${randomSuffix}`;
154
+ if (!Number.isSafeInteger(chunkRows) || chunkRows <= 0) {
155
+ throw new BackupError("BAD_MANIFEST", "chunkRows must be a positive safe integer.");
156
+ }
157
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(options.realm)) {
158
+ throw new BackupError("BAD_MANIFEST", "The snapshot realm is not safe for an object key.");
159
+ }
160
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(id)) {
161
+ throw new BackupError("BAD_MANIFEST", "The snapshot id is not safe for an object key.");
162
+ }
163
+ if (!prefix || prefix.includes("..") || prefix.startsWith("/") || !prefix.endsWith("/")) {
164
+ throw new BackupError("BAD_MANIFEST", "The snapshot prefix must be a relative object prefix ending in /.");
165
+ }
29
166
  const key = await keyFor(options.masterSeed, options.realm, id);
30
167
  const chunks = [];
31
168
  const collections = {};
@@ -47,26 +184,38 @@ async function snapshot(options) {
47
184
  bytes: body.length
48
185
  });
49
186
  };
50
- for (const collection of await options.source.collections()) {
51
- let batch = [];
52
- let count = 0;
53
- for await (const row of options.source.rows(collection)) {
54
- batch.push(row);
55
- count += 1;
56
- if (batch.length >= chunkRows) {
57
- await flush(collection, batch);
58
- batch = [];
187
+ let sourceFinalized = false;
188
+ try {
189
+ await options.source.begin?.();
190
+ for (const collection of await options.source.collections()) {
191
+ let batch = [];
192
+ let count = 0;
193
+ for await (const row of options.source.rows(collection)) {
194
+ batch.push(row);
195
+ count += 1;
196
+ if (batch.length >= chunkRows) {
197
+ await flush(collection, batch);
198
+ batch = [];
199
+ }
59
200
  }
201
+ await flush(collection, batch);
202
+ collections[collection] = count;
203
+ totalRows += count;
204
+ options.onProgress?.({ collection, rows: count, chunks: chunks.length });
60
205
  }
61
- await flush(collection, batch);
62
- collections[collection] = count;
63
- totalRows += count;
64
- options.onProgress?.({ collection, rows: count, chunks: chunks.length });
206
+ await options.source.commit?.();
207
+ sourceFinalized = true;
208
+ } catch (cause) {
209
+ if (!sourceFinalized)
210
+ await options.source.abort?.().catch(() => {
211
+ return;
212
+ });
213
+ throw cause;
65
214
  }
66
215
  if (totalRows === 0) {
67
216
  throw new BackupError("EMPTY_SNAPSHOT", "The source produced no rows. Refusing to write an empty snapshot.");
68
217
  }
69
- const manifest = {
218
+ const unsigned = {
70
219
  format: SNAPSHOT_FORMAT,
71
220
  id,
72
221
  realm: options.realm,
@@ -74,9 +223,12 @@ async function snapshot(options) {
74
223
  collections,
75
224
  chunks,
76
225
  totalRows,
77
- sealDigest: await sha256(chunks.map((chunk) => chunk.digest).join("")),
78
226
  ...options.labels ? { labels: options.labels } : {}
79
227
  };
228
+ const manifest = {
229
+ ...unsigned,
230
+ sealDigest: await sealManifest(unsigned, options.masterSeed)
231
+ };
80
232
  await options.store.putObject({
81
233
  key: objectKey(prefix, options.realm, id, "manifest.json"),
82
234
  body: JSON.stringify(manifest, null, 2),
@@ -86,9 +238,9 @@ async function snapshot(options) {
86
238
  }
87
239
  async function verifySnapshot(args) {
88
240
  const problems = [];
89
- const key = await keyFor(args.masterSeed, args.manifest.realm, args.manifest.id);
90
241
  let rowsChecked = 0;
91
- if (args.manifest.format > SNAPSHOT_FORMAT) {
242
+ const invalid = manifestProblem(args.manifest);
243
+ if (invalid) {
92
244
  return {
93
245
  ok: false,
94
246
  id: args.manifest.id,
@@ -97,12 +249,25 @@ async function verifySnapshot(args) {
97
249
  problems: [
98
250
  {
99
251
  chunk: -1,
100
- code: "UNSUPPORTED_FORMAT",
101
- message: `Snapshot format ${args.manifest.format} is newer than this reader (${SNAPSHOT_FORMAT}).`
252
+ code: args.manifest.format !== SNAPSHOT_FORMAT ? "UNSUPPORTED_FORMAT" : "BAD_MANIFEST",
253
+ message: invalid
102
254
  }
103
255
  ]
104
256
  };
105
257
  }
258
+ const { sealDigest, ...unsigned } = args.manifest;
259
+ const expectedSeal = await sealManifest(unsigned, args.masterSeed);
260
+ if (!timingSafeEqual(expectedSeal, sealDigest)) {
261
+ return {
262
+ ok: false,
263
+ id: args.manifest.id,
264
+ chunksChecked: 0,
265
+ rowsChecked: 0,
266
+ problems: [{ chunk: -1, code: "BAD_MANIFEST", message: "The manifest did not authenticate." }]
267
+ };
268
+ }
269
+ const key = await keyFor(args.masterSeed, args.manifest.realm, args.manifest.id);
270
+ const collectionRows = {};
106
271
  for (const chunk of args.manifest.chunks) {
107
272
  let raw;
108
273
  try {
@@ -135,15 +300,34 @@ async function verifySnapshot(args) {
135
300
  });
136
301
  continue;
137
302
  }
138
- rowsChecked += JSON.parse(plaintext).rows.length;
303
+ let parsed;
304
+ try {
305
+ parsed = JSON.parse(plaintext);
306
+ } catch {
307
+ problems.push({ chunk: chunk.index, code: "BAD_MANIFEST", message: `${chunk.object} is not JSON.` });
308
+ continue;
309
+ }
310
+ if (!isRecord(parsed) || typeof parsed.collection !== "string" || !Object.hasOwn(args.manifest.collections, parsed.collection) || !Array.isArray(parsed.rows) || parsed.rows.length !== chunk.rows || parsed.rows.some((row) => !isRecord(row))) {
311
+ problems.push({
312
+ chunk: chunk.index,
313
+ code: "BAD_MANIFEST",
314
+ message: `${chunk.object} does not match its declared collection and row count.`
315
+ });
316
+ continue;
317
+ }
318
+ collectionRows[parsed.collection] = (collectionRows[parsed.collection] ?? 0) + parsed.rows.length;
319
+ rowsChecked += parsed.rows.length;
139
320
  }
140
- const sealDigest = await sha256(args.manifest.chunks.map((chunk) => chunk.digest).join(""));
141
- if (!timingSafeEqual(sealDigest, args.manifest.sealDigest)) {
142
- problems.push({
143
- chunk: -1,
144
- code: "BAD_MANIFEST",
145
- message: "The manifest does not match its own seal — a chunk was added to or removed from the list."
146
- });
321
+ if (problems.length === 0) {
322
+ for (const [collection, expectedRows] of Object.entries(args.manifest.collections)) {
323
+ if ((collectionRows[collection] ?? 0) === expectedRows)
324
+ continue;
325
+ problems.push({
326
+ chunk: -1,
327
+ code: "BAD_MANIFEST",
328
+ message: `Collection ${collection} restored ${collectionRows[collection] ?? 0} rows, expected ${expectedRows}.`
329
+ });
330
+ }
147
331
  }
148
332
  return {
149
333
  ok: problems.length === 0,
@@ -158,8 +342,8 @@ async function restore(options) {
158
342
  if (manifest.realm !== options.realm) {
159
343
  throw new BackupError("REALM_MISMATCH", `This snapshot belongs to realm "${manifest.realm}" and cannot be restored into "${options.realm}".`);
160
344
  }
161
- if (manifest.format > SNAPSHOT_FORMAT) {
162
- throw new BackupError("UNSUPPORTED_FORMAT", `Snapshot format ${manifest.format} is newer than this reader (${SNAPSHOT_FORMAT}). Restoring it would silently drop whatever the newer format added.`);
345
+ if (manifest.format !== SNAPSHOT_FORMAT) {
346
+ throw new BackupError("UNSUPPORTED_FORMAT", `Snapshot format ${manifest.format} is not supported by this reader (${SNAPSHOT_FORMAT}).`);
163
347
  }
164
348
  if (!options.skipVerify) {
165
349
  const report = await verifySnapshot({
@@ -172,21 +356,31 @@ async function restore(options) {
172
356
  throw new BackupError(first.code, `Refusing to restore: ${report.problems.length} problem(s), first at chunk ${first.chunk} — ${first.message}`);
173
357
  }
174
358
  }
175
- await options.sink.begin?.(manifest);
176
359
  const key = await keyFor(options.masterSeed, manifest.realm, manifest.id);
177
360
  const collections = {};
178
361
  let rowsRestored = 0;
179
- for (const chunk of manifest.chunks) {
180
- const raw = await options.store.getObject(chunk.object);
181
- const sealed = JSON.parse(new TextDecoder().decode(raw));
182
- const plaintext = await open(key, sealed, aadFor(manifest.realm, manifest.id, chunk.index));
183
- const { collection, rows } = JSON.parse(plaintext);
184
- await options.sink.write(collection, rows);
185
- collections[collection] = (collections[collection] ?? 0) + rows.length;
186
- rowsRestored += rows.length;
187
- options.onProgress?.({ chunk: chunk.index + 1, of: manifest.chunks.length, rows: rowsRestored });
362
+ let sinkFinalized = false;
363
+ try {
364
+ await options.sink.begin?.(manifest);
365
+ for (const chunk of manifest.chunks) {
366
+ const raw = await options.store.getObject(chunk.object);
367
+ const sealed = JSON.parse(new TextDecoder().decode(raw));
368
+ const plaintext = await open(key, sealed, aadFor(manifest.realm, manifest.id, chunk.index));
369
+ const { collection, rows } = JSON.parse(plaintext);
370
+ await options.sink.write(collection, rows);
371
+ collections[collection] = (collections[collection] ?? 0) + rows.length;
372
+ rowsRestored += rows.length;
373
+ options.onProgress?.({ chunk: chunk.index + 1, of: manifest.chunks.length, rows: rowsRestored });
374
+ }
375
+ await options.sink.finish?.(manifest);
376
+ sinkFinalized = true;
377
+ } catch (cause) {
378
+ if (!sinkFinalized)
379
+ await options.sink.abort?.(manifest).catch(() => {
380
+ return;
381
+ });
382
+ throw cause;
188
383
  }
189
- await options.sink.finish?.(manifest);
190
384
  return { id: manifest.id, rowsRestored, collections };
191
385
  }
192
386
  async function listSnapshots(args) {
@@ -197,7 +391,14 @@ async function listSnapshots(args) {
197
391
  if (!object.key.endsWith("/manifest.json"))
198
392
  continue;
199
393
  try {
200
- manifests.push(JSON.parse(new TextDecoder().decode(await args.store.getObject(object.key))));
394
+ const manifest = JSON.parse(new TextDecoder().decode(await args.store.getObject(object.key)));
395
+ const invalid = manifestProblem(manifest);
396
+ if (invalid || manifest.realm !== args.realm)
397
+ continue;
398
+ const { sealDigest, ...unsigned } = manifest;
399
+ if (!timingSafeEqual(await sealManifest(unsigned, args.masterSeed), sealDigest))
400
+ continue;
401
+ manifests.push(manifest);
201
402
  } catch {}
202
403
  }
203
404
  return manifests.sort((a, b) => b.createdAtMs - a.createdAtMs);
@@ -245,7 +446,12 @@ function selectForDeletion(manifests, policy = DEFAULT_RETENTION) {
245
446
  return newestFirst.filter((manifest) => !keep.has(manifest.id));
246
447
  }
247
448
  async function prune(args) {
248
- const manifests = await listSnapshots({ store: args.store, realm: args.realm, prefix: args.prefix });
449
+ const manifests = await listSnapshots({
450
+ store: args.store,
451
+ realm: args.realm,
452
+ masterSeed: args.masterSeed,
453
+ prefix: args.prefix
454
+ });
249
455
  const doomed = selectForDeletion(manifests, args.policy);
250
456
  if (doomed.length >= manifests.length) {
251
457
  throw new BackupError("RETENTION_WOULD_EMPTY", "This policy would delete every snapshot. Refusing.");
@@ -276,6 +482,7 @@ function backupJob(options) {
276
482
  const pruned = await prune({
277
483
  store: options.store,
278
484
  realm: options.realm,
485
+ masterSeed: options.masterSeed,
279
486
  policy: options.policy,
280
487
  prefix: options.prefix
281
488
  });
@@ -168,10 +168,14 @@ function parseAccount(id) {
168
168
  return { kind, owner, bucket };
169
169
  }
170
170
  var queueKeyFor = (account) => `${account.kind}:${account.owner}`;
171
+ var MAX_LEDGER_ENTRIES = 64;
171
172
  function assertBalanced(transaction) {
172
- if (transaction.entries.length === 0) {
173
+ if (transaction.entries.length < 2) {
173
174
  throw new LedgerError("EMPTY_TRANSACTION", "A transaction needs at least two entries.");
174
175
  }
176
+ if (transaction.entries.length > MAX_LEDGER_ENTRIES) {
177
+ throw new LedgerError("TOO_MANY_ENTRIES", `A transaction may contain at most ${MAX_LEDGER_ENTRIES} entries; split this batch into separate transactions.`);
178
+ }
175
179
  const totals = new Map;
176
180
  for (const entry of transaction.entries) {
177
181
  if (entry.amount.units === 0n) {
@@ -168,10 +168,14 @@ function parseAccount(id) {
168
168
  return { kind, owner, bucket };
169
169
  }
170
170
  var queueKeyFor = (account) => `${account.kind}:${account.owner}`;
171
+ var MAX_LEDGER_ENTRIES = 64;
171
172
  function assertBalanced(transaction) {
172
- if (transaction.entries.length === 0) {
173
+ if (transaction.entries.length < 2) {
173
174
  throw new LedgerError("EMPTY_TRANSACTION", "A transaction needs at least two entries.");
174
175
  }
176
+ if (transaction.entries.length > MAX_LEDGER_ENTRIES) {
177
+ throw new LedgerError("TOO_MANY_ENTRIES", `A transaction may contain at most ${MAX_LEDGER_ENTRIES} entries; split this batch into separate transactions.`);
178
+ }
175
179
  const totals = new Map;
176
180
  for (const entry of transaction.entries) {
177
181
  if (entry.amount.units === 0n) {
@@ -168,10 +168,14 @@ function parseAccount(id) {
168
168
  return { kind, owner, bucket };
169
169
  }
170
170
  var queueKeyFor = (account) => `${account.kind}:${account.owner}`;
171
+ var MAX_LEDGER_ENTRIES = 64;
171
172
  function assertBalanced(transaction) {
172
- if (transaction.entries.length === 0) {
173
+ if (transaction.entries.length < 2) {
173
174
  throw new LedgerError("EMPTY_TRANSACTION", "A transaction needs at least two entries.");
174
175
  }
176
+ if (transaction.entries.length > MAX_LEDGER_ENTRIES) {
177
+ throw new LedgerError("TOO_MANY_ENTRIES", `A transaction may contain at most ${MAX_LEDGER_ENTRIES} entries; split this batch into separate transactions.`);
178
+ }
175
179
  const totals = new Map;
176
180
  for (const entry of transaction.entries) {
177
181
  if (entry.amount.units === 0n) {
@@ -33,8 +33,8 @@ import { type Money } from './money';
33
33
  * must balance, so a split that loses a unit cannot be committed at all.
34
34
  */
35
35
  export declare class LedgerError extends Error {
36
- readonly code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE';
37
- constructor(code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE', message: string);
36
+ readonly code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'TOO_MANY_ENTRIES' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE';
37
+ constructor(code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'TOO_MANY_ENTRIES' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE', message: string);
38
38
  }
39
39
  /**
40
40
  * The sub-accounts every owner has.
@@ -96,6 +96,12 @@ export interface Transaction {
96
96
  atMs: number;
97
97
  memo?: string;
98
98
  }
99
+ /**
100
+ * A ledger transaction is one atomic business movement, not a batch transport.
101
+ * Keeping the posting set small bounds both the durable document and the
102
+ * multikey `accountIds[*]` index entry fan-out derived from it by the API.
103
+ */
104
+ export declare const MAX_LEDGER_ENTRIES = 64;
99
105
  /**
100
106
  * Every transaction sums to zero, per asset.
101
107
  *
@@ -168,10 +168,14 @@ function parseAccount(id) {
168
168
  return { kind, owner, bucket };
169
169
  }
170
170
  var queueKeyFor = (account) => `${account.kind}:${account.owner}`;
171
+ var MAX_LEDGER_ENTRIES = 64;
171
172
  function assertBalanced(transaction) {
172
- if (transaction.entries.length === 0) {
173
+ if (transaction.entries.length < 2) {
173
174
  throw new LedgerError("EMPTY_TRANSACTION", "A transaction needs at least two entries.");
174
175
  }
176
+ if (transaction.entries.length > MAX_LEDGER_ENTRIES) {
177
+ throw new LedgerError("TOO_MANY_ENTRIES", `A transaction may contain at most ${MAX_LEDGER_ENTRIES} entries; split this batch into separate transactions.`);
178
+ }
175
179
  const totals = new Map;
176
180
  for (const entry of transaction.entries) {
177
181
  if (entry.amount.units === 0n) {
@@ -302,6 +306,7 @@ export {
302
306
  assertAvailable,
303
307
  accountId,
304
308
  VERSION2 as VERSION,
309
+ MAX_LEDGER_ENTRIES,
305
310
  LedgerError,
306
311
  BUCKETS,
307
312
  ACCOUNT_KINDS
package/dist/jobs.d.ts CHANGED
@@ -86,6 +86,8 @@ export interface JobContext {
86
86
  log(message: string, detail?: Record<string, unknown>): void;
87
87
  }
88
88
  export interface JobResult {
89
+ /** A resolved run may still report an operational failure without throwing. */
90
+ ok?: boolean;
89
91
  /** Anything worth showing on a status screen. Kept in the report verbatim. */
90
92
  [key: string]: unknown;
91
93
  }
package/dist/jobs.js CHANGED
@@ -477,8 +477,15 @@ function createScheduler(options) {
477
477
  log: (message, detail) => options.onLog?.(job.key, message, detail)
478
478
  });
479
479
  report.lastResult = result ?? undefined;
480
- report.lastError = undefined;
481
- report.consecutiveFailures = 0;
480
+ if (result?.ok === false) {
481
+ const error = new Error("job returned ok=false");
482
+ report.lastError = error.message;
483
+ report.consecutiveFailures += 1;
484
+ options.onError?.(job.key, error);
485
+ } else {
486
+ report.lastError = undefined;
487
+ report.consecutiveFailures = 0;
488
+ }
482
489
  } catch (error) {
483
490
  report.lastError = error instanceof Error ? error.message : String(error);
484
491
  report.consecutiveFailures += 1;
@@ -0,0 +1,47 @@
1
+ /** Provider-neutral, function-based typed query contracts. */
2
+ export type QueryIssue = Readonly<{
3
+ path: string;
4
+ message: string;
5
+ }>;
6
+ export type QueryDecode<T> = Readonly<{
7
+ ok: true;
8
+ value: T;
9
+ }> | Readonly<{
10
+ ok: false;
11
+ issues: readonly QueryIssue[];
12
+ }>;
13
+ /** Inputs may coerce wire values; outputs must validate strictly. */
14
+ export interface QueryCodec<T> {
15
+ readonly schema?: unknown;
16
+ decode(value: unknown): QueryDecode<T>;
17
+ encode(value: unknown): QueryDecode<T>;
18
+ }
19
+ export interface QueryContract<Name extends string, Input, Output> {
20
+ readonly name: Name;
21
+ readonly input: QueryCodec<Input>;
22
+ readonly output: QueryCodec<Output>;
23
+ }
24
+ export declare class QueryContractError extends Error {
25
+ readonly query: string;
26
+ readonly phase: 'input' | 'output' | 'aborted';
27
+ readonly issues: readonly QueryIssue[];
28
+ constructor(query: string, phase: 'input' | 'output' | 'aborted', issues?: readonly QueryIssue[]);
29
+ }
30
+ export declare function defineQuery<const Name extends string, Input, Output>(definition: {
31
+ name: Name;
32
+ input: QueryCodec<Input>;
33
+ output: QueryCodec<Output>;
34
+ }): QueryContract<Name, Input, Output>;
35
+ export interface QueryExecution {
36
+ readonly signal: AbortSignal;
37
+ }
38
+ export interface QueryImplementation<Context, Output> {
39
+ execute(context: Context, input: unknown, options?: {
40
+ signal?: AbortSignal;
41
+ }): Promise<Output>;
42
+ }
43
+ export declare function implementQuery<Context, Name extends string, Input, Output>(contract: QueryContract<Name, Input, Output>, handler: (context: Context, input: Input, execution: QueryExecution) => Output | Promise<Output>): QueryImplementation<Context, Output> & {
44
+ readonly contract: typeof contract;
45
+ };
46
+ export type QueryInput<Q> = Q extends QueryContract<string, infer Input, unknown> ? Input : never;
47
+ export type QueryOutput<Q> = Q extends QueryContract<string, unknown, infer Output> ? Output : never;
package/dist/query.js ADDED
@@ -0,0 +1,51 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined")
5
+ return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/query.ts
10
+ class QueryContractError extends Error {
11
+ query;
12
+ phase;
13
+ issues;
14
+ constructor(query, phase, issues = []) {
15
+ super(`Query ${query} failed ${phase} validation`);
16
+ this.query = query;
17
+ this.phase = phase;
18
+ this.issues = issues;
19
+ this.name = "QueryContractError";
20
+ }
21
+ }
22
+ function defineQuery(definition) {
23
+ if (!definition.name.trim())
24
+ throw new Error("A query contract requires a stable name");
25
+ return Object.freeze({ ...definition });
26
+ }
27
+ function implementQuery(contract, handler) {
28
+ return {
29
+ contract,
30
+ async execute(context, rawInput, options = {}) {
31
+ const signal = options.signal ?? new AbortController().signal;
32
+ if (signal.aborted)
33
+ throw new QueryContractError(contract.name, "aborted");
34
+ const decoded = contract.input.decode(rawInput);
35
+ if (!decoded.ok)
36
+ throw new QueryContractError(contract.name, "input", decoded.issues);
37
+ const rawOutput = await handler(context, decoded.value, { signal });
38
+ if (signal.aborted)
39
+ throw new QueryContractError(contract.name, "aborted");
40
+ const encoded = contract.output.encode(rawOutput);
41
+ if (!encoded.ok)
42
+ throw new QueryContractError(contract.name, "output", encoded.issues);
43
+ return encoded.value;
44
+ }
45
+ };
46
+ }
47
+ export {
48
+ implementQuery,
49
+ defineQuery,
50
+ QueryContractError
51
+ };
@@ -1,4 +1,5 @@
1
1
  import type { TSchema, Static } from '@sinclair/typebox';
2
+ import type { QueryCodec } from './query';
2
3
  import { type SchemaValidator } from './schema';
3
4
  /**
4
5
  * TypeBox implementation of `SchemaValidator`.
@@ -20,5 +21,7 @@ export declare const typebox: SchemaValidator<TSchema>;
20
21
  * refusing to start.
21
22
  */
22
23
  export declare function parse<T extends TSchema>(schema: T, value: unknown): Static<T>;
24
+ /** TypeBox codec for `@forgezero/runtime/query`. */
25
+ export declare function typeboxQueryCodec<T extends TSchema>(schema: T): QueryCodec<Static<T>>;
23
26
  export { Type as T } from '@sinclair/typebox';
24
27
  export type { TSchema, Static } from '@sinclair/typebox';
@@ -21,6 +21,7 @@ var DEFAULT_RESTRICTIONS = {
21
21
  maxDepth: 4,
22
22
  maxFields: 100,
23
23
  maxBytes: 64 * 1024,
24
+ maxArrayItems: 1000,
24
25
  forbidden: ["$ref", "$id", "$dynamicRef", "$dynamicAnchor", "$schema", "definitions", "$defs"]
25
26
  };
26
27
  function restrictJsonSchema(schema, limits = DEFAULT_RESTRICTIONS) {
@@ -59,8 +60,20 @@ function restrictJsonSchema(schema, limits = DEFAULT_RESTRICTIONS) {
59
60
  walk(properties[name], depth + 1, path ? `${path}.${name}` : name);
60
61
  }
61
62
  }
62
- if (record.type === "array" && record.items) {
63
- walk(record.items, depth + 1, `${path}[]`);
63
+ if (record.type === "array") {
64
+ const maximum = record.maxItems;
65
+ if (!Number.isInteger(maximum) || maximum < 0) {
66
+ throw new SchemaError("SCHEMA_ARRAY_UNBOUNDED", "Arrays must declare a finite non-negative integer maxItems.", path || "(root)");
67
+ }
68
+ if (maximum > limits.maxArrayItems) {
69
+ throw new SchemaError("SCHEMA_ARRAY_TOO_LARGE", `Array maxItems ${maximum} exceeds the platform limit of ${limits.maxArrayItems}.`, path || "(root)");
70
+ }
71
+ const minimum = record.minItems;
72
+ if (minimum !== undefined && (!Number.isInteger(minimum) || minimum < 0 || minimum > maximum)) {
73
+ throw new SchemaError("SCHEMA_ARRAY_BOUNDS_INVALID", "minItems must be a non-negative integer no larger than maxItems.", path || "(root)");
74
+ }
75
+ if (record.items)
76
+ walk(record.items, depth + 1, `${path}[]`);
64
77
  }
65
78
  };
66
79
  walk(schema, 1, "");
@@ -194,7 +207,24 @@ function parse(schema, value) {
194
207
  const first = result.errors[0];
195
208
  throw new SchemaError("SCHEMA_INVALID", first?.message ?? "Value does not match schema", first?.path);
196
209
  }
210
+ function typeboxQueryCodec(schema) {
211
+ const issues = (value) => [...Value.Errors(schema, value)].map((error) => ({
212
+ path: error.path || "(root)",
213
+ message: error.message
214
+ }));
215
+ return {
216
+ schema,
217
+ decode(value) {
218
+ const converted = Value.Convert(schema, value);
219
+ return Value.Check(schema, converted) ? { ok: true, value: Value.Clean(schema, converted) } : { ok: false, issues: issues(converted) };
220
+ },
221
+ encode(value) {
222
+ return Value.Check(schema, value) ? { ok: true, value: Value.Clean(schema, value) } : { ok: false, issues: issues(value) };
223
+ }
224
+ };
225
+ }
197
226
  export {
227
+ typeboxQueryCodec,
198
228
  typebox,
199
229
  parse,
200
230
  Type as T
package/dist/schema.d.ts CHANGED
@@ -38,6 +38,8 @@ export interface Restrictions {
38
38
  maxDepth: number;
39
39
  maxFields: number;
40
40
  maxBytes: number;
41
+ /** Largest caller-authored array a persisted value may contain. */
42
+ maxArrayItems: number;
41
43
  /** Keywords refused outright, wherever they appear. */
42
44
  forbidden: readonly string[];
43
45
  }
package/dist/schema.js CHANGED
@@ -21,6 +21,7 @@ var DEFAULT_RESTRICTIONS = {
21
21
  maxDepth: 4,
22
22
  maxFields: 100,
23
23
  maxBytes: 64 * 1024,
24
+ maxArrayItems: 1000,
24
25
  forbidden: ["$ref", "$id", "$dynamicRef", "$dynamicAnchor", "$schema", "definitions", "$defs"]
25
26
  };
26
27
  function restrictJsonSchema(schema, limits = DEFAULT_RESTRICTIONS) {
@@ -59,8 +60,20 @@ function restrictJsonSchema(schema, limits = DEFAULT_RESTRICTIONS) {
59
60
  walk(properties[name], depth + 1, path ? `${path}.${name}` : name);
60
61
  }
61
62
  }
62
- if (record.type === "array" && record.items) {
63
- walk(record.items, depth + 1, `${path}[]`);
63
+ if (record.type === "array") {
64
+ const maximum = record.maxItems;
65
+ if (!Number.isInteger(maximum) || maximum < 0) {
66
+ throw new SchemaError("SCHEMA_ARRAY_UNBOUNDED", "Arrays must declare a finite non-negative integer maxItems.", path || "(root)");
67
+ }
68
+ if (maximum > limits.maxArrayItems) {
69
+ throw new SchemaError("SCHEMA_ARRAY_TOO_LARGE", `Array maxItems ${maximum} exceeds the platform limit of ${limits.maxArrayItems}.`, path || "(root)");
70
+ }
71
+ const minimum = record.minItems;
72
+ if (minimum !== undefined && (!Number.isInteger(minimum) || minimum < 0 || minimum > maximum)) {
73
+ throw new SchemaError("SCHEMA_ARRAY_BOUNDS_INVALID", "minItems must be a non-negative integer no larger than maxItems.", path || "(root)");
74
+ }
75
+ if (record.items)
76
+ walk(record.items, depth + 1, `${path}[]`);
64
77
  }
65
78
  };
66
79
  walk(schema, 1, "");
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
- "//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
3
2
  "name": "@forgezero/runtime",
4
- "version": "0.1.4",
3
+ "version": "0.1.5",
5
4
  "type": "module",
6
- "publishConfig": {
7
- "access": "public"
5
+ "publishConfig": {
6
+ "access": "public",
7
+ "provenance": true
8
8
  },
9
9
  "exports": {
10
10
  "./jobs": {
@@ -43,6 +43,10 @@
43
43
  "types": "./dist/schema-typebox.d.ts",
44
44
  "default": "./dist/schema-typebox.js"
45
45
  },
46
+ "./query": {
47
+ "types": "./dist/query.d.ts",
48
+ "default": "./dist/query.js"
49
+ },
46
50
  "./calendar": {
47
51
  "types": "./dist/calendar.d.ts",
48
52
  "default": "./dist/calendar.js"
@@ -179,7 +183,7 @@
179
183
  "scripts": {
180
184
  "check": "tsc --noEmit",
181
185
  "prebuild": "rm -rf dist",
182
- "build": "bun build src/jobs.ts src/queue.ts src/outbox.ts src/audit.ts src/backup.ts src/notify.ts src/notify-templates.ts src/calendar.ts src/compliance.ts src/pipeline.ts src/totp.ts src/otpauth.ts src/identity.ts src/slip10.ts src/openssh.ts src/ssh-cert.ts src/importers.ts src/snp.ts src/passkey.ts src/custody-crypto.ts src/custody-share.ts src/phrase.ts src/ssh-agent.ts src/schema.ts src/schema-typebox.ts src/finance/discounts.ts src/finance/money.ts src/finance/storage.ts src/finance/custody.ts src/finance/tax.ts src/finance/derive.ts src/finance/venues.ts src/finance/ledger.ts src/finance/rates.ts src/finance/transfers.ts src/finance/chain.ts src/finance/chain-addresses.ts src/finance/chain-deposits.ts src/finance/chain-withdrawals.ts src/finance/chain-reconcile.ts src/finance/market.ts src/finance/commission.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
186
+ "build": "bun build src/query.ts src/jobs.ts src/queue.ts src/outbox.ts src/audit.ts src/backup.ts src/notify.ts src/notify-templates.ts src/calendar.ts src/compliance.ts src/pipeline.ts src/totp.ts src/otpauth.ts src/identity.ts src/slip10.ts src/openssh.ts src/ssh-cert.ts src/importers.ts src/snp.ts src/passkey.ts src/custody-crypto.ts src/custody-share.ts src/phrase.ts src/ssh-agent.ts src/schema.ts src/schema-typebox.ts src/finance/discounts.ts src/finance/money.ts src/finance/storage.ts src/finance/custody.ts src/finance/tax.ts src/finance/derive.ts src/finance/venues.ts src/finance/ledger.ts src/finance/rates.ts src/finance/transfers.ts src/finance/chain.ts src/finance/chain-addresses.ts src/finance/chain-deposits.ts src/finance/chain-withdrawals.ts src/finance/chain-reconcile.ts src/finance/market.ts src/finance/commission.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
183
187
  "prepublishOnly": "bun run check && bun run build"
184
188
  },
185
189
  "dependencies": {
@@ -242,7 +246,7 @@
242
246
  "repository": {
243
247
  "type": "git",
244
248
  "url": "git+https://github.com/forgezero-net/packages.git",
245
- "directory": "packages/runtime"
249
+ "directory": "runtime"
246
250
  },
247
251
  "bugs": "https://github.com/forgezero-net/packages/issues",
248
252
  "sideEffects": false,