@adcp/sdk 15.3.0 → 15.5.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 (130) hide show
  1. package/dist/lib/advanced.d.mts +2 -2
  2. package/dist/lib/advanced.d.ts +2 -2
  3. package/dist/lib/advanced.js +8 -2
  4. package/dist/lib/advanced.mjs +8 -2
  5. package/dist/lib/core/AgentClient.d.mts +4 -0
  6. package/dist/lib/core/AgentClient.d.ts +4 -0
  7. package/dist/lib/core/AgentClient.js +8 -0
  8. package/dist/lib/core/AgentClient.mjs +8 -0
  9. package/dist/lib/core/SingleAgentClient.d.mts +154 -0
  10. package/dist/lib/core/SingleAgentClient.d.ts +154 -0
  11. package/dist/lib/core/SingleAgentClient.js +661 -23
  12. package/dist/lib/core/SingleAgentClient.mjs +666 -23
  13. package/dist/lib/core/agent-discovery-cache.d.mts +103 -0
  14. package/dist/lib/core/agent-discovery-cache.d.ts +103 -0
  15. package/dist/lib/core/agent-discovery-cache.js +121 -0
  16. package/dist/lib/core/agent-discovery-cache.mjs +92 -0
  17. package/dist/lib/index.d.mts +5 -3
  18. package/dist/lib/index.d.ts +5 -3
  19. package/dist/lib/index.js +7 -0
  20. package/dist/lib/index.mjs +6 -0
  21. package/dist/lib/protocols/index.d.mts +2 -2
  22. package/dist/lib/protocols/index.d.ts +2 -2
  23. package/dist/lib/protocols/index.js +1 -1
  24. package/dist/lib/protocols/index.mjs +1 -1
  25. package/dist/lib/protocols/mcp-call-context.d.mts +109 -0
  26. package/dist/lib/protocols/mcp-call-context.d.ts +109 -0
  27. package/dist/lib/protocols/mcp-call-context.js +221 -0
  28. package/dist/lib/protocols/mcp-call-context.mjs +186 -0
  29. package/dist/lib/protocols/mcp-modern.d.mts +9 -1
  30. package/dist/lib/protocols/mcp-modern.d.ts +9 -1
  31. package/dist/lib/protocols/mcp-modern.js +180 -104
  32. package/dist/lib/protocols/mcp-modern.mjs +193 -104
  33. package/dist/lib/protocols/mcp-scope.d.mts +15 -0
  34. package/dist/lib/protocols/mcp-scope.d.ts +15 -0
  35. package/dist/lib/protocols/mcp.d.mts +4 -2
  36. package/dist/lib/protocols/mcp.d.ts +4 -2
  37. package/dist/lib/protocols/mcp.js +178 -154
  38. package/dist/lib/protocols/mcp.mjs +189 -153
  39. package/dist/lib/protocols/transportDiagnostics.d.mts +33 -1
  40. package/dist/lib/protocols/transportDiagnostics.d.ts +33 -1
  41. package/dist/lib/protocols/transportDiagnostics.js +128 -12
  42. package/dist/lib/protocols/transportDiagnostics.mjs +128 -12
  43. package/dist/lib/reporting/gcs/index.d.mts +2 -0
  44. package/dist/lib/reporting/gcs/index.d.ts +2 -0
  45. package/dist/lib/reporting/gcs/index.js +3 -0
  46. package/dist/lib/reporting/gcs/index.mjs +2 -0
  47. package/dist/lib/reporting/gcs/row-provider.d.mts +32 -0
  48. package/dist/lib/reporting/gcs/row-provider.d.ts +32 -0
  49. package/dist/lib/reporting/gcs/row-provider.js +205 -0
  50. package/dist/lib/reporting/gcs/row-provider.mjs +188 -0
  51. package/dist/lib/reporting/ledger/change-feed.d.mts +41 -0
  52. package/dist/lib/reporting/ledger/change-feed.d.ts +41 -0
  53. package/dist/lib/reporting/ledger/change-feed.js +101 -0
  54. package/dist/lib/reporting/ledger/change-feed.mjs +74 -0
  55. package/dist/lib/reporting/ledger/handler.d.mts +6 -0
  56. package/dist/lib/reporting/ledger/handler.d.ts +6 -0
  57. package/dist/lib/reporting/ledger/handler.js +51 -8
  58. package/dist/lib/reporting/ledger/handler.mjs +50 -8
  59. package/dist/lib/reporting/ledger/index.d.mts +5 -0
  60. package/dist/lib/reporting/ledger/index.d.ts +5 -0
  61. package/dist/lib/reporting/ledger/index.js +11 -1
  62. package/dist/lib/reporting/ledger/index.mjs +5 -0
  63. package/dist/lib/reporting/ledger/lifecycle.js +3 -2
  64. package/dist/lib/reporting/ledger/lifecycle.mjs +3 -2
  65. package/dist/lib/reporting/ledger/managed-postgres.d.mts +2 -0
  66. package/dist/lib/reporting/ledger/managed-postgres.d.ts +2 -0
  67. package/dist/lib/reporting/ledger/managed-postgres.js +26 -6
  68. package/dist/lib/reporting/ledger/managed-postgres.mjs +26 -6
  69. package/dist/lib/reporting/ledger/managed.d.mts +9 -0
  70. package/dist/lib/reporting/ledger/managed.d.ts +9 -0
  71. package/dist/lib/reporting/ledger/managed.js +18 -0
  72. package/dist/lib/reporting/ledger/managed.mjs +18 -0
  73. package/dist/lib/reporting/ledger/postgres.d.mts +179 -1
  74. package/dist/lib/reporting/ledger/postgres.d.ts +179 -1
  75. package/dist/lib/reporting/ledger/postgres.js +882 -19
  76. package/dist/lib/reporting/ledger/postgres.mjs +890 -19
  77. package/dist/lib/reporting/ledger/producer.js +13 -3
  78. package/dist/lib/reporting/ledger/producer.mjs +13 -3
  79. package/dist/lib/reporting/ledger/revision-metadata.d.mts +17 -0
  80. package/dist/lib/reporting/ledger/revision-metadata.d.ts +17 -0
  81. package/dist/lib/reporting/ledger/revision-metadata.js +71 -0
  82. package/dist/lib/reporting/ledger/revision-metadata.mjs +44 -0
  83. package/dist/lib/reporting/ledger/row-encoding.d.mts +128 -0
  84. package/dist/lib/reporting/ledger/row-encoding.d.ts +128 -0
  85. package/dist/lib/reporting/ledger/row-encoding.js +355 -0
  86. package/dist/lib/reporting/ledger/row-encoding.mjs +315 -0
  87. package/dist/lib/reporting/ledger/row-storage-errors.d.mts +7 -0
  88. package/dist/lib/reporting/ledger/row-storage-errors.d.ts +7 -0
  89. package/dist/lib/reporting/ledger/row-storage-errors.js +41 -0
  90. package/dist/lib/reporting/ledger/row-storage-errors.mjs +16 -0
  91. package/dist/lib/reporting/ledger/row-storage-migration.d.mts +41 -0
  92. package/dist/lib/reporting/ledger/row-storage-migration.d.ts +41 -0
  93. package/dist/lib/reporting/ledger/row-storage-migration.js +234 -0
  94. package/dist/lib/reporting/ledger/row-storage-migration.mjs +208 -0
  95. package/dist/lib/reporting/ledger/row-storage-object.d.mts +112 -0
  96. package/dist/lib/reporting/ledger/row-storage-object.d.ts +112 -0
  97. package/dist/lib/reporting/ledger/row-storage-object.js +281 -0
  98. package/dist/lib/reporting/ledger/row-storage-object.mjs +239 -0
  99. package/dist/lib/reporting/ledger/row-storage-provider-io.d.mts +51 -0
  100. package/dist/lib/reporting/ledger/row-storage-provider-io.d.ts +51 -0
  101. package/dist/lib/reporting/ledger/row-storage-provider-io.js +152 -0
  102. package/dist/lib/reporting/ledger/row-storage-provider-io.mjs +119 -0
  103. package/dist/lib/reporting/ledger/row-storage.d.mts +224 -0
  104. package/dist/lib/reporting/ledger/row-storage.d.ts +224 -0
  105. package/dist/lib/reporting/ledger/row-storage.js +692 -0
  106. package/dist/lib/reporting/ledger/row-storage.mjs +680 -0
  107. package/dist/lib/reporting/ledger/types.d.mts +45 -0
  108. package/dist/lib/reporting/ledger/types.d.ts +45 -0
  109. package/dist/lib/reporting/service/index.d.mts +41 -1
  110. package/dist/lib/reporting/service/index.d.ts +41 -1
  111. package/dist/lib/reporting/service/index.js +68 -1
  112. package/dist/lib/reporting/service/index.mjs +70 -1
  113. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  114. package/dist/lib/signing/agent-resolver/fetch-helpers.js +1 -1
  115. package/dist/lib/signing/agent-resolver/fetch-helpers.mjs +1 -1
  116. package/dist/lib/testing/storyboard/validations.d.mts +1 -1
  117. package/dist/lib/testing/storyboard/validations.d.ts +1 -1
  118. package/dist/lib/version.d.mts +3 -3
  119. package/dist/lib/version.d.ts +3 -3
  120. package/dist/lib/version.js +3 -3
  121. package/dist/lib/version.mjs +3 -3
  122. package/docs/README.md +1 -1
  123. package/docs/TYPE-SUMMARY.md +35 -2
  124. package/docs/guides/DISCOVERY-CACHE-AND-SESSION-REUSE.md +142 -0
  125. package/docs/guides/EXISTING-PLATFORM.md +43 -12
  126. package/docs/guides/REPORTING-LEDGER.md +209 -0
  127. package/docs/llms.txt +4 -2
  128. package/docs/migration-14.x-rc-worksheet.md +4 -4
  129. package/docs/recipes/verifying-inbound-webhooks.md +8 -0
  130. package/package.json +1 -1
@@ -24,6 +24,7 @@ __export(postgres_exports, {
24
24
  REPORTING_LEDGER_MIGRATION: () => REPORTING_LEDGER_MIGRATION,
25
25
  ReportingLedgerContinuityError: () => ReportingLedgerContinuityError,
26
26
  ReportingLedgerLeaseLostError: () => ReportingLedgerLeaseLostError,
27
+ ReportingLedgerPeriodRetiredError: () => ReportingLedgerPeriodRetiredError,
27
28
  sweepExpiredReportingLedgerState: () => sweepExpiredReportingLedgerState
28
29
  });
29
30
  module.exports = __toCommonJS(postgres_exports);
@@ -39,6 +40,8 @@ var import_types = require('./types.js');
39
40
  var import_types2 = require('./types.js');
40
41
  var import_handler = require('./handler.js');
41
42
  var import_producer = require('./producer.js');
43
+ var import_change_feed = require('./change-feed.js');
44
+ var import_row_storage = require('./row-storage.js');
42
45
  var import_schedule = require('./schedule.js');
43
46
  var import_consumer_status_identity = require('./consumer-status-identity.js');
44
47
  const REPORTING_LEDGER_MIGRATION = `
@@ -287,6 +290,29 @@ CREATE TABLE IF NOT EXISTS adcp_reporting_checkpoints (
287
290
  );
288
291
  CREATE INDEX IF NOT EXISTS adcp_reporting_checkpoints_expiry
289
292
  ON adcp_reporting_checkpoints (expires_at);
293
+
294
+ -- A retired period keeps one compact record: it refuses replays that would
295
+ -- resurrect the period and preserves the audit conclusion after the period's
296
+ -- ledger records are deleted.
297
+ CREATE TABLE IF NOT EXISTS adcp_reporting_obligation_tombstones (
298
+ obligation_id TEXT PRIMARY KEY,
299
+ account_id TEXT NOT NULL,
300
+ configuration_id TEXT NOT NULL,
301
+ period_ordinal BIGINT NOT NULL,
302
+ period_start TIMESTAMPTZ NOT NULL,
303
+ period_end TIMESTAMPTZ NOT NULL,
304
+ state TEXT NOT NULL CHECK (state IN ('retiring', 'retired')),
305
+ final_revision_id TEXT,
306
+ final_content_sha256 TEXT,
307
+ revision_count INTEGER NOT NULL CHECK (revision_count >= 0),
308
+ adjustment_count INTEGER NOT NULL CHECK (adjustment_count >= 0),
309
+ retiring_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
310
+ retired_at TIMESTAMPTZ,
311
+ UNIQUE (configuration_id, period_ordinal),
312
+ CHECK ((state = 'retired') = (retired_at IS NOT NULL))
313
+ );
314
+ CREATE INDEX IF NOT EXISTS adcp_reporting_obligation_tombstones_account
315
+ ON adcp_reporting_obligation_tombstones (account_id, configuration_id, period_ordinal);
290
316
  `.trim();
291
317
  const REPORTING_LEDGER_FINALITY_FENCE_CONSTRAINT = "adcp_reporting_transitions_finality_recorded";
292
318
  const REPORTING_LEDGER_FINALITY_WRITER_FENCE_MIGRATION = `
@@ -319,6 +345,12 @@ class ReportingLedgerLeaseLostError extends Error {
319
345
  this.name = "ReportingLedgerLeaseLostError";
320
346
  }
321
347
  }
348
+ class ReportingLedgerPeriodRetiredError extends Error {
349
+ constructor(message = "Reporting obligation period was retired by retention") {
350
+ super(message);
351
+ this.name = "ReportingLedgerPeriodRetiredError";
352
+ }
353
+ }
322
354
  class ReportingLedgerContinuityError extends Error {
323
355
  constructor(message = "Reporting ledger revision continuity invariant failed") {
324
356
  super(message);
@@ -371,6 +403,9 @@ class PostgresReportingLedgerStore {
371
403
  this.consumerMismatchEscalation = (0, import_health.assertReportingConsumerMismatchEscalation)(options.consumerMismatchEscalation);
372
404
  this.notificationActivityPort = options.notificationActivityPort;
373
405
  this.transactionalNotificationActivity = options.notificationActivityPort !== void 0;
406
+ this.rowStorage = new import_row_storage.ReportingRowStorageV1(pool, typeof options.rowStorage === "object" ? options.rowStorage : {});
407
+ this.rowStorageWrites = options.rowStorage !== void 0 && options.rowStorage !== false;
408
+ this.changeFeed = options.changeFeed === true;
374
409
  }
375
410
  pool;
376
411
  /**
@@ -386,6 +421,119 @@ class PostgresReportingLedgerStore {
386
421
  obligatedConsumers;
387
422
  transactionalNotificationActivity;
388
423
  notificationActivityPort;
424
+ rowStorage;
425
+ rowStorageWrites;
426
+ changeFeed;
427
+ /** Append one host change-feed row inside the caller's account-locked transaction. */
428
+ async appendChange(client, kind, accountId, recordId, obligationId) {
429
+ if (!this.changeFeed) return;
430
+ await client.query(
431
+ `INSERT INTO adcp_reporting_changes (account_id, record_kind, record_id, obligation_id) VALUES ($1, $2, $3, $4)`,
432
+ [accountId, kind, recordId, obligationId]
433
+ );
434
+ }
435
+ /** Probe and register row storage when enabled. Safe to call repeatedly. */
436
+ async readyRowStorage() {
437
+ if (this.rowStorageWrites) await this.rowStorage.ready();
438
+ }
439
+ /**
440
+ * Move legacy inline revision and adjustment rows into row storage, a
441
+ * bounded batch at a time. Each row set is re-encoded, proven to reproduce
442
+ * its committed binding, written, and only then removed from the ledger
443
+ * document, in one transaction under the account lock. A document whose
444
+ * rows do not reproduce its binding is never rewritten: it is reported in
445
+ * `quarantined` and left inline. Pass the returned `cursor` back to resume
446
+ * after the last examined document; it is absent once every candidate has
447
+ * been examined. Reclaiming the freed space needs `VACUUM FULL` or an online
448
+ * repack.
449
+ */
450
+ async migrateInlineRows(input = {}) {
451
+ if (!this.rowStorageWrites) throw new Error("migrateInlineRows requires rowStorage");
452
+ const limit = input.limit ?? 100;
453
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e4) {
454
+ throw new RangeError("migrateInlineRows limit must be between 1 and 10000");
455
+ }
456
+ let after;
457
+ if (input.cursor !== void 0) {
458
+ try {
459
+ const parsed = JSON.parse(Buffer.from(input.cursor, "base64url").toString("utf8"));
460
+ if (typeof parsed.at !== "string" || typeof parsed.id !== "string") throw new Error("invalid");
461
+ after = { at: parsed.at, id: parsed.id };
462
+ } catch {
463
+ throw new RangeError("migrateInlineRows cursor is invalid");
464
+ }
465
+ }
466
+ await this.rowStorage.ready();
467
+ const candidates = await this.query(
468
+ `SELECT kind, id, account_id, to_char(recorded_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS at
469
+ FROM (
470
+ SELECT 'revision' AS kind, revision.revision_id AS id, obligation.account_id, revision.recorded_at
471
+ FROM adcp_reporting_revisions revision
472
+ JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
473
+ WHERE revision.data ? 'rows' AND ($2::text IS NULL OR obligation.account_id = $2)
474
+ UNION ALL
475
+ SELECT 'adjustment', adjustment.adjustment_id, obligation.account_id, adjustment.recorded_at
476
+ FROM adcp_reporting_adjustments adjustment
477
+ JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = adjustment.obligation_id
478
+ WHERE adjustment.data ? 'rows' AND ($2::text IS NULL OR obligation.account_id = $2)
479
+ ) inline_rows
480
+ WHERE $3::timestamptz IS NULL OR (recorded_at, id) > ($3::timestamptz, $4::text)
481
+ ORDER BY recorded_at, id
482
+ LIMIT $1`,
483
+ [limit + 1, input.account_id ?? null, after?.at ?? null, after?.id ?? null]
484
+ );
485
+ let migrated = 0;
486
+ const quarantined = [];
487
+ for (const candidate of candidates.rows.slice(0, limit)) {
488
+ input.signal?.throwIfAborted();
489
+ const table = candidate.kind === "revision" ? "adcp_reporting_revisions" : "adcp_reporting_adjustments";
490
+ const idColumn = candidate.kind === "revision" ? "revision_id" : "adjustment_id";
491
+ const outcome = await this.transaction(
492
+ async (client) => {
493
+ const locked = await client.query(
494
+ `SELECT data FROM ${table} WHERE ${idColumn} = $1 AND data ? 'rows' FOR UPDATE`,
495
+ [candidate.id]
496
+ );
497
+ const document = locked.rows[0]?.data;
498
+ if (!document) return "skipped";
499
+ let prepared;
500
+ try {
501
+ prepared = await this.rowStorage.prepare({
502
+ rowSetId: candidate.id,
503
+ rowSetKind: candidate.kind,
504
+ accountId: candidate.account_id,
505
+ obligationId: document.reporting_obligation_id,
506
+ rows: document.rows,
507
+ ...candidate.kind === "revision" ? { finality: document.finality, controlTotals: document.wireRevision.control_totals } : {},
508
+ binding: document.binding,
509
+ ...input.bindingId ? { bindingId: input.bindingId } : {}
510
+ });
511
+ } catch (error) {
512
+ if ((0, import_row_storage.isReportingRowStoreError)(error) && error.code === "INVALID_INPUT") return "quarantined";
513
+ throw error;
514
+ }
515
+ if (prepared.binding.kind !== "postgres") {
516
+ throw new import_row_storage.ReportingRowStoreError(
517
+ "INVALID_INPUT",
518
+ "migrateInlineRows writes PostgreSQL-kind row sets; select a postgres binding for migrated rows"
519
+ );
520
+ }
521
+ await this.rowStorage.writeInTransaction(client, prepared);
522
+ await client.query(`UPDATE ${table} SET data = data - 'rows' WHERE ${idColumn} = $1`, [candidate.id]);
523
+ return "migrated";
524
+ },
525
+ { preBeginAdvisoryLock: accountLock(candidate.account_id) }
526
+ );
527
+ if (outcome === "migrated") migrated += 1;
528
+ if (outcome === "quarantined") quarantined.push(candidate.id);
529
+ }
530
+ const last = candidates.rows.length > limit ? candidates.rows[limit - 1] : void 0;
531
+ return {
532
+ migrated,
533
+ quarantined,
534
+ ...last ? { cursor: Buffer.from(JSON.stringify({ at: last.at, id: last.id }), "utf8").toString("base64url") } : {}
535
+ };
536
+ }
389
537
  async putConfiguration(configuration) {
390
538
  return this.transaction(
391
539
  async (client) => {
@@ -442,11 +590,20 @@ class PostgresReportingLedgerStore {
442
590
  return result.rows.map((row) => clone(row.data));
443
591
  }
444
592
  async putObligation(obligation) {
593
+ const retired = await this.query(
594
+ `SELECT 1 FROM adcp_reporting_obligation_tombstones WHERE configuration_id = $1 AND period_ordinal = $2`,
595
+ [obligation.configurationId, obligation.periodOrdinal]
596
+ );
597
+ if (retired.rows.length > 0) throw new ReportingLedgerPeriodRetiredError();
445
598
  return this.putImmutable(
446
599
  `INSERT INTO adcp_reporting_obligations
447
600
  (obligation_id, configuration_id, account_id, period_ordinal, period_start, period_end,
448
601
  next_attempt_at, state, semantic_fingerprint, data, created_at)
449
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, clock_timestamp())
602
+ SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, clock_timestamp()
603
+ WHERE NOT EXISTS (
604
+ SELECT 1 FROM adcp_reporting_obligation_tombstones
605
+ WHERE configuration_id = $2 AND period_ordinal = $4
606
+ )
450
607
  ON CONFLICT DO NOTHING RETURNING data`,
451
608
  [
452
609
  obligation.reporting_obligation_id,
@@ -465,7 +622,16 @@ class PostgresReportingLedgerStore {
465
622
  [obligation.configurationId, obligation.periodOrdinal],
466
623
  obligation,
467
624
  (value) => value.semanticFingerprint,
468
- accountLock(obligation.account.account_id)
625
+ accountLock(obligation.account.account_id),
626
+ void 0,
627
+ void 0,
628
+ this.changeFeed ? (client) => this.appendChange(
629
+ client,
630
+ "obligation",
631
+ obligation.account.account_id,
632
+ obligation.reporting_obligation_id,
633
+ obligation.reporting_obligation_id
634
+ ) : void 0
469
635
  );
470
636
  }
471
637
  async getObligation(id, account_id) {
@@ -475,6 +641,18 @@ class PostgresReportingLedgerStore {
475
641
  [id, account_id ?? null]
476
642
  );
477
643
  }
644
+ /** Periods retired by retention; planners treat them as already covered. */
645
+ async listRetiredObligationOrdinals(account_id) {
646
+ const result = await this.query(
647
+ `SELECT configuration_id, period_ordinal FROM adcp_reporting_obligation_tombstones
648
+ WHERE ($1::text IS NULL OR account_id = $1) ORDER BY configuration_id, period_ordinal`,
649
+ [account_id ?? null]
650
+ );
651
+ return result.rows.map((row) => ({
652
+ configurationId: row.configuration_id,
653
+ periodOrdinal: Number(row.period_ordinal)
654
+ }));
655
+ }
478
656
  async listObligations(account_id) {
479
657
  const result = await this.query(
480
658
  `SELECT data FROM adcp_reporting_obligations
@@ -553,6 +731,13 @@ ${managedDueArm} )
553
731
  ]
554
732
  );
555
733
  if (result.rowCount !== 1) throw new ReportingLedgerLeaseLostError();
734
+ await this.appendChange(
735
+ client,
736
+ "obligation",
737
+ lease.obligation.account.account_id,
738
+ obligation.reporting_obligation_id,
739
+ obligation.reporting_obligation_id
740
+ );
556
741
  if (issue) {
557
742
  if (issue.reporting_obligation_id !== obligation.reporting_obligation_id) {
558
743
  throw new Error("Reporting issue must belong to the leased obligation");
@@ -621,7 +806,20 @@ ${managedDueArm} )
621
806
  async commitRevision(revision, lease) {
622
807
  assertLeaseTarget(revision.reporting_obligation_id, lease);
623
808
  validateRevisionBinding(revision);
624
- return this.putImmutable(
809
+ const prepared = this.rowStorageWrites ? await this.rowStorage.prepare({
810
+ rowSetId: revision.reporting_revision_id,
811
+ rowSetKind: "revision",
812
+ accountId: lease.obligation.account.account_id,
813
+ obligationId: revision.reporting_obligation_id,
814
+ rows: revision.rows,
815
+ finality: revision.finality,
816
+ periodStart: lease.obligation.period.start,
817
+ binding: revision.binding,
818
+ controlTotals: revision.wireRevision.control_totals
819
+ }) : void 0;
820
+ const upload = prepared?.binding.kind === "object" ? await this.uploadRowSetUnlessCommitted(prepared, lease, "adcp_reporting_revisions", "revision_id") : void 0;
821
+ const port = this.notificationActivityPort?.recordLedgerChanged ? this.notificationActivityPort : void 0;
822
+ const result = await this.putImmutable(
625
823
  `WITH leased_obligation AS (
626
824
  SELECT * FROM adcp_reporting_obligations obligation
627
825
  WHERE obligation.obligation_id = $2
@@ -658,7 +856,7 @@ ${managedDueArm} )
658
856
  revision.kind,
659
857
  revision.supersedes_reporting_revision_id ?? null,
660
858
  revision.binding.sha256,
661
- JSON.stringify(revision),
859
+ JSON.stringify(prepared ? withoutRows(revision) : revision),
662
860
  lease.owner,
663
861
  lease.generation
664
862
  ],
@@ -673,19 +871,31 @@ ${managedDueArm} )
673
871
  generation: lease.generation
674
872
  },
675
873
  revisionLegacyCanonicalDigestReplay,
676
- this.notificationActivityPort?.recordLedgerChanged ? (client, committed) => this.notificationActivityPort.recordLedgerChanged(
677
- { obligation: lease.obligation, revision: committed },
678
- client
679
- ) : void 0
874
+ prepared || port || this.changeFeed ? async (client, committed) => {
875
+ if (prepared) await this.writePreparedRowSet(client, prepared, upload);
876
+ await this.appendChange(
877
+ client,
878
+ "revision",
879
+ lease.obligation.account.account_id,
880
+ revision.reporting_revision_id,
881
+ revision.reporting_obligation_id
882
+ );
883
+ await port?.recordLedgerChanged(
884
+ { obligation: lease.obligation, revision: withRows(committed, revision.rows) },
885
+ client
886
+ );
887
+ } : void 0
680
888
  );
889
+ return { inserted: result.inserted, value: withRows(result.value, revision.rows) };
681
890
  }
682
891
  async getRevision(id, accountId) {
683
- return this.one(
892
+ const revision = await this.one(
684
893
  `SELECT revision.data FROM adcp_reporting_revisions revision
685
894
  JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
686
895
  WHERE revision.revision_id = $1 AND obligation.account_id = $2`,
687
896
  [id, accountId]
688
897
  );
898
+ return revision ? this.hydrateRevision(revision, accountId) : null;
689
899
  }
690
900
  async getRevisionMetadata(id, accountId) {
691
901
  return this.one(
@@ -695,18 +905,71 @@ ${managedDueArm} )
695
905
  [id, accountId]
696
906
  );
697
907
  }
908
+ async readRevisionRows(input) {
909
+ if (!Number.isSafeInteger(input.offset) || input.offset < 0 || !Number.isSafeInteger(input.limit) || input.limit < 1) {
910
+ throw new RangeError("Reporting row page bounds are invalid");
911
+ }
912
+ const result = await this.query(
913
+ `SELECT revision.data ? 'rows' AS inline,
914
+ CASE WHEN revision.data ? 'rows' THEN jsonb_array_length(revision.data->'rows') END AS total,
915
+ CASE WHEN revision.data ? 'rows' THEN jsonb_path_query_array(
916
+ revision.data->'rows',
917
+ '$[$from to $to]',
918
+ jsonb_build_object('from', $3::bigint, 'to', $3::bigint + $4::bigint - 1)
919
+ ) END AS rows
920
+ FROM adcp_reporting_revisions revision
921
+ JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
922
+ WHERE revision.revision_id = $1 AND obligation.account_id = $2`,
923
+ [input.reporting_revision_id, input.account_id, input.offset, input.limit]
924
+ );
925
+ const row = result.rows[0];
926
+ if (!row) return null;
927
+ if (row.inline) return { rows: clone(row.rows ?? []), total: Number(row.total) };
928
+ const handle = await this.rowStorage.find(input.reporting_revision_id, input.account_id);
929
+ if (!handle) throw new import_row_storage.ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "revision rows are not recorded");
930
+ if (input.offset >= handle.rowCount) return { rows: [], total: handle.rowCount };
931
+ return {
932
+ rows: await this.rowStorage.readPage(handle, input.offset, input.limit, input.signal),
933
+ total: handle.rowCount
934
+ };
935
+ }
698
936
  async listRevisions(obligationId) {
699
937
  const result = await this.query(
700
- `SELECT data FROM adcp_reporting_revisions WHERE obligation_id = $1
701
- ORDER BY revision_number, revision_id`,
938
+ `SELECT revision.data, obligation.account_id FROM adcp_reporting_revisions revision
939
+ JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
940
+ WHERE revision.obligation_id = $1
941
+ ORDER BY revision.revision_number, revision.revision_id`,
702
942
  [obligationId]
703
943
  );
944
+ const revisions = [];
945
+ for (const row of result.rows) revisions.push(await this.hydrateRevision(clone(row.data), row.account_id));
946
+ return revisions;
947
+ }
948
+ async listRevisionMetadata(obligationId, accountId) {
949
+ const result = await this.query(
950
+ `SELECT revision.data - 'rows' AS data FROM adcp_reporting_revisions revision
951
+ JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
952
+ WHERE revision.obligation_id = $1 AND ($2::text IS NULL OR obligation.account_id = $2)
953
+ ORDER BY revision.revision_number, revision.revision_id`,
954
+ [obligationId, accountId ?? null]
955
+ );
704
956
  return result.rows.map((row) => clone(row.data));
705
957
  }
706
958
  async commitAdjustment(adjustment, lease) {
707
959
  assertLeaseTarget(adjustment.reporting_obligation_id, lease);
708
960
  validateBoundRows(adjustment);
709
- return this.putImmutable(
961
+ const prepared = this.rowStorageWrites ? await this.rowStorage.prepare({
962
+ rowSetId: adjustment.reporting_adjustment_id,
963
+ rowSetKind: "adjustment",
964
+ accountId: lease.obligation.account.account_id,
965
+ obligationId: adjustment.reporting_obligation_id,
966
+ rows: adjustment.rows,
967
+ periodStart: lease.obligation.period.start,
968
+ binding: adjustment.binding
969
+ }) : void 0;
970
+ const upload = prepared?.binding.kind === "object" ? await this.uploadRowSetUnlessCommitted(prepared, lease, "adcp_reporting_adjustments", "adjustment_id") : void 0;
971
+ const port = this.notificationActivityPort?.recordLedgerChanged ? this.notificationActivityPort : void 0;
972
+ const result = await this.putImmutable(
710
973
  `WITH leased_obligation AS (
711
974
  SELECT * FROM adcp_reporting_obligations obligation
712
975
  WHERE obligation.obligation_id = $2
@@ -737,7 +1000,7 @@ ${managedDueArm} )
737
1000
  adjustment.adjusts_reporting_revision_id,
738
1001
  adjustment.adjustmentNumber,
739
1002
  adjustment.binding.sha256,
740
- JSON.stringify(adjustment),
1003
+ JSON.stringify(prepared ? withoutRows(adjustment) : adjustment),
741
1004
  lease.owner,
742
1005
  lease.generation
743
1006
  ],
@@ -752,15 +1015,607 @@ ${managedDueArm} )
752
1015
  generation: lease.generation
753
1016
  },
754
1017
  adjustmentLegacyCanonicalDigestReplay,
755
- this.notificationActivityPort?.recordLedgerChanged ? (client, committed) => this.notificationActivityPort.recordLedgerChanged(
756
- { obligation: lease.obligation, adjustment: committed },
757
- client
758
- ) : void 0
1018
+ prepared || port || this.changeFeed ? async (client, committed) => {
1019
+ if (prepared) await this.writePreparedRowSet(client, prepared, upload);
1020
+ await this.appendChange(
1021
+ client,
1022
+ "adjustment",
1023
+ lease.obligation.account.account_id,
1024
+ adjustment.reporting_adjustment_id,
1025
+ adjustment.reporting_obligation_id
1026
+ );
1027
+ await port?.recordLedgerChanged(
1028
+ { obligation: lease.obligation, adjustment: withRows(committed, adjustment.rows) },
1029
+ client
1030
+ );
1031
+ } : void 0
759
1032
  );
1033
+ return { inserted: result.inserted, value: withRows(result.value, adjustment.rows) };
760
1034
  }
761
1035
  async listAdjustments(obligationId) {
762
1036
  const result = await this.query(
763
- `SELECT data FROM adcp_reporting_adjustments WHERE obligation_id = $1
1037
+ `SELECT adjustment.data, obligation.account_id FROM adcp_reporting_adjustments adjustment
1038
+ JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = adjustment.obligation_id
1039
+ WHERE adjustment.obligation_id = $1
1040
+ ORDER BY adjustment.adjustment_number, adjustment.adjustment_id`,
1041
+ [obligationId]
1042
+ );
1043
+ const adjustments = [];
1044
+ for (const row of result.rows) {
1045
+ const adjustment = clone(row.data);
1046
+ if (Array.isArray(adjustment.rows)) {
1047
+ adjustments.push(adjustment);
1048
+ continue;
1049
+ }
1050
+ const handle = await this.rowStorage.find(adjustment.reporting_adjustment_id, row.account_id);
1051
+ if (!handle) throw new import_row_storage.ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "adjustment rows are not recorded");
1052
+ adjustments.push({ ...adjustment, rows: await this.rowStorage.readAll(handle) });
1053
+ }
1054
+ return adjustments;
1055
+ }
1056
+ /**
1057
+ * The obligation's current revision header: its official revision if one
1058
+ * exists, otherwise its highest-numbered snapshot. An official revision is
1059
+ * terminal, so this is always the highest revision number — one index
1060
+ * lookup. Rows are read separately through `readRevisionRows`.
1061
+ */
1062
+ async getCurrentRevision(input) {
1063
+ return this.one(
1064
+ `SELECT revision.data - 'rows' AS data
1065
+ FROM adcp_reporting_obligations obligation
1066
+ JOIN LATERAL (
1067
+ SELECT data FROM adcp_reporting_revisions current_revision
1068
+ WHERE current_revision.obligation_id = obligation.obligation_id
1069
+ ORDER BY current_revision.revision_number DESC LIMIT 1
1070
+ ) revision ON TRUE
1071
+ WHERE obligation.obligation_id = $1 AND obligation.account_id = $2`,
1072
+ [input.reporting_obligation_id, input.account_id]
1073
+ );
1074
+ }
1075
+ /**
1076
+ * Current revision headers for an account's obligations whose period starts
1077
+ * in `[period_start_from, period_start_to)`, ordered by period start.
1078
+ * Obligations without a revision yet are omitted. Page with `cursor`.
1079
+ */
1080
+ async listCurrentRevisions(input) {
1081
+ const limit = input.limit ?? 100;
1082
+ positiveInteger(limit, "limit");
1083
+ if (limit > 1e3) throw new RangeError("listCurrentRevisions limit must not exceed 1000");
1084
+ let after;
1085
+ if (input.cursor !== void 0) {
1086
+ try {
1087
+ const parsed = JSON.parse(Buffer.from(input.cursor, "base64url").toString("utf8"));
1088
+ if (typeof parsed.start !== "string" || typeof parsed.id !== "string") throw new Error("invalid");
1089
+ after = { start: parsed.start, id: parsed.id };
1090
+ } catch {
1091
+ throw new RangeError("listCurrentRevisions cursor is invalid");
1092
+ }
1093
+ }
1094
+ const result = await this.query(
1095
+ `SELECT revision.data - 'rows' AS data,
1096
+ to_char(obligation.period_start AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS start,
1097
+ obligation.obligation_id AS id
1098
+ FROM adcp_reporting_obligations obligation
1099
+ JOIN LATERAL (
1100
+ SELECT data FROM adcp_reporting_revisions current_revision
1101
+ WHERE current_revision.obligation_id = obligation.obligation_id
1102
+ ORDER BY current_revision.revision_number DESC LIMIT 1
1103
+ ) revision ON TRUE
1104
+ WHERE obligation.account_id = $1
1105
+ AND obligation.period_start >= $2::timestamptz AND obligation.period_start < $3::timestamptz
1106
+ AND ($4::text IS NULL OR obligation.data->>'delivery_config_id' = $4)
1107
+ AND ($5::timestamptz IS NULL
1108
+ OR (obligation.period_start, obligation.obligation_id) > ($5::timestamptz, $6::text))
1109
+ ORDER BY obligation.period_start, obligation.obligation_id
1110
+ LIMIT $7`,
1111
+ [
1112
+ input.account_id,
1113
+ input.period_start_from,
1114
+ input.period_start_to,
1115
+ input.delivery_config_id ?? null,
1116
+ after?.start ?? null,
1117
+ after?.id ?? null,
1118
+ limit + 1
1119
+ ]
1120
+ );
1121
+ const page = result.rows.slice(0, limit);
1122
+ const last = result.rows.length > limit ? page.at(-1) : void 0;
1123
+ return {
1124
+ revisions: page.map((row) => clone(row.data)),
1125
+ ...last ? { cursor: Buffer.from(JSON.stringify({ start: last.start, id: last.id }), "utf8").toString("base64url") } : {}
1126
+ };
1127
+ }
1128
+ /**
1129
+ * Host change feed. Omit `cursor` to start from the oldest retained change.
1130
+ * Account-scoped feeds order by sequence; the deployment-wide feed orders by
1131
+ * `(xid, seq)` and returns only changes whose transaction precedes every
1132
+ * transaction still in flight, so no commit is ever skipped. A cursor older
1133
+ * than the retained history throws `ReportingChangeCursorExpiredError`;
1134
+ * resynchronize from `listCurrentRevisions`.
1135
+ */
1136
+ async changesAfter(input = {}) {
1137
+ if (!this.changeFeed) throw new Error("changesAfter requires the store changeFeed option");
1138
+ const limit = input.limit ?? 500;
1139
+ positiveInteger(limit, "limit");
1140
+ if (limit > 1e4) throw new RangeError("changesAfter limit must not exceed 10000");
1141
+ const account = input.account_id ?? null;
1142
+ const cursor = input.cursor === void 0 ? void 0 : (0, import_change_feed.decodeReportingChangeCursorV1)(input.cursor, account);
1143
+ const horizon = account !== null ? {
1144
+ seq: (await this.query(
1145
+ `SELECT pruned_through_seq::text AS seq FROM adcp_reporting_change_account_horizon
1146
+ WHERE account_id = $1`,
1147
+ [account]
1148
+ )).rows[0]?.seq ?? "0"
1149
+ } : (await this.query(
1150
+ `SELECT pruned_through_xid::text AS xid, pruned_through_seq::text AS seq
1151
+ FROM adcp_reporting_change_horizon WHERE singleton`
1152
+ )).rows[0] ?? { xid: "0", seq: "0" };
1153
+ if (cursor) {
1154
+ const behind = account !== null ? BigInt(horizon.seq) > BigInt(cursor.seq) : BigInt(horizon.xid) > BigInt(cursor.xid) || BigInt(horizon.xid) === BigInt(cursor.xid) && BigInt(horizon.seq) > BigInt(cursor.seq);
1155
+ if (behind) throw new import_change_feed.ReportingChangeCursorExpiredError();
1156
+ }
1157
+ const position = cursor ?? {
1158
+ v: 1,
1159
+ account,
1160
+ seq: horizon.seq,
1161
+ ...account === null ? { xid: horizon.xid } : {}
1162
+ };
1163
+ const kinds = input.kinds?.length ? [...input.kinds] : null;
1164
+ const columns = `seq::text AS seq, xid::text AS xid, account_id, record_kind, record_id, obligation_id,
1165
+ to_char(recorded_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS recorded_at`;
1166
+ const result = account !== null ? await this.query(
1167
+ `SELECT ${columns} FROM adcp_reporting_changes
1168
+ WHERE account_id = $1 AND seq > $2::bigint AND ($3::text[] IS NULL OR record_kind = ANY($3))
1169
+ ORDER BY seq LIMIT $4`,
1170
+ [account, position.seq, kinds, limit]
1171
+ ) : await this.query(
1172
+ `SELECT ${columns} FROM adcp_reporting_changes
1173
+ WHERE (xid, seq) > ($1::text::xid8, $2::bigint)
1174
+ AND xid < pg_snapshot_xmin(pg_current_snapshot())
1175
+ AND ($3::text[] IS NULL OR record_kind = ANY($3))
1176
+ ORDER BY xid, seq LIMIT $4`,
1177
+ [position.xid ?? "0", position.seq, kinds, limit]
1178
+ );
1179
+ const last = result.rows.at(-1);
1180
+ const next = last ? (0, import_change_feed.encodeReportingChangeCursorV1)({ v: 1, account, seq: last.seq, ...account === null ? { xid: last.xid } : {} }) : input.cursor ?? (0, import_change_feed.encodeReportingChangeCursorV1)(position);
1181
+ return {
1182
+ records: result.rows.map((row) => ({
1183
+ kind: row.record_kind,
1184
+ record_id: row.record_id,
1185
+ reporting_obligation_id: row.obligation_id,
1186
+ account_id: row.account_id,
1187
+ recorded_at: row.recorded_at
1188
+ })),
1189
+ cursor: next
1190
+ };
1191
+ }
1192
+ /**
1193
+ * Record a named durable consumer's position. Registered consumers hold
1194
+ * back change-table pruning until `maxFeedHoldDays` after their last update.
1195
+ */
1196
+ async saveFeedConsumerCursor(name, cursor) {
1197
+ if (!this.changeFeed) throw new Error("saveFeedConsumerCursor requires the store changeFeed option");
1198
+ let account;
1199
+ try {
1200
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
1201
+ account = typeof parsed.account === "string" ? parsed.account : null;
1202
+ } catch {
1203
+ throw new RangeError("feed consumer cursor is invalid");
1204
+ }
1205
+ const decoded = (0, import_change_feed.decodeReportingChangeCursorV1)(cursor, account);
1206
+ await this.query(
1207
+ `INSERT INTO adcp_reporting_feed_consumers (consumer_name, account_id, cursor_xid, cursor_seq)
1208
+ VALUES ($1, $2, $3::text::xid8, $4::bigint)
1209
+ ON CONFLICT (consumer_name) DO UPDATE
1210
+ SET account_id = EXCLUDED.account_id, cursor_xid = EXCLUDED.cursor_xid,
1211
+ cursor_seq = EXCLUDED.cursor_seq, updated_at = clock_timestamp()`,
1212
+ [name, account, account === null ? decoded.xid : null, decoded.seq]
1213
+ );
1214
+ }
1215
+ /**
1216
+ * Delete change rows older than `changeRetentionDays` that every consumer
1217
+ * updated within `maxFeedHoldDays` has passed, and advance the expiry
1218
+ * horizon so older cursors fail closed.
1219
+ */
1220
+ async pruneChanges(input = {}) {
1221
+ if (!this.changeFeed) return { deleted: 0 };
1222
+ const retentionDays = input.changeRetentionDays ?? 30;
1223
+ const holdDays = input.maxFeedHoldDays ?? 7;
1224
+ positiveInteger(retentionDays, "changeRetentionDays");
1225
+ positiveInteger(holdDays, "maxFeedHoldDays");
1226
+ const limit = input.limit ?? 1e4;
1227
+ positiveInteger(limit, "limit");
1228
+ const deleted = await this.transaction(async (client) => {
1229
+ const victims = await client.query(
1230
+ `WITH victims AS (
1231
+ SELECT change.seq FROM adcp_reporting_changes change
1232
+ WHERE change.recorded_at < clock_timestamp() - ($1::integer * INTERVAL '1 day')
1233
+ AND NOT EXISTS (
1234
+ SELECT 1 FROM adcp_reporting_feed_consumers consumer
1235
+ WHERE consumer.updated_at > clock_timestamp() - ($2::integer * INTERVAL '1 day')
1236
+ AND ((consumer.account_id IS NULL
1237
+ AND (change.xid, change.seq) > (consumer.cursor_xid, consumer.cursor_seq))
1238
+ OR (consumer.account_id = change.account_id AND change.seq > consumer.cursor_seq))
1239
+ )
1240
+ ORDER BY change.seq LIMIT $3
1241
+ )
1242
+ DELETE FROM adcp_reporting_changes change USING victims
1243
+ WHERE change.seq = victims.seq
1244
+ RETURNING change.seq::text AS seq, change.xid::text AS xid, change.account_id`,
1245
+ [retentionDays, holdDays, limit]
1246
+ );
1247
+ if (victims.rows.length === 0) return 0;
1248
+ const highest = victims.rows.reduce(
1249
+ (max, row) => BigInt(row.xid) > BigInt(max.xid) || row.xid === max.xid && BigInt(row.seq) > BigInt(max.seq) ? row : max
1250
+ );
1251
+ await client.query(
1252
+ `UPDATE adcp_reporting_change_horizon
1253
+ SET (pruned_through_xid, pruned_through_seq) = ($1::text::xid8, $2::bigint)
1254
+ WHERE singleton AND ($1::text::xid8, $2::bigint) > (pruned_through_xid, pruned_through_seq)`,
1255
+ [highest.xid, highest.seq]
1256
+ );
1257
+ const perAccount = /* @__PURE__ */ new Map();
1258
+ for (const row of victims.rows) {
1259
+ const seq = BigInt(row.seq);
1260
+ if (seq > (perAccount.get(row.account_id) ?? 0n)) perAccount.set(row.account_id, seq);
1261
+ }
1262
+ for (const [accountId, seq] of perAccount) {
1263
+ await client.query(
1264
+ `INSERT INTO adcp_reporting_change_account_horizon (account_id, pruned_through_seq) VALUES ($1, $2::bigint)
1265
+ ON CONFLICT (account_id) DO UPDATE
1266
+ SET pruned_through_seq = GREATEST(adcp_reporting_change_account_horizon.pruned_through_seq,
1267
+ EXCLUDED.pruned_through_seq)`,
1268
+ [accountId, seq.toString()]
1269
+ );
1270
+ }
1271
+ return victims.rows.length;
1272
+ });
1273
+ return { deleted };
1274
+ }
1275
+ /**
1276
+ * Retire whole reporting periods whose retention has elapsed (shared SDK
1277
+ * persistence spec §4). A period is eligible once both its end and its
1278
+ * latest publication are older than `max(statusRetentionDays,
1279
+ * recordRetentionDays)`, no worker holds its lease, and no Managed Delivery
1280
+ * materialization still references it. Retirement is period-aligned and
1281
+ * resumable:
1282
+ *
1283
+ * 1. under the account lock, record a `retiring` tombstone and mark the
1284
+ * period's row sets `pruning`, so exact reads answer as expired;
1285
+ * 2. delete the period's external row objects at their recorded versions;
1286
+ * 3. under the account lock, delete the period's ledger records (rows,
1287
+ * revisions, adjustments, consumer statuses, issues, transitions,
1288
+ * lifecycle state, obligation) and mark the tombstone `retired`.
1289
+ *
1290
+ * Planners skip retired periods, and `ledger_retained_from` advances past
1291
+ * them because their obligations no longer exist.
1292
+ */
1293
+ async retireExpiredPeriods(input) {
1294
+ if (!Number.isSafeInteger(input.statusRetentionDays) || input.statusRetentionDays < 1) {
1295
+ throw new RangeError("statusRetentionDays must be a positive integer");
1296
+ }
1297
+ const recordDays = input.recordRetentionDays ?? input.statusRetentionDays;
1298
+ if (!Number.isSafeInteger(recordDays) || recordDays < input.statusRetentionDays) {
1299
+ throw new RangeError("recordRetentionDays must be an integer no shorter than statusRetentionDays");
1300
+ }
1301
+ const limit = input.limit ?? 100;
1302
+ positiveInteger(limit, "limit");
1303
+ const tables = await this.optionalTables();
1304
+ const materializationHold = tables.has("adcp_reporting_materializations") ? `AND NOT EXISTS (SELECT 1 FROM adcp_reporting_materializations hold WHERE hold.obligation_id = obligation.obligation_id)` : "";
1305
+ const candidates = await this.query(
1306
+ `SELECT obligation_id, account_id FROM (
1307
+ SELECT tombstone.obligation_id, tombstone.account_id, 0 AS resume, tombstone.period_end
1308
+ FROM adcp_reporting_obligation_tombstones tombstone
1309
+ WHERE tombstone.state = 'retiring' AND ($2::text IS NULL OR tombstone.account_id = $2)
1310
+ UNION ALL
1311
+ SELECT obligation.obligation_id, obligation.account_id, 1, obligation.period_end
1312
+ FROM adcp_reporting_obligations obligation
1313
+ WHERE ($2::text IS NULL OR obligation.account_id = $2)
1314
+ AND obligation.period_end + ($3::integer * INTERVAL '1 day') <= clock_timestamp()
1315
+ AND (obligation.lease_expires_at IS NULL OR obligation.lease_expires_at <= clock_timestamp())
1316
+ AND COALESCE((SELECT MAX(revision.recorded_at) FROM adcp_reporting_revisions revision
1317
+ WHERE revision.obligation_id = obligation.obligation_id), obligation.created_at)
1318
+ + ($3::integer * INTERVAL '1 day') <= clock_timestamp()
1319
+ AND COALESCE((SELECT MAX(adjustment.recorded_at) FROM adcp_reporting_adjustments adjustment
1320
+ WHERE adjustment.obligation_id = obligation.obligation_id), '-infinity'::timestamptz)
1321
+ + ($3::integer * INTERVAL '1 day') <= clock_timestamp()
1322
+ AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1323
+ WHERE tombstone.obligation_id = obligation.obligation_id)
1324
+ ${materializationHold}
1325
+ ) candidate
1326
+ ORDER BY resume, period_end, obligation_id
1327
+ LIMIT $1`,
1328
+ [limit, input.account_id ?? null, recordDays]
1329
+ );
1330
+ let retired = 0;
1331
+ const failed = [];
1332
+ const failures = [];
1333
+ for (const candidate of candidates.rows) {
1334
+ input.signal?.throwIfAborted();
1335
+ try {
1336
+ await this.retirePeriod(candidate.obligation_id, candidate.account_id, tables, input.signal);
1337
+ retired += 1;
1338
+ } catch (error) {
1339
+ if (error instanceof ReportingLedgerPeriodRetiredError) continue;
1340
+ failed.push(candidate.obligation_id);
1341
+ failures.push({
1342
+ reporting_obligation_id: candidate.obligation_id,
1343
+ cause: (0, import_row_storage.isReportingRowStoreError)(error) ? error.code : error instanceof Error ? error.name : "Error"
1344
+ });
1345
+ }
1346
+ }
1347
+ return { retired, failed, failures };
1348
+ }
1349
+ async retirePeriod(obligationId, accountId, tables, signal) {
1350
+ const rowStorage = tables.has("adcp_reporting_row_sets");
1351
+ await this.transaction(
1352
+ async (client) => {
1353
+ await client.query(
1354
+ `INSERT INTO adcp_reporting_obligation_tombstones
1355
+ (obligation_id, account_id, configuration_id, period_ordinal, period_start, period_end, state,
1356
+ final_revision_id, final_content_sha256, revision_count, adjustment_count)
1357
+ SELECT obligation.obligation_id, obligation.account_id, obligation.configuration_id,
1358
+ obligation.period_ordinal, obligation.period_start, obligation.period_end, 'retiring',
1359
+ latest.revision_id, latest.content_sha256,
1360
+ (SELECT count(*) FROM adcp_reporting_revisions revision WHERE revision.obligation_id = obligation.obligation_id),
1361
+ (SELECT count(*) FROM adcp_reporting_adjustments adjustment WHERE adjustment.obligation_id = obligation.obligation_id)
1362
+ FROM adcp_reporting_obligations obligation
1363
+ LEFT JOIN LATERAL (
1364
+ SELECT revision.revision_id, revision.content_sha256 FROM adcp_reporting_revisions revision
1365
+ WHERE revision.obligation_id = obligation.obligation_id
1366
+ ORDER BY revision.revision_number DESC LIMIT 1
1367
+ ) latest ON TRUE
1368
+ WHERE obligation.obligation_id = $1
1369
+ ON CONFLICT (obligation_id) DO NOTHING`,
1370
+ [obligationId]
1371
+ );
1372
+ if (rowStorage) {
1373
+ await client.query(
1374
+ `UPDATE adcp_reporting_row_sets SET rows_state = 'pruning', rows_state_changed_at = clock_timestamp()
1375
+ WHERE obligation_id = $1 AND rows_state IN ('live', 'unavailable')`,
1376
+ [obligationId]
1377
+ );
1378
+ }
1379
+ },
1380
+ { preBeginAdvisoryLock: accountLock(accountId) }
1381
+ );
1382
+ if (rowStorage) {
1383
+ const objects = await this.query(
1384
+ `SELECT DISTINCT row_set.row_binding_id, chunk.object_key, chunk.native_version
1385
+ FROM adcp_reporting_row_chunks chunk
1386
+ JOIN adcp_reporting_row_sets row_set ON row_set.row_set_id = chunk.row_set_id
1387
+ WHERE chunk.obligation_id = $1 AND chunk.object_key IS NOT NULL`,
1388
+ [obligationId]
1389
+ );
1390
+ const byBinding = /* @__PURE__ */ new Map();
1391
+ for (const row of objects.rows) {
1392
+ const list = byBinding.get(row.row_binding_id) ?? [];
1393
+ list.push({ objectKey: row.object_key, nativeVersion: row.native_version });
1394
+ byBinding.set(row.row_binding_id, list);
1395
+ }
1396
+ for (const [bindingId, list] of byBinding) await this.rowStorage.deleteObjects(bindingId, list, signal);
1397
+ }
1398
+ await this.transaction(
1399
+ async (client) => {
1400
+ const revisionIds = `SELECT revision_id FROM adcp_reporting_revisions WHERE obligation_id = $1`;
1401
+ if (rowStorage) {
1402
+ await client.query(`DELETE FROM adcp_reporting_row_chunks WHERE obligation_id = $1`, [obligationId]);
1403
+ await client.query(`DELETE FROM adcp_reporting_row_sets WHERE obligation_id = $1`, [obligationId]);
1404
+ await client.query(`DELETE FROM adcp_reporting_chunk_bodies WHERE account_id = $2 AND obligation_id = $1`, [
1405
+ obligationId,
1406
+ accountId
1407
+ ]);
1408
+ }
1409
+ await client.query(
1410
+ `DELETE FROM adcp_reporting_consumer_statuses
1411
+ WHERE obligation_id = $1 OR revision_id IN (${revisionIds})`,
1412
+ [obligationId]
1413
+ );
1414
+ await client.query(`DELETE FROM adcp_reporting_adjustments WHERE obligation_id = $1`, [obligationId]);
1415
+ await client.query(`DELETE FROM adcp_reporting_revisions WHERE obligation_id = $1`, [obligationId]);
1416
+ await client.query(`DELETE FROM adcp_reporting_issues WHERE obligation_id = $1`, [obligationId]);
1417
+ await client.query(`DELETE FROM adcp_reporting_transitions WHERE obligation_id = $1`, [obligationId]);
1418
+ await client.query(`DELETE FROM adcp_reporting_lifecycle_state WHERE obligation_id = $1`, [obligationId]);
1419
+ await client.query(`DELETE FROM adcp_reporting_obligations WHERE obligation_id = $1`, [obligationId]);
1420
+ const retiredNow = await client.query(
1421
+ `UPDATE adcp_reporting_obligation_tombstones SET state = 'retired', retired_at = clock_timestamp()
1422
+ WHERE obligation_id = $1 AND state = 'retiring'`,
1423
+ [obligationId]
1424
+ );
1425
+ if (retiredNow.rowCount === 1) {
1426
+ await this.appendChange(client, "retirement", accountId, obligationId, obligationId);
1427
+ }
1428
+ },
1429
+ { preBeginAdvisoryLock: accountLock(accountId) }
1430
+ );
1431
+ }
1432
+ /**
1433
+ * Resolve abandoned object uploads (spec §4.3). An expired `open` write
1434
+ * intent whose ledger commit never happened is moved to `sweeping` under
1435
+ * the account lock, so no writer can commit against it, then only the
1436
+ * objects that upload created — never adopted ones, never any object a
1437
+ * committed chunk references — are deleted at their recorded versions.
1438
+ * Interrupted sweeps resume.
1439
+ */
1440
+ async sweepRowWriteIntents(input = {}) {
1441
+ const limit = input.limit ?? 100;
1442
+ positiveInteger(limit, "limit");
1443
+ const tables = await this.optionalTables();
1444
+ if (!tables.has("adcp_reporting_row_write_intents")) return { swept: 0, objectsDeleted: 0, failed: 0 };
1445
+ const expired = await this.query(
1446
+ `SELECT row_binding_id, account_id, row_set_id, content_sha256 FROM adcp_reporting_row_write_intents
1447
+ WHERE ($2::text IS NULL OR account_id = $2)
1448
+ AND (state = 'sweeping' OR expires_at <= clock_timestamp())
1449
+ ORDER BY expires_at LIMIT $1`,
1450
+ [limit, input.account_id ?? null]
1451
+ );
1452
+ let swept = 0;
1453
+ let objectsDeleted = 0;
1454
+ let failed = 0;
1455
+ for (const intent of expired.rows) {
1456
+ input.signal?.throwIfAborted();
1457
+ const key = [intent.row_binding_id, intent.row_set_id, intent.content_sha256];
1458
+ try {
1459
+ const claimed = await this.transaction(
1460
+ async (client) => {
1461
+ const updated = await client.query(
1462
+ `UPDATE adcp_reporting_row_write_intents SET state = 'sweeping'
1463
+ WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3
1464
+ AND (state = 'sweeping' OR expires_at <= clock_timestamp())
1465
+ RETURNING created_objects`,
1466
+ key
1467
+ );
1468
+ return updated.rows[0]?.created_objects;
1469
+ },
1470
+ { preBeginAdvisoryLock: accountLock(intent.account_id) }
1471
+ );
1472
+ if (!claimed) continue;
1473
+ const orphaned = [];
1474
+ for (const object of claimed) {
1475
+ const referenced = await this.query(
1476
+ `SELECT 1 FROM adcp_reporting_row_chunks WHERE object_key = $1 AND native_version = $2 LIMIT 1`,
1477
+ [object.objectKey, object.nativeVersion]
1478
+ );
1479
+ if (referenced.rows.length === 0) orphaned.push(object);
1480
+ }
1481
+ if (orphaned.length > 0) await this.rowStorage.deleteObjects(intent.row_binding_id, orphaned, input.signal);
1482
+ await this.query(
1483
+ `DELETE FROM adcp_reporting_row_write_intents
1484
+ WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3 AND state = 'sweeping'`,
1485
+ key
1486
+ );
1487
+ swept += 1;
1488
+ objectsDeleted += orphaned.length;
1489
+ } catch {
1490
+ failed += 1;
1491
+ }
1492
+ }
1493
+ return { swept, objectsDeleted, failed };
1494
+ }
1495
+ async optionalTables() {
1496
+ const result = await this.query(
1497
+ `SELECT table_name AS name FROM information_schema.tables
1498
+ WHERE table_schema = current_schema()
1499
+ AND table_name IN ('adcp_reporting_row_sets', 'adcp_reporting_row_write_intents',
1500
+ 'adcp_reporting_materializations')`
1501
+ );
1502
+ return new Set(result.rows.map((row) => row.name));
1503
+ }
1504
+ /**
1505
+ * Upload an object-kind row set before its ledger commit (spec §3.5):
1506
+ * skip when the document already exists (replay), refuse unless the lease
1507
+ * still covers the upload deadline, open or take over an expired write
1508
+ * intent under the account lock, then write every chunk create-only,
1509
+ * recording each object this upload created in the intent first.
1510
+ */
1511
+ async uploadRowSetUnlessCommitted(prepared, lease, table, idColumn) {
1512
+ const { input } = prepared;
1513
+ const committed = await this.query(`SELECT 1 FROM ${table} WHERE ${idColumn} = $1`, [input.rowSetId]);
1514
+ if (committed.rows.length > 0) return void 0;
1515
+ const shared = await this.rowStorage.findShareable(this.pool, prepared);
1516
+ if (shared) return { locators: shared.locators, sharedFrom: shared.rowSetId };
1517
+ const ownerToken = (0, import_row_storage.reportingRowWriteIntentIdV1)();
1518
+ const budget = this.rowStorage.writeDeadlineMilliseconds;
1519
+ const intentKey = [prepared.bindingId, input.rowSetId, input.binding.sha256];
1520
+ await this.transaction(
1521
+ async (client) => {
1522
+ const opened = await client.query(
1523
+ `INSERT INTO adcp_reporting_row_write_intents
1524
+ (row_binding_id, account_id, row_set_id, content_sha256, state, owner_token, expires_at)
1525
+ SELECT $1, $2, $3, $4, 'open', $5, obligation.lease_expires_at + ($6::bigint * INTERVAL '1 millisecond')
1526
+ FROM adcp_reporting_obligations obligation
1527
+ WHERE obligation.obligation_id = $7 AND obligation.lease_owner = $8
1528
+ AND obligation.lease_generation = $9
1529
+ AND obligation.lease_expires_at - clock_timestamp() >= (($6::bigint + 5000) * INTERVAL '1 millisecond')
1530
+ ON CONFLICT (row_binding_id, row_set_id, content_sha256) DO UPDATE
1531
+ SET owner_token = EXCLUDED.owner_token, expires_at = EXCLUDED.expires_at
1532
+ WHERE adcp_reporting_row_write_intents.state = 'open'
1533
+ AND adcp_reporting_row_write_intents.expires_at <= clock_timestamp()
1534
+ RETURNING owner_token`,
1535
+ [
1536
+ prepared.bindingId,
1537
+ input.accountId,
1538
+ input.rowSetId,
1539
+ input.binding.sha256,
1540
+ ownerToken,
1541
+ budget,
1542
+ input.obligationId,
1543
+ lease.owner,
1544
+ lease.generation
1545
+ ]
1546
+ );
1547
+ if (opened.rows.length === 1) return;
1548
+ const held = await client.query(
1549
+ `SELECT lease_expires_at - clock_timestamp() >= (($4::bigint + 5000) * INTERVAL '1 millisecond') AS budget
1550
+ FROM adcp_reporting_obligations
1551
+ WHERE obligation_id = $1 AND lease_owner = $2 AND lease_generation = $3
1552
+ AND lease_expires_at > clock_timestamp()`,
1553
+ [input.obligationId, lease.owner, lease.generation, budget]
1554
+ );
1555
+ if (held.rows.length === 0) throw new ReportingLedgerLeaseLostError();
1556
+ if (!held.rows[0].budget) {
1557
+ throw new import_row_storage.ReportingRowStoreError("DEADLINE_EXCEEDED", "the lease does not cover the row upload deadline");
1558
+ }
1559
+ throw new import_row_storage.ReportingRowStoreError("STATE_UNAVAILABLE", "another writer holds this row upload");
1560
+ },
1561
+ { preBeginAdvisoryLock: accountLock(input.accountId) }
1562
+ );
1563
+ const locators = await this.rowStorage.upload(prepared, ownerToken, async (object) => {
1564
+ const recorded = await this.query(
1565
+ `UPDATE adcp_reporting_row_write_intents
1566
+ SET created_objects = created_objects || $5::jsonb
1567
+ WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3
1568
+ AND owner_token = $4 AND state = 'open'`,
1569
+ [...intentKey, ownerToken, JSON.stringify([object])]
1570
+ );
1571
+ if (recorded.rowCount !== 1) {
1572
+ throw new import_row_storage.ReportingRowStoreError("STATE_UNAVAILABLE", "the row upload intent was taken over or swept");
1573
+ }
1574
+ });
1575
+ return { locators, ownerToken };
1576
+ }
1577
+ /** Persist a prepared row set and close its upload intent in the ledger commit transaction. */
1578
+ async writePreparedRowSet(client, prepared, upload) {
1579
+ if (prepared.binding.kind === "postgres") {
1580
+ const shared = await this.rowStorage.findShareable(client, prepared);
1581
+ await this.rowStorage.writeInTransaction(client, prepared, void 0, shared?.rowSetId);
1582
+ return;
1583
+ }
1584
+ if (!upload) throw new import_row_storage.ReportingRowStoreError("STATE_UNAVAILABLE", "object rows were not uploaded");
1585
+ if (upload.sharedFrom) {
1586
+ const live = await client.query(
1587
+ `SELECT 1 FROM adcp_reporting_row_sets WHERE row_set_id = $1 AND rows_state = 'live' FOR SHARE`,
1588
+ [upload.sharedFrom]
1589
+ );
1590
+ if (live.rows.length !== 1) {
1591
+ throw new import_row_storage.ReportingRowStoreError("STATE_UNAVAILABLE", "the shared row set is no longer live");
1592
+ }
1593
+ } else {
1594
+ const closed = await client.query(
1595
+ `DELETE FROM adcp_reporting_row_write_intents
1596
+ WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3
1597
+ AND owner_token = $4 AND state = 'open'`,
1598
+ [prepared.bindingId, prepared.input.rowSetId, prepared.input.binding.sha256, upload.ownerToken]
1599
+ );
1600
+ if (closed.rowCount !== 1) {
1601
+ throw new import_row_storage.ReportingRowStoreError("STATE_UNAVAILABLE", "the row upload intent was taken over or swept");
1602
+ }
1603
+ }
1604
+ await this.rowStorage.writeInTransaction(client, prepared, upload.locators, upload.sharedFrom);
1605
+ }
1606
+ /** Attach verified rows to a revision document whose rows live in row storage. */
1607
+ async hydrateRevision(revision, accountId) {
1608
+ if (Array.isArray(revision.rows)) return revision;
1609
+ const handle = await this.rowStorage.find(revision.reporting_revision_id, accountId);
1610
+ if (!handle) throw new import_row_storage.ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "revision rows are not recorded");
1611
+ if (handle.contentSha256 !== revision.binding.sha256 || handle.rowCount !== revision.binding.rowCount) {
1612
+ throw new import_row_storage.ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "row set does not match the revision binding");
1613
+ }
1614
+ return { ...revision, rows: await this.rowStorage.readAll(handle, revision.wireRevision.control_totals) };
1615
+ }
1616
+ async listAdjustmentMetadata(obligationId) {
1617
+ const result = await this.query(
1618
+ `SELECT data - 'rows' AS data FROM adcp_reporting_adjustments WHERE obligation_id = $1
764
1619
  ORDER BY adjustment_number, adjustment_id`,
765
1620
  [obligationId]
766
1621
  );
@@ -2612,7 +3467,7 @@ ${managedDueArm} )
2612
3467
  releaseError = rollbackCause instanceof Error ? rollbackCause : new Error("Reporting ledger rollback failed");
2613
3468
  }
2614
3469
  }
2615
- if (error instanceof ReportingLedgerLeaseLostError || error instanceof ReportingLedgerContinuityError || error instanceof import_types.ReportingLedgerSnapshotUnavailableError || error instanceof import_types2.ReportingConsumerStatusConflictError) {
3470
+ if (error instanceof ReportingLedgerLeaseLostError || error instanceof ReportingLedgerContinuityError || error instanceof ReportingLedgerPeriodRetiredError || error instanceof import_types.ReportingLedgerSnapshotUnavailableError || error instanceof import_types2.ReportingConsumerStatusConflictError || (0, import_row_storage.isReportingRowStoreError)(error)) {
2616
3471
  throw error;
2617
3472
  }
2618
3473
  throw new Error("PostgresReportingLedgerStore transaction failed", { cause: error });
@@ -3063,6 +3918,13 @@ function clone(value) {
3063
3918
  function positiveInteger(value, name) {
3064
3919
  if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
3065
3920
  }
3921
+ function withoutRows(value) {
3922
+ const { rows: _rows, ...rest } = value;
3923
+ return rest;
3924
+ }
3925
+ function withRows(value, rows) {
3926
+ return Array.isArray(value.rows) ? value : { ...value, rows: clone(rows) };
3927
+ }
3066
3928
  // Annotate the CommonJS export names for ESM import in node:
3067
3929
  0 && (module.exports = {
3068
3930
  PostgresReportingLedgerStore,
@@ -3071,5 +3933,6 @@ function positiveInteger(value, name) {
3071
3933
  REPORTING_LEDGER_MIGRATION,
3072
3934
  ReportingLedgerContinuityError,
3073
3935
  ReportingLedgerLeaseLostError,
3936
+ ReportingLedgerPeriodRetiredError,
3074
3937
  sweepExpiredReportingLedgerState
3075
3938
  });