@ariestools/aries-dapp-core 0.1.7 → 0.1.8

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 (75) hide show
  1. package/README.md +253 -32
  2. package/dist/bin/dappServer.mjs +614 -100
  3. package/dist/node/DappActor.d.ts +28 -7
  4. package/dist/node/DappActor.d.ts.map +1 -1
  5. package/dist/node/actor/AbstractActor.d.ts +13 -8
  6. package/dist/node/actor/AbstractActor.d.ts.map +1 -1
  7. package/dist/node/actor/types.d.ts +7 -0
  8. package/dist/node/actor/types.d.ts.map +1 -1
  9. package/dist/node/bin/dappServer.d.ts +8 -4
  10. package/dist/node/bin/dappServer.d.ts.map +1 -1
  11. package/dist/node/boot/bootDappActors.d.ts +12 -1
  12. package/dist/node/boot/bootDappActors.d.ts.map +1 -1
  13. package/dist/node/consumer/api.d.ts +56 -0
  14. package/dist/node/consumer/api.d.ts.map +1 -0
  15. package/dist/node/consumer/hash.d.ts +6 -0
  16. package/dist/node/consumer/hash.d.ts.map +1 -0
  17. package/dist/node/consumer/parse.d.ts +18 -0
  18. package/dist/node/consumer/parse.d.ts.map +1 -0
  19. package/dist/node/consumer.d.ts +9 -0
  20. package/dist/node/consumer.d.ts.map +1 -0
  21. package/dist/node/consumer.mjs +330 -0
  22. package/dist/node/consumer.mjs.map +7 -0
  23. package/dist/node/examples/productionComposition.d.ts +114 -0
  24. package/dist/node/examples/productionComposition.d.ts.map +1 -0
  25. package/dist/node/index.d.ts +25 -3
  26. package/dist/node/index.d.ts.map +1 -1
  27. package/dist/node/index.mjs +1730 -68
  28. package/dist/node/index.mjs.map +4 -4
  29. package/dist/node/locator/createDappLocator.d.ts +32 -5
  30. package/dist/node/locator/createDappLocator.d.ts.map +1 -1
  31. package/dist/node/providers/DappBucketStore.d.ts +26 -21
  32. package/dist/node/providers/DappBucketStore.d.ts.map +1 -1
  33. package/dist/node/providers/capabilities.d.ts +49 -0
  34. package/dist/node/providers/capabilities.d.ts.map +1 -0
  35. package/dist/node/providers/errors.d.ts +11 -0
  36. package/dist/node/providers/errors.d.ts.map +1 -0
  37. package/dist/node/providers/hashBytes.d.ts +3 -0
  38. package/dist/node/providers/hashBytes.d.ts.map +1 -0
  39. package/dist/node/providers/joinKey.d.ts +11 -0
  40. package/dist/node/providers/joinKey.d.ts.map +1 -0
  41. package/dist/node/providers/memoryBucketStore.d.ts +14 -0
  42. package/dist/node/providers/memoryBucketStore.d.ts.map +1 -0
  43. package/dist/node/providers/types.d.ts +99 -0
  44. package/dist/node/providers/types.d.ts.map +1 -0
  45. package/dist/node/publication/canonicalHead.d.ts +7 -0
  46. package/dist/node/publication/canonicalHead.d.ts.map +1 -0
  47. package/dist/node/publication/commitHead.d.ts +9 -0
  48. package/dist/node/publication/commitHead.d.ts.map +1 -0
  49. package/dist/node/publication/constants.d.ts +27 -0
  50. package/dist/node/publication/constants.d.ts.map +1 -0
  51. package/dist/node/publication/gc.d.ts +42 -0
  52. package/dist/node/publication/gc.d.ts.map +1 -0
  53. package/dist/node/publication/incremental.d.ts +83 -0
  54. package/dist/node/publication/incremental.d.ts.map +1 -0
  55. package/dist/node/publication/publishGeneration.d.ts +18 -0
  56. package/dist/node/publication/publishGeneration.d.ts.map +1 -0
  57. package/dist/node/publication/putCreateOnly.d.ts +11 -0
  58. package/dist/node/publication/putCreateOnly.d.ts.map +1 -0
  59. package/dist/node/publication/releases.d.ts +49 -0
  60. package/dist/node/publication/releases.d.ts.map +1 -0
  61. package/dist/node/publication/safety.d.ts +55 -0
  62. package/dist/node/publication/safety.d.ts.map +1 -0
  63. package/dist/node/publication/shard.d.ts +7 -0
  64. package/dist/node/publication/shard.d.ts.map +1 -0
  65. package/dist/node/publication/status.d.ts +27 -0
  66. package/dist/node/publication/status.d.ts.map +1 -0
  67. package/dist/node/publication/statusPatch.d.ts +10 -0
  68. package/dist/node/publication/statusPatch.d.ts.map +1 -0
  69. package/dist/node/publication/types.d.ts +138 -0
  70. package/dist/node/publication/types.d.ts.map +1 -0
  71. package/dist/node/publication/writeGenerationObjects.d.ts +5 -0
  72. package/dist/node/publication/writeGenerationObjects.d.ts.map +1 -0
  73. package/dist/node/reducer/DappReducer.d.ts +37 -14
  74. package/dist/node/reducer/DappReducer.d.ts.map +1 -1
  75. package/package.json +12 -5
@@ -2,16 +2,18 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import { DAPP_BACKINGS, DAPP_BUCKETS, isDappBackingKind, resolveBacking, startDappServer } from "@ariestools/aries-dapp-serve";
4
4
  import { AbstractCreatable, ConsoleLogger, IdLogger, assertEx, creatable } from "@ariestools/sdk";
5
- import { GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
5
+ import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
6
6
  //#region src/actor/AbstractActor.ts
7
- function createDeferred() {
7
+ function createDeferred$1() {
8
8
  let resolve;
9
9
  let reject;
10
+ const promise = new Promise((res, rej) => {
11
+ resolve = res;
12
+ reject = rej;
13
+ });
14
+ promise.catch(() => {});
10
15
  return {
11
- promise: new Promise((res, rej) => {
12
- resolve = res;
13
- reject = rej;
14
- }),
16
+ promise,
15
17
  reject,
16
18
  resolve
17
19
  };
@@ -32,9 +34,10 @@ function createDeferred() {
32
34
  var AbstractActor = class extends AbstractCreatable {
33
35
  _intervals = /* @__PURE__ */ new Map();
34
36
  _timeouts = /* @__PURE__ */ new Map();
37
+ _abortController = new AbortController();
35
38
  _idLogger;
36
39
  _inFlight = /* @__PURE__ */ new Map();
37
- _readyDeferred = createDeferred();
40
+ _readyDeferred = createDeferred$1();
38
41
  _readyError;
39
42
  _readyState = "pending";
40
43
  get logger() {
@@ -53,6 +56,10 @@ var AbstractActor = class extends AbstractCreatable {
53
56
  get locator() {
54
57
  return this.params.locator;
55
58
  }
59
+ /** Abort signal cancelled when the actor stops (or is replaced on restart). */
60
+ get signal() {
61
+ return this._abortController.signal;
62
+ }
56
63
  static async paramsHandler(params) {
57
64
  const inParams = params ?? {};
58
65
  const baseParams = await super.paramsHandler({
@@ -65,11 +72,14 @@ var AbstractActor = class extends AbstractCreatable {
65
72
  locator
66
73
  };
67
74
  }
75
+ /** Override to prove the actor can do useful work. Default: no-op. */
76
+ async readyHandler() {}
68
77
  /**
69
- * Register a recurring task. The interval only fires while the actor is
70
- * `started`, and a run is skipped if the previous one is still in flight, so
71
- * a slow pass can never stack on top of itself. Must be called from
72
- * `startHandler` (guards on `starting` status), mirroring the xl1 actor.
78
+ * Register a recurring task. The first invocation fires after `dueTimeMs`
79
+ * (the documented first-run delay); subsequent invocations fire every
80
+ * `periodMs` while the actor is `started`. A run is skipped if the previous
81
+ * one is still in flight, so a slow pass can never stack on top of itself.
82
+ * Must be called from `startHandler` (guards on `starting` status).
73
83
  */
74
84
  registerTimer(timerName, callback, dueTimeMs, periodMs) {
75
85
  if (this.status !== "starting") {
@@ -77,7 +87,7 @@ var AbstractActor = class extends AbstractCreatable {
77
87
  return;
78
88
  }
79
89
  const tick = () => {
80
- if (this.status !== "started" || !this._intervals.has(timerName)) return;
90
+ if (this.status !== "started") return;
81
91
  if (this._inFlight.has(timerName)) {
82
92
  this.logger.warn(`Skipping timer '${this.name}:${timerName}' because the previous run is still in flight.`);
83
93
  return;
@@ -99,13 +109,15 @@ var AbstractActor = class extends AbstractCreatable {
99
109
  }));
100
110
  };
101
111
  const timeoutId = setTimeout(() => {
112
+ if (this.status !== "started") return;
102
113
  this._intervals.set(timerName, setInterval(tick, periodMs));
114
+ tick();
103
115
  }, dueTimeMs);
104
116
  this._timeouts.set(timerName, timeoutId);
105
117
  }
106
118
  /**
107
- * Run the warm-pass once. Not driven automatically here (no orchestrator);
108
- * a caller invokes it after `start()` if it wants `whenReady()` to resolve.
119
+ * Run the warm-pass once. The standard boot path invokes this after
120
+ * `start()` so readiness either resolves or rejects.
109
121
  */
110
122
  async runReadyHandler() {
111
123
  if (this._readyState !== "pending") return;
@@ -121,22 +133,81 @@ var AbstractActor = class extends AbstractCreatable {
121
133
  throw err;
122
134
  }
123
135
  }
136
+ async startHandler() {
137
+ if (this._abortController.signal.aborted) this._abortController = new AbortController();
138
+ await super.startHandler();
139
+ }
124
140
  async stopHandler() {
125
141
  await super.stopHandler();
142
+ this._abortController.abort();
126
143
  for (const timeoutRef of this._timeouts.values()) clearTimeout(timeoutRef);
127
144
  this._timeouts.clear();
128
145
  for (const intervalRef of this._intervals.values()) clearInterval(intervalRef);
129
146
  this._intervals.clear();
130
- await Promise.allSettled(this._inFlight.values());
147
+ const inFlight = [...this._inFlight.values()];
131
148
  this._inFlight.clear();
149
+ const timeoutMs = this.params.shutdownTimeoutMs;
150
+ if (timeoutMs === void 0) {
151
+ await Promise.allSettled(inFlight);
152
+ return;
153
+ }
154
+ await Promise.race([Promise.allSettled(inFlight), new Promise((resolve) => {
155
+ setTimeout(resolve, timeoutMs);
156
+ })]);
132
157
  }
133
158
  async whenReady() {
134
159
  await this._readyDeferred.promise;
135
160
  }
136
- /** Override to prove the actor can do useful work. Default: no-op. */
137
- async readyHandler() {}
138
161
  };
139
162
  //#endregion
163
+ //#region src/providers/capabilities.ts
164
+ /** Thrown when a read-only capability wrapper receives a write. */
165
+ var ReadOnlyStoreError = class extends Error {
166
+ name = "ReadOnlyStoreError";
167
+ constructor(role, operation = "put") {
168
+ super(`Store role "${role}" is read-only; ${operation} is not permitted`);
169
+ }
170
+ };
171
+ function bindReader(store) {
172
+ return {
173
+ role: store.role,
174
+ bucket: store.bucket,
175
+ prefix: store.prefix,
176
+ ...store.publicBaseUrl !== void 0 && { publicBaseUrl: store.publicBaseUrl },
177
+ destroy: () => store.destroy(),
178
+ get: (key) => store.get(key),
179
+ getWithMeta: (key) => store.getWithMeta(key),
180
+ list: (listPrefix) => store.list(listPrefix),
181
+ listAsync: (listPrefix) => store.listAsync(listPrefix),
182
+ listPage: (options) => store.listPage(options),
183
+ publicUrl: (key) => store.publicUrl(key),
184
+ resolveKey: (key) => store.resolveKey(key),
185
+ stat: (key) => store.stat(key)
186
+ };
187
+ }
188
+ /**
189
+ * Wrap any writer as a read-only reader. Other methods and destroy delegate to
190
+ * the underlying store.
191
+ */
192
+ function asReadOnly(store) {
193
+ return bindReader(store);
194
+ }
195
+ /**
196
+ * Wrap a writer so `put` fails even if the caller still holds the writer type
197
+ * (defense in depth for the data-role locator registration).
198
+ */
199
+ function asReadOnlyWriter(store) {
200
+ return {
201
+ ...bindReader(store),
202
+ async put() {
203
+ throw new ReadOnlyStoreError(store.role, "put");
204
+ },
205
+ async delete() {
206
+ throw new ReadOnlyStoreError(store.role, "delete");
207
+ }
208
+ };
209
+ }
210
+ //#endregion
140
211
  //#region src/providers/monikers.ts
141
212
  /**
142
213
  * Provider monikers for the three dapp buckets. An actor declares these in its
@@ -146,6 +217,108 @@ const DappDataStoreMoniker = "DappDataStore";
146
217
  const DappStateStoreMoniker = "DappStateStore";
147
218
  const DappIndexStoreMoniker = "DappIndexStore";
148
219
  //#endregion
220
+ //#region src/publication/constants.ts
221
+ /** Well-known relative keys published by the coherent generation protocol. */
222
+ const HEAD_KEY = "head.json";
223
+ const STATUS_KEY = "status.json";
224
+ /** CDN caching: mutable head and status objects that must revalidate. */
225
+ const CACHE_CONTROL_REVALIDATE = "public, max-age=0, must-revalidate";
226
+ const INDEXER_STATUS_SCHEMA = "network.aries.dapp.indexer.status";
227
+ //#endregion
228
+ //#region src/publication/status.ts
229
+ function encodeStatus(status) {
230
+ return `${JSON.stringify(status, null, 2)}\n`;
231
+ }
232
+ /**
233
+ * Read the durable indexer status object from a state (or status) store.
234
+ * Returns undefined when no status has been published yet.
235
+ */
236
+ async function readIndexerStatus(store, key = STATUS_KEY) {
237
+ const body = await store.get(key);
238
+ if (body === void 0) return void 0;
239
+ const parsed = JSON.parse(new TextDecoder().decode(body));
240
+ if (parsed === null || typeof parsed !== "object") throw new Error(`Invalid indexer status at "${key}": not an object`);
241
+ const record = parsed;
242
+ if (record.schema !== "network.aries.dapp.indexer.status") throw new Error(`Invalid indexer status schema at "${key}": ${String(record.schema)}`);
243
+ return record;
244
+ }
245
+ /**
246
+ * Publish a machine-readable status object with revalidate CDN caching.
247
+ * Status never carries credentials or private application material.
248
+ */
249
+ async function writeIndexerStatus(store, input) {
250
+ const key = input.key ?? "status.json";
251
+ const status = {
252
+ schema: INDEXER_STATUS_SCHEMA,
253
+ consecutiveFailures: input.consecutiveFailures,
254
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
255
+ ...input.floor !== void 0 && { floor: input.floor },
256
+ ...input.cursor !== void 0 && { cursor: input.cursor },
257
+ ...input.lastCompletedPosition !== void 0 && { lastCompletedPosition: input.lastCompletedPosition },
258
+ ...input.observedSourceHead !== void 0 && { observedSourceHead: input.observedSourceHead },
259
+ ...input.generation !== void 0 && { generation: input.generation },
260
+ ...input.generationRoot !== void 0 && { generationRoot: input.generationRoot },
261
+ ...input.lastSuccessAt !== void 0 && { lastSuccessAt: input.lastSuccessAt },
262
+ ...input.lastErrorAt !== void 0 && { lastErrorAt: input.lastErrorAt },
263
+ ...input.lastError !== void 0 && { lastError: input.lastError }
264
+ };
265
+ await store.put(key, encodeStatus(status), {
266
+ contentType: "application/json",
267
+ cacheControl: CACHE_CONTROL_REVALIDATE
268
+ });
269
+ return status;
270
+ }
271
+ //#endregion
272
+ //#region src/publication/statusPatch.ts
273
+ function pickDefined(source, keys) {
274
+ const out = {};
275
+ for (const key of keys) {
276
+ const value = source[key];
277
+ if (value !== void 0) out[key] = value;
278
+ }
279
+ return out;
280
+ }
281
+ const PROGRESS_KEYS = [
282
+ "floor",
283
+ "cursor",
284
+ "lastCompletedPosition",
285
+ "observedSourceHead",
286
+ "generation",
287
+ "generationRoot"
288
+ ];
289
+ /** Carry forward durable progress fields from a prior status object. */
290
+ function priorProgressFields(prior) {
291
+ if (prior === void 0) return {};
292
+ return {
293
+ ...pickDefined(prior, PROGRESS_KEYS),
294
+ ...prior.lastSuccessAt !== void 0 && { lastSuccessAt: prior.lastSuccessAt }
295
+ };
296
+ }
297
+ /** Build success status fields from reduce progress, falling back to prior. */
298
+ function successStatusFields(progress, prior, now) {
299
+ const fromProgress = progress === void 0 ? {} : pickDefined(progress, PROGRESS_KEYS);
300
+ const generation = progress?.generation ?? prior?.generation;
301
+ const generationRoot = progress?.generationRoot ?? prior?.generationRoot;
302
+ return {
303
+ consecutiveFailures: 0,
304
+ ...fromProgress,
305
+ ...generation !== void 0 && { generation },
306
+ ...generationRoot !== void 0 && { generationRoot },
307
+ lastSuccessAt: now,
308
+ ...prior?.lastError !== void 0 && { lastError: prior.lastError },
309
+ ...prior?.lastErrorAt !== void 0 && { lastErrorAt: prior.lastErrorAt }
310
+ };
311
+ }
312
+ /** Build failure status fields while preserving prior checkpoint progress. */
313
+ function failureStatusFields(prior, consecutiveFailures, error, now) {
314
+ return {
315
+ consecutiveFailures,
316
+ ...priorProgressFields(prior),
317
+ lastError: error.message,
318
+ lastErrorAt: now
319
+ };
320
+ }
321
+ //#endregion
149
322
  //#region \0@oxc-project+runtime@0.140.0/helpers/esm/decorate.js
150
323
  function __decorate(decorators, target, key, desc) {
151
324
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -157,6 +330,29 @@ function __decorate(decorators, target, key, desc) {
157
330
  //#region src/DappActor.ts
158
331
  const DEFAULT_REDUCE_INTERVAL_MS = 5e3;
159
332
  const DEFAULT_FIRST_RUN_DELAY_MS = 2e3;
333
+ const REDUCE_TIMER = "DappReduce";
334
+ function createDeferred() {
335
+ let resolve;
336
+ let reject;
337
+ const promise = new Promise((res, rej) => {
338
+ resolve = res;
339
+ reject = rej;
340
+ });
341
+ promise.catch(() => {});
342
+ return {
343
+ promise,
344
+ reject,
345
+ resolve
346
+ };
347
+ }
348
+ function withDefaultGenerationRoot(result) {
349
+ if (result === void 0) return;
350
+ if (result.generation === void 0 || result.generationRoot !== void 0) return result;
351
+ return {
352
+ ...result,
353
+ generationRoot: HEAD_KEY
354
+ };
355
+ }
160
356
  let DappActor = class DappActor extends AbstractActor {
161
357
  static dependencies = [
162
358
  DappDataStoreMoniker,
@@ -166,28 +362,88 @@ let DappActor = class DappActor extends AbstractActor {
166
362
  _data;
167
363
  _index;
168
364
  _state;
365
+ _consecutiveFailures = 0;
366
+ _firstPass = createDeferred();
367
+ _firstPassSettled = false;
368
+ /** Consecutive reduce failures since the last success. */
369
+ get consecutiveFailures() {
370
+ return this._consecutiveFailures;
371
+ }
169
372
  async createHandler() {
170
373
  await super.createHandler();
171
- this._data = await this.locator.getInstance(DappDataStoreMoniker);
374
+ const dataWriter = await this.locator.getInstance(DappDataStoreMoniker);
375
+ this._data = asReadOnly(dataWriter);
172
376
  this._state = await this.locator.getInstance(DappStateStoreMoniker);
173
377
  this._index = await this.locator.getInstance(DappIndexStoreMoniker);
174
378
  }
379
+ async readyHandler() {
380
+ await this._firstPass.promise;
381
+ }
175
382
  async startHandler() {
176
383
  await super.startHandler();
177
384
  const interval = this.params.reduceIntervalMs ?? DEFAULT_REDUCE_INTERVAL_MS;
178
385
  const firstRunDelay = this.params.firstRunDelayMs ?? DEFAULT_FIRST_RUN_DELAY_MS;
179
- this.registerTimer("DappReduce", async () => {
386
+ this.registerTimer(REDUCE_TIMER, async () => {
180
387
  await this.runReducePass();
181
388
  }, firstRunDelay, interval);
182
- this.logger.info(`DappActor started: reducer '${this.params.reducer.name}' every ${interval}ms`);
389
+ this.logger.info(`DappActor started: reducer '${this.params.reducer.name}' every ${interval}ms (first run in ${firstRunDelay}ms)`);
390
+ }
391
+ async stopHandler() {
392
+ this.settleFirstPass(/* @__PURE__ */ new Error("Actor stopped before first reduce pass completed"));
393
+ await super.stopHandler();
394
+ }
395
+ async publishStatusFromProgress(progress, error) {
396
+ if (this.params.publishStatus === false) return;
397
+ let prior;
398
+ try {
399
+ prior = await readIndexerStatus(this._state);
400
+ } catch {
401
+ prior = void 0;
402
+ }
403
+ const now = (/* @__PURE__ */ new Date()).toISOString();
404
+ const fields = error === void 0 ? successStatusFields(progress, prior, now) : failureStatusFields(prior, this._consecutiveFailures, error, now);
405
+ await writeIndexerStatus(this._state, fields);
183
406
  }
184
407
  async runReducePass() {
185
- await this.params.reducer.reduce({
186
- data: this._data,
187
- index: this._index,
188
- logger: this.logger,
189
- state: this._state
190
- });
408
+ try {
409
+ const result = await this.params.reducer.reduce({
410
+ data: this._data,
411
+ index: this._index,
412
+ logger: this.logger,
413
+ signal: this.signal,
414
+ state: this._state
415
+ });
416
+ const progress = withDefaultGenerationRoot(result === void 0 ? void 0 : result);
417
+ this._consecutiveFailures = 0;
418
+ await this.safePublishStatus(progress);
419
+ this.settleFirstPass();
420
+ } catch (error) {
421
+ const err = error instanceof Error ? error : new Error(String(error));
422
+ this._consecutiveFailures += 1;
423
+ await this.safePublishStatus(void 0, err);
424
+ this.settleFirstPass(err);
425
+ this.logger.error(`Error in reduce pass for '${this.params.reducer.name}': ${err.message}`);
426
+ const max = this.params.maxConsecutiveFailures;
427
+ if (max !== void 0 && this._consecutiveFailures >= max) {
428
+ this.logger.error(`Reducer '${this.params.reducer.name}' failed ${this._consecutiveFailures} consecutive times; stopping actor`);
429
+ setTimeout(() => {
430
+ this.stop();
431
+ }, 0);
432
+ }
433
+ }
434
+ }
435
+ async safePublishStatus(progress, error) {
436
+ try {
437
+ await this.publishStatusFromProgress(progress, error);
438
+ } catch (statusError) {
439
+ this.logger.error(`Failed to publish indexer status: ${String(statusError)}`);
440
+ }
441
+ }
442
+ settleFirstPass(error) {
443
+ if (this._firstPassSettled) return;
444
+ this._firstPassSettled = true;
445
+ if (error === void 0) this._firstPass.resolve();
446
+ else this._firstPass.reject(error);
191
447
  }
192
448
  };
193
449
  DappActor = __decorate([creatable()], DappActor);
@@ -195,112 +451,356 @@ DappActor = __decorate([creatable()], DappActor);
195
451
  //#region src/boot/bootDappActors.ts
196
452
  /**
197
453
  * Creates and starts one {@link DappActor} per spec, all sharing the single
198
- * process-wide locator. Returns them so the caller can stop them on shutdown.
454
+ * process-wide locator. On partial failure, actors already started are stopped
455
+ * before the error is rethrown. Returns them so the caller can stop them on
456
+ * shutdown.
199
457
  */
200
458
  async function bootDappActors(locator, specs) {
201
459
  const actors = [];
202
- for (const spec of specs) {
203
- const params = {
204
- locator,
205
- name: spec.name,
206
- reducer: spec.reducer,
207
- ...spec.reduceIntervalMs !== void 0 && { reduceIntervalMs: spec.reduceIntervalMs }
208
- };
209
- const actor = await DappActor.create(params);
210
- await actor.start();
211
- actors.push(actor);
460
+ try {
461
+ for (const spec of specs) {
462
+ const params = {
463
+ locator,
464
+ name: spec.name,
465
+ reducer: spec.reducer,
466
+ ...spec.reduceIntervalMs !== void 0 && { reduceIntervalMs: spec.reduceIntervalMs },
467
+ ...spec.firstRunDelayMs !== void 0 && { firstRunDelayMs: spec.firstRunDelayMs },
468
+ ...spec.maxConsecutiveFailures !== void 0 && { maxConsecutiveFailures: spec.maxConsecutiveFailures },
469
+ ...spec.publishStatus !== void 0 && { publishStatus: spec.publishStatus },
470
+ ...spec.shutdownTimeoutMs !== void 0 && { shutdownTimeoutMs: spec.shutdownTimeoutMs }
471
+ };
472
+ const actor = await DappActor.create(params);
473
+ await actor.start();
474
+ actors.push(actor);
475
+ if (spec.runReady !== false) await actor.runReadyHandler();
476
+ }
477
+ return actors;
478
+ } catch (error) {
479
+ for (const actor of [...actors].reverse()) try {
480
+ await actor.stop();
481
+ } catch {}
482
+ throw error;
483
+ }
484
+ }
485
+ //#endregion
486
+ //#region src/providers/errors.ts
487
+ /**
488
+ * Thrown when a conditional put fails (object already exists under
489
+ * `ifNotExists`, or ETag mismatch under `ifMatch`).
490
+ */
491
+ var ConditionalWriteError = class extends Error {
492
+ key;
493
+ name = "ConditionalWriteError";
494
+ constructor(key, message) {
495
+ super(message ?? `Conditional write failed for key "${key}"`);
496
+ this.key = key;
212
497
  }
213
- return actors;
498
+ };
499
+ //#endregion
500
+ //#region src/providers/joinKey.ts
501
+ /**
502
+ * Join a role prefix with a relative object key into a physical S3 key.
503
+ * Empty prefix yields the relative key unchanged; empty relative yields the
504
+ * prefix without a trailing slash.
505
+ */
506
+ function joinKey(prefix, relativeKey) {
507
+ const cleanPrefix = prefix.replaceAll(/^\/+|\/+$/g, "");
508
+ const cleanKey = relativeKey.replaceAll(/^\/+/g, "");
509
+ if (cleanPrefix.length === 0) return cleanKey;
510
+ if (cleanKey.length === 0) return cleanPrefix;
511
+ return `${cleanPrefix}/${cleanKey}`;
512
+ }
513
+ /** Strip a role prefix from a physical key, returning the relative key. */
514
+ function stripPrefix(prefix, physicalKey) {
515
+ const cleanPrefix = prefix.replaceAll(/^\/+|\/+$/g, "");
516
+ if (cleanPrefix.length === 0) return physicalKey;
517
+ const withSlash = `${cleanPrefix}/`;
518
+ if (physicalKey === cleanPrefix) return "";
519
+ if (physicalKey.startsWith(withSlash)) return physicalKey.slice(withSlash.length);
520
+ return physicalKey;
521
+ }
522
+ /** Normalize an optional public base URL (no trailing slash). */
523
+ function normalizePublicBaseUrl(url) {
524
+ if (url === void 0 || url.length === 0) return void 0;
525
+ return url.replace(/\/+$/, "");
214
526
  }
215
527
  //#endregion
216
528
  //#region src/providers/DappBucketStore.ts
217
529
  const DEFAULT_CREDENTIAL = "S3RVER";
530
+ const DEFAULT_REGION = "us-east-1";
218
531
  function isNotFound(error) {
219
532
  const name = error.name;
220
- return name === "NoSuchKey" || name === "NotFound";
533
+ const status = error.$metadata?.httpStatusCode;
534
+ return name === "NoSuchKey" || name === "NotFound" || name === "404" || status === 404;
535
+ }
536
+ function isConditionalFailure(error) {
537
+ const name = error.name;
538
+ const status = error.$metadata?.httpStatusCode;
539
+ return name === "PreconditionFailed" || name === "ConditionalRequestConflict" || name === "412" || status === 412;
540
+ }
541
+ function toBodyBytes(body) {
542
+ return typeof body === "string" ? new TextEncoder().encode(body) : body;
543
+ }
544
+ function metaFromResponse(response) {
545
+ return {
546
+ ...response.CacheControl !== void 0 && { cacheControl: response.CacheControl },
547
+ ...response.ContentEncoding !== void 0 && { contentEncoding: response.ContentEncoding },
548
+ ...response.ContentLength !== void 0 && { contentLength: response.ContentLength },
549
+ ...response.ContentType !== void 0 && { contentType: response.ContentType },
550
+ ...response.ETag !== void 0 && { etag: response.ETag },
551
+ ...response.LastModified !== void 0 && { lastModified: response.LastModified },
552
+ ...response.Metadata !== void 0 && Object.keys(response.Metadata).length > 0 && { metadata: response.Metadata }
553
+ };
554
+ }
555
+ /**
556
+ * Resolve a shared S3 client from an injected instance or construct options.
557
+ * Callers that build three role stores should resolve once and pass the same
558
+ * client to each so dapp-core does not own one client per role.
559
+ */
560
+ function resolveClientCredentials(options) {
561
+ if (options.credentials !== void 0) return options.credentials;
562
+ if (options.endpoint === void 0) return void 0;
563
+ return {
564
+ accessKeyId: DEFAULT_CREDENTIAL,
565
+ secretAccessKey: DEFAULT_CREDENTIAL
566
+ };
567
+ }
568
+ function resolveS3Client(options) {
569
+ if (options.client !== void 0) return {
570
+ client: options.client,
571
+ ownClient: options.ownClient ?? false
572
+ };
573
+ const credentials = resolveClientCredentials(options);
574
+ return {
575
+ client: new S3Client({
576
+ ...options.endpoint !== void 0 && { endpoint: options.endpoint },
577
+ region: options.region ?? DEFAULT_REGION,
578
+ forcePathStyle: options.forcePathStyle ?? options.endpoint !== void 0,
579
+ ...credentials !== void 0 && { credentials }
580
+ }),
581
+ ownClient: options.ownClient ?? true
582
+ };
221
583
  }
222
584
  /**
223
- * S3-backed {@link DappBucketStore} pointed at the local dapp server. Uses
224
- * path-style addressing so `/{bucket}/{key}` resolves as a subfolder of the
225
- * one origin. Under `--ssl auto` the server speaks HTTPS with a locally
226
- * trusted certificate; the daemon exports its CA via `NODE_EXTRA_CA_CERTS`, so
227
- * this client trusts it without any per-request handler configuration.
585
+ * S3-backed {@link DappBucketStore}. Accepts an injected client (production)
586
+ * or builds one from endpoint/credentials (local s3rver / simple configs).
587
+ * Path-style addressing is the default when an endpoint is provided so local
588
+ * fixtures and many R2 setups work without virtual-host DNS.
228
589
  */
229
590
  function createS3BucketStore(options) {
230
- const { bucket } = options;
231
- const client = new S3Client({
232
- endpoint: options.endpoint,
233
- region: "us-east-1",
234
- forcePathStyle: true,
235
- credentials: {
236
- accessKeyId: options.accessKeyId ?? DEFAULT_CREDENTIAL,
237
- secretAccessKey: options.secretAccessKey ?? DEFAULT_CREDENTIAL
238
- }
239
- });
591
+ const { role } = options;
592
+ const bindingPrefix = (options.binding.prefix ?? "").replaceAll(/^\/+|\/+$/g, "");
593
+ const publicBaseUrl = normalizePublicBaseUrl(options.binding.publicBaseUrl);
594
+ const { client, ownClient } = resolveS3Client(options);
595
+ const bucket = options.binding.bucket;
596
+ let destroyed = false;
597
+ function resolve(relativeKey) {
598
+ return joinKey(bindingPrefix, relativeKey);
599
+ }
240
600
  return {
601
+ role,
241
602
  bucket,
603
+ prefix: bindingPrefix,
604
+ ...publicBaseUrl !== void 0 && { publicBaseUrl },
605
+ resolveKey(key) {
606
+ return resolve(key);
607
+ },
608
+ publicUrl(key) {
609
+ if (publicBaseUrl === void 0) return void 0;
610
+ const relative = key.replaceAll(/^\/+/g, "");
611
+ return relative.length === 0 ? publicBaseUrl : `${publicBaseUrl}/${relative}`;
612
+ },
613
+ destroy() {
614
+ if (destroyed) return;
615
+ destroyed = true;
616
+ if (ownClient) client.destroy();
617
+ },
242
618
  async get(key) {
619
+ return (await this.getWithMeta(key))?.body;
620
+ },
621
+ async getWithMeta(key) {
622
+ try {
623
+ const response = await client.send(new GetObjectCommand({
624
+ Bucket: bucket,
625
+ Key: resolve(key)
626
+ }));
627
+ const body = await response.Body?.transformToByteArray();
628
+ if (body === void 0) return void 0;
629
+ return {
630
+ body,
631
+ meta: metaFromResponse(response)
632
+ };
633
+ } catch (error) {
634
+ if (isNotFound(error)) return void 0;
635
+ throw error;
636
+ }
637
+ },
638
+ async stat(key) {
243
639
  try {
244
- return await (await client.send(new GetObjectCommand({
640
+ return metaFromResponse(await client.send(new HeadObjectCommand({
245
641
  Bucket: bucket,
246
- Key: key
247
- }))).Body?.transformToByteArray();
642
+ Key: resolve(key)
643
+ })));
248
644
  } catch (error) {
249
645
  if (isNotFound(error)) return void 0;
250
646
  throw error;
251
647
  }
252
648
  },
253
- async put(key, body) {
254
- await client.send(new PutObjectCommand({
649
+ async put(key, body, putOptions) {
650
+ const physicalKey = resolve(key);
651
+ try {
652
+ const response = await client.send(new PutObjectCommand({
653
+ Bucket: bucket,
654
+ Key: physicalKey,
655
+ Body: toBodyBytes(body),
656
+ ...putOptions?.contentType !== void 0 && { ContentType: putOptions.contentType },
657
+ ...putOptions?.contentEncoding !== void 0 && { ContentEncoding: putOptions.contentEncoding },
658
+ ...putOptions?.cacheControl !== void 0 && { CacheControl: putOptions.cacheControl },
659
+ ...putOptions?.metadata !== void 0 && { Metadata: putOptions.metadata },
660
+ ...putOptions?.ifNotExists === true && { IfNoneMatch: "*" },
661
+ ...putOptions?.ifMatch !== void 0 && { IfMatch: putOptions.ifMatch }
662
+ }));
663
+ return { ...response.ETag !== void 0 && { etag: response.ETag } };
664
+ } catch (error) {
665
+ if (isConditionalFailure(error)) throw new ConditionalWriteError(key);
666
+ throw error;
667
+ }
668
+ },
669
+ async delete(key) {
670
+ await client.send(new DeleteObjectCommand({
255
671
  Bucket: bucket,
256
- Key: key,
257
- Body: body
672
+ Key: resolve(key)
258
673
  }));
259
674
  },
260
675
  async list(prefix) {
261
676
  const keys = [];
677
+ for await (const key of this.listAsync(prefix)) keys.push(key);
678
+ return keys;
679
+ },
680
+ async listPage(pageOptions = {}) {
681
+ const physicalPrefix = pageOptions.prefix === void 0 ? bindingPrefix.length === 0 ? void 0 : `${bindingPrefix}/` : resolve(pageOptions.prefix);
682
+ const response = await client.send(new ListObjectsV2Command({
683
+ Bucket: bucket,
684
+ ...physicalPrefix !== void 0 && { Prefix: physicalPrefix },
685
+ ...pageOptions.continuationToken !== void 0 && { ContinuationToken: pageOptions.continuationToken },
686
+ ...pageOptions.maxKeys !== void 0 && { MaxKeys: pageOptions.maxKeys }
687
+ }));
688
+ const keys = [];
689
+ for (const item of response.Contents ?? []) {
690
+ if (item.Key === void 0) continue;
691
+ keys.push(stripPrefix(bindingPrefix, item.Key));
692
+ }
693
+ const isTruncated = response.IsTruncated === true;
694
+ return {
695
+ keys,
696
+ isTruncated,
697
+ ...isTruncated && response.NextContinuationToken !== void 0 && { continuationToken: response.NextContinuationToken }
698
+ };
699
+ },
700
+ async *listAsync(prefix) {
262
701
  let token;
263
702
  do {
264
- const response = await client.send(new ListObjectsV2Command({
265
- Bucket: bucket,
266
- Prefix: prefix,
267
- ContinuationToken: token
268
- }));
269
- for (const item of response.Contents ?? []) if (item.Key !== void 0) keys.push(item.Key);
270
- token = response.IsTruncated === true ? response.NextContinuationToken : void 0;
703
+ const page = await this.listPage({
704
+ ...prefix !== void 0 && { prefix },
705
+ ...token !== void 0 && { continuationToken: token }
706
+ });
707
+ for (const key of page.keys) yield key;
708
+ token = page.continuationToken;
271
709
  } while (token !== void 0);
272
- return keys;
273
710
  }
274
711
  };
275
712
  }
276
713
  //#endregion
277
714
  //#region src/locator/createDappLocator.ts
715
+ const DEFAULT_BINDINGS = {
716
+ data: { bucket: "data" },
717
+ state: { bucket: "state" },
718
+ index: { bucket: "index" }
719
+ };
720
+ const ROLE_MONIKERS = {
721
+ data: DappDataStoreMoniker,
722
+ state: DappStateStoreMoniker,
723
+ index: DappIndexStoreMoniker
724
+ };
725
+ function resolveCredentials(options) {
726
+ if (options.credentials !== void 0) return options.credentials;
727
+ if (options.accessKeyId !== void 0 || options.secretAccessKey !== void 0) return {
728
+ accessKeyId: options.accessKeyId ?? "S3RVER",
729
+ secretAccessKey: options.secretAccessKey ?? "S3RVER"
730
+ };
731
+ }
732
+ function resolveSharedClient(options) {
733
+ const credentials = resolveCredentials(options);
734
+ if (options.client !== void 0) return {
735
+ client: options.client,
736
+ ownClient: options.ownClient ?? false
737
+ };
738
+ return resolveS3Client({
739
+ ...options.endpoint !== void 0 && { endpoint: options.endpoint },
740
+ ...options.region !== void 0 && { region: options.region },
741
+ ...options.forcePathStyle !== void 0 && { forcePathStyle: options.forcePathStyle },
742
+ ...credentials !== void 0 && { credentials },
743
+ ownClient: options.ownClient
744
+ });
745
+ }
746
+ function resolveRoleClient(role, options, shared) {
747
+ const override = options.clients?.[role];
748
+ if (override === void 0) return shared;
749
+ if (typeof override.send === "function") return {
750
+ client: override,
751
+ ownClient: false
752
+ };
753
+ return resolveS3Client({
754
+ ...override,
755
+ ownClient: override.ownClient ?? true
756
+ });
757
+ }
278
758
  /**
279
759
  * Builds the process-wide locator: one instance, shared across every actor
280
- * (xl1-cli style). It pre-registers an S3-backed store per bucket keyed by
281
- * moniker; extra providers can be `register`ed before actors are created.
760
+ * (xl1-cli style). It pre-registers an S3-backed store per logical role using
761
+ * either the default local bucket names or explicit production bindings.
762
+ * Extra providers can be `register`ed before actors are created.
763
+ *
764
+ * Prefer a shared {@link client} when all roles share one principal; use
765
+ * {@link CreateDappLocatorOptions.clients} for distinct least-privilege
766
+ * credentials per role. Call `destroy()` on shutdown when the locator owns
767
+ * any client it constructed.
282
768
  */
283
769
  function createDappLocator(options) {
284
770
  const logger = options.logger ?? new ConsoleLogger();
285
- const bucketOptions = {
286
- endpoint: options.endpoint,
287
- ...options.accessKeyId !== void 0 && { accessKeyId: options.accessKeyId },
288
- ...options.secretAccessKey !== void 0 && { secretAccessKey: options.secretAccessKey }
771
+ const bindings = options.bindings ?? DEFAULT_BINDINGS;
772
+ const shared = resolveSharedClient(options);
773
+ const readOnlyData = options.readOnlyData !== false;
774
+ const roles = [
775
+ "data",
776
+ "state",
777
+ "index"
778
+ ];
779
+ const instances = /* @__PURE__ */ new Map();
780
+ const stores = [];
781
+ const ownedClients = [];
782
+ const seenClients = /* @__PURE__ */ new Set();
783
+ const trackClient = (owned) => {
784
+ if (!owned.ownClient || seenClients.has(owned.client)) return;
785
+ seenClients.add(owned.client);
786
+ ownedClients.push(owned);
289
787
  };
290
- const instances = /* @__PURE__ */ new Map([
291
- [DappDataStoreMoniker, createS3BucketStore({
292
- ...bucketOptions,
293
- bucket: "data"
294
- })],
295
- [DappStateStoreMoniker, createS3BucketStore({
296
- ...bucketOptions,
297
- bucket: "state"
298
- })],
299
- [DappIndexStoreMoniker, createS3BucketStore({
300
- ...bucketOptions,
301
- bucket: "index"
302
- })]
303
- ]);
788
+ trackClient(shared);
789
+ for (const role of roles) {
790
+ const binding = bindings[role];
791
+ const roleClient = resolveRoleClient(role, options, shared);
792
+ trackClient(roleClient);
793
+ let store = createS3BucketStore({
794
+ role,
795
+ binding,
796
+ client: roleClient.client,
797
+ ownClient: false
798
+ });
799
+ if (role === "data" && readOnlyData) store = asReadOnlyWriter(store);
800
+ stores.push(store);
801
+ instances.set(ROLE_MONIKERS[role], store);
802
+ }
803
+ let destroyed = false;
304
804
  return {
305
805
  context: { logger },
306
806
  has(moniker) {
@@ -316,15 +816,21 @@ function createDappLocator(options) {
316
816
  },
317
817
  async tryGetInstance(moniker) {
318
818
  return instances.get(moniker);
819
+ },
820
+ destroy() {
821
+ if (destroyed) return;
822
+ destroyed = true;
823
+ for (const store of stores) store.destroy();
824
+ for (const owned of ownedClients) if (owned.ownClient) owned.client.destroy();
319
825
  }
320
826
  };
321
827
  }
322
828
  //#endregion
323
829
  //#region src/reducer/DappReducer.ts
324
830
  /**
325
- * Default reducer: does nothing. The harness runs the full timer/provider
326
- * spine with this in place; supply a real reducer to derive `state` and
327
- * `index` from `data`.
831
+ * Default reducer: does nothing. The local harness runs the full timer/provider
832
+ * spine with this in place; production entrypoints must supply a real reducer.
833
+ * **Development only** — not a production indexer.
328
834
  */
329
835
  const noopReducer = {
330
836
  name: "noop",
@@ -333,10 +839,14 @@ const noopReducer = {
333
839
  //#endregion
334
840
  //#region src/bin/dappServer.ts
335
841
  /**
336
- * Composed dapp daemon: boots the S3-compatible storage server (from
337
- * `@ariestools/aries-dapp-serve`) and, in the same process, a shared
338
- * process-wide locator plus the DappActor(s) that derive `state`/`index` from
339
- * `data` on an interval. Spawned by `aries dapp up`.
842
+ * **Local development only.** Composed dapp daemon: boots the embedded
843
+ * S3-compatible storage server (from `@ariestools/aries-dapp-serve`) and, in
844
+ * the same process, a shared process-wide locator plus a `noopReducer`
845
+ * DappActor. Spawned by `aries dapp up`.
846
+ *
847
+ * Production indexers must not use this binary. Compose `dapp-core` from a
848
+ * project-owned entrypoint with real store bindings, an injected S3/R2 client,
849
+ * and a real reducer — see the package README.
340
850
  */
341
851
  const PORT = Number(process.env.DAPP_PORT ?? 8801);
342
852
  const HOST = process.env.HOST ?? "127.0.0.1";
@@ -360,17 +870,21 @@ async function main() {
360
870
  }
361
871
  });
362
872
  const logger = new ConsoleLogger();
363
- const actors = await bootDappActors(createDappLocator({
873
+ const locator = createDappLocator({
364
874
  endpoint: server.baseUrl,
365
875
  logger
366
- }), [{
876
+ });
877
+ const actors = await bootDappActors(locator, [{
367
878
  name: "DappActor",
368
879
  reducer: noopReducer,
369
- reduceIntervalMs: REDUCE_INTERVAL_MS
880
+ reduceIntervalMs: REDUCE_INTERVAL_MS,
881
+ firstRunDelayMs: 0,
882
+ publishStatus: false
370
883
  }]);
371
- console.log(`dapp server listening at ${server.baseUrl}\n` + DAPP_BUCKETS.map((bucket) => ` /${bucket.padEnd(5)} ${server.baseUrl}/${bucket}`).join("\n") + `\nbacking: ${backing.description} — ${backing.directory}\nactors: ${actors.length} (reducer '${noopReducer.name}', every ${REDUCE_INTERVAL_MS}ms)`);
884
+ console.log(`[dev-only] dapp server listening at ${server.baseUrl}\n` + DAPP_BUCKETS.map((bucket) => ` /${bucket.padEnd(5)} ${server.baseUrl}/${bucket}`).join("\n") + `\nbacking: ${backing.description} — ${backing.directory}\nactors: ${actors.length} (reducer '${noopReducer.name}', every ${REDUCE_INTERVAL_MS}ms)`);
372
885
  const shutdown = async () => {
373
886
  for (const actor of actors) await actor.stop();
887
+ locator.destroy();
374
888
  await server.close();
375
889
  process.exit(0);
376
890
  };