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