@adcp/sdk 15.7.1 → 15.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/lib/core/SingleAgentClient.d.mts +14 -0
  2. package/dist/lib/core/SingleAgentClient.d.ts +14 -0
  3. package/dist/lib/core/SingleAgentClient.js +138 -54
  4. package/dist/lib/core/SingleAgentClient.mjs +141 -54
  5. package/dist/lib/core/agent-discovery-cache.d.mts +2 -0
  6. package/dist/lib/core/agent-discovery-cache.d.ts +2 -0
  7. package/dist/lib/core/creative-schema-support.d.mts +3 -0
  8. package/dist/lib/core/creative-schema-support.d.ts +3 -0
  9. package/dist/lib/core/creative-schema-support.js +113 -0
  10. package/dist/lib/core/creative-schema-support.mjs +89 -0
  11. package/dist/lib/core/product-cache.js +4 -3
  12. package/dist/lib/core/product-cache.mjs +4 -3
  13. package/dist/lib/reporting/ledger/handler.d.mts +10 -3
  14. package/dist/lib/reporting/ledger/handler.d.ts +10 -3
  15. package/dist/lib/reporting/ledger/handler.js +17 -4
  16. package/dist/lib/reporting/ledger/handler.mjs +17 -4
  17. package/dist/lib/reporting/ledger/managed.d.mts +3 -1
  18. package/dist/lib/reporting/ledger/managed.d.ts +3 -1
  19. package/dist/lib/reporting/ledger/managed.js +4 -1
  20. package/dist/lib/reporting/ledger/managed.mjs +4 -1
  21. package/dist/lib/reporting/ledger/postgres.d.mts +87 -0
  22. package/dist/lib/reporting/ledger/postgres.d.ts +87 -0
  23. package/dist/lib/reporting/ledger/postgres.js +314 -13
  24. package/dist/lib/reporting/ledger/postgres.mjs +314 -13
  25. package/dist/lib/reporting/ledger/row-storage.d.mts +23 -0
  26. package/dist/lib/reporting/ledger/row-storage.d.ts +23 -0
  27. package/dist/lib/reporting/ledger/row-storage.js +56 -0
  28. package/dist/lib/reporting/ledger/row-storage.mjs +56 -0
  29. package/dist/lib/reporting/ledger/types.d.mts +26 -0
  30. package/dist/lib/reporting/ledger/types.d.ts +26 -0
  31. package/dist/lib/reporting/service/index.d.mts +33 -3
  32. package/dist/lib/reporting/service/index.d.ts +33 -3
  33. package/dist/lib/reporting/service/index.js +20 -2
  34. package/dist/lib/reporting/service/index.mjs +20 -2
  35. package/dist/lib/reporting/service/maintenance.d.mts +20 -1
  36. package/dist/lib/reporting/service/maintenance.d.ts +20 -1
  37. package/dist/lib/reporting/service/maintenance.js +25 -2
  38. package/dist/lib/reporting/service/maintenance.mjs +25 -2
  39. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  40. package/dist/lib/testing/storyboard/request-builder.js +14 -16
  41. package/dist/lib/testing/storyboard/request-builder.mjs +14 -16
  42. package/dist/lib/testing/storyboard/validations.d.mts +1 -1
  43. package/dist/lib/testing/storyboard/validations.d.ts +1 -1
  44. package/dist/lib/v2/projection/creative-delivery.d.mts +22 -0
  45. package/dist/lib/v2/projection/creative-delivery.d.ts +22 -0
  46. package/dist/lib/v2/projection/creative-delivery.js +92 -0
  47. package/dist/lib/v2/projection/creative-delivery.mjs +91 -0
  48. package/dist/lib/v2/projection/legacy-metadata.d.mts +6 -3
  49. package/dist/lib/v2/projection/legacy-metadata.d.ts +6 -3
  50. package/dist/lib/v2/projection/legacy-metadata.js +17 -0
  51. package/dist/lib/v2/projection/legacy-metadata.mjs +15 -0
  52. package/dist/lib/version.d.mts +3 -3
  53. package/dist/lib/version.d.ts +3 -3
  54. package/dist/lib/version.js +3 -3
  55. package/dist/lib/version.mjs +3 -3
  56. package/docs/TYPE-SUMMARY.md +2 -2
  57. package/docs/guides/CREATIVE-DELIVERY.md +16 -0
  58. package/docs/guides/REPORTING-LEDGER-CUTOVER.md +405 -0
  59. package/docs/guides/REPORTING-LEDGER.md +130 -0
  60. package/docs/guides/REPORTING-OPERATIONS.md +12 -0
  61. package/docs/llms.txt +3 -2
  62. package/docs/migration-14.x-rc-worksheet.md +4 -4
  63. package/package.json +1 -1
@@ -443,6 +443,14 @@ class PostgresReportingLedgerStore {
443
443
  [accountId, kind, recordId, obligationId]
444
444
  );
445
445
  }
446
+ /**
447
+ * Throws `INVALID_INPUT` when any configured object row binding's key template omits
448
+ * `{revision_key}`, which makes snapshot compaction unsafe (see `compactSupersededSnapshots`).
449
+ * Called by `compactSupersededSnapshots` and by service construction when compaction is enabled.
450
+ */
451
+ assertCompactionSafeRowBindings() {
452
+ this.rowStorage.assertCompactionSafeKeyTemplates();
453
+ }
446
454
  /** Probe and register row storage when enabled. Safe to call repeatedly. */
447
455
  async readyRowStorage() {
448
456
  if (this.rowStorageWrites) await this.rowStorage.ready();
@@ -953,7 +961,14 @@ ${managedDueArm} )
953
961
  [obligationId]
954
962
  );
955
963
  const revisions = [];
956
- for (const row of result.rows) revisions.push(await this.hydrateRevision(clone(row.data), row.account_id));
964
+ for (const row of result.rows) {
965
+ try {
966
+ revisions.push(await this.hydrateRevision(clone(row.data), row.account_id));
967
+ } catch (error) {
968
+ if (isReportingRowStoreError(error) && error.code === "ROWS_EXPIRED") continue;
969
+ throw error;
970
+ }
971
+ }
957
972
  return revisions;
958
973
  }
959
974
  async listRevisionMetadata(obligationId, accountId) {
@@ -1424,7 +1439,9 @@ ${managedDueArm} )
1424
1439
  list.push({ objectKey: row.object_key, nativeVersion: row.native_version });
1425
1440
  byBinding.set(row.row_binding_id, list);
1426
1441
  }
1427
- for (const [bindingId, list] of byBinding) await this.rowStorage.deleteObjects(bindingId, list, signal);
1442
+ for (const [bindingId, list] of byBinding) {
1443
+ await this.deleteUnreferencedObjects(bindingId, list, { obligationId }, signal);
1444
+ }
1428
1445
  }
1429
1446
  await this.transaction(
1430
1447
  async (client) => {
@@ -1460,6 +1477,228 @@ ${managedDueArm} )
1460
1477
  { preBeginAdvisoryLock: accountLock(accountId) }
1461
1478
  );
1462
1479
  }
1480
+ /**
1481
+ * Compact superseded snapshot revisions to metadata only, as adcontextprotocol/adcp#8100
1482
+ * permits (`reporting-revision.json` `content_retention`). Opt-in: nothing calls this
1483
+ * unless the host enables `compaction` in maintenance, and it must not be enabled in
1484
+ * production before the AdCP release that takes compaction into effect (the next
1485
+ * eligible minor after November 20, 2026).
1486
+ *
1487
+ * A snapshot revision is compacted only when every one of these holds:
1488
+ *
1489
+ * - it is superseded: a later revision names it in `supersedes_reporting_revision_id`
1490
+ * (so a current revision, and every official revision, is never compacted);
1491
+ * - it is not the revision the current, unsuperseded revision names;
1492
+ * - its recovery grace has passed: `max(automatedRecoveryWindowSeconds, 3600)` seconds
1493
+ * since its successor's `created_at`;
1494
+ * - while its window is open (`statusRetentionDays` since its successor's `created_at`),
1495
+ * no caller's reporting receipt (any status) names it and no caller's current consumer
1496
+ * status statement of `received`, `unreadable` or `content_mismatch` names it. The
1497
+ * check spans every caller, so content any caller is entitled to is kept;
1498
+ * - it has rows (nothing to reclaim otherwise, and an empty exact read is still correct);
1499
+ * - no live obligation lease, no pending materialization of it, and no live change-feed
1500
+ * consumer that has not yet passed its change row (the holds retirement applies,
1501
+ * narrowed to this revision).
1502
+ *
1503
+ * Only row content is dropped: the revision header, its manifest and every metadata
1504
+ * listing stay. The row set is marked `pruned`, so an exact read answers
1505
+ * `ROWS_EXPIRED`, which the handlers surface as the same nondisclosing
1506
+ * `REFERENCE_NOT_FOUND` as an unknown ID. No change-feed row is recorded: the revision's
1507
+ * metadata is unchanged, and a sink still catching up holds compaction back.
1508
+ *
1509
+ * Compaction is resumable and runs in the same three phases as retirement, under the
1510
+ * account lock where eligibility is decided:
1511
+ *
1512
+ * 1. re-check eligibility and mark the row set `pruning`, so reads answer as expired and
1513
+ * no new revision can share its chunks;
1514
+ * 2. delete external objects at their recorded versions, skipping any object another
1515
+ * row set that is not being pruned still references (chunk sharing);
1516
+ * 3. delete the chunk manifest rows and any PostgreSQL chunk body nothing else
1517
+ * references, and mark the row set `pruned`.
1518
+ *
1519
+ * A crash after phase 1 leaves a `pruning` row set that the next pass resumes.
1520
+ */
1521
+ async compactSupersededSnapshots(input) {
1522
+ this.assertCompactionSafeRowBindings();
1523
+ positiveInteger(input.statusRetentionDays, "statusRetentionDays");
1524
+ positiveInteger(input.automatedRecoveryWindowSeconds, "automatedRecoveryWindowSeconds");
1525
+ const limit = input.limit ?? 100;
1526
+ positiveInteger(limit, "limit");
1527
+ const holdDays = input.maxFeedHoldDays ?? 7;
1528
+ positiveInteger(holdDays, "maxFeedHoldDays");
1529
+ const tables = await this.optionalTables();
1530
+ if (!tables.has("adcp_reporting_row_sets")) return { compacted: 0, failed: [], failures: [] };
1531
+ const graceSeconds = Math.max(input.automatedRecoveryWindowSeconds, 3600);
1532
+ const eligible = `
1533
+ FROM adcp_reporting_revisions revision
1534
+ JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
1535
+ JOIN adcp_reporting_row_sets row_set
1536
+ ON row_set.row_set_id = revision.revision_id AND row_set.row_set_kind = 'revision'
1537
+ JOIN LATERAL (
1538
+ SELECT MAX(later.created_at) AS created_at FROM adcp_reporting_revisions later
1539
+ WHERE later.supersedes_revision_id = revision.revision_id
1540
+ ) successor ON successor.created_at IS NOT NULL
1541
+ WHERE revision.finality = 'snapshot' AND revision.kind = 'snapshot'
1542
+ AND ($4::text IS NULL OR obligation.account_id = $4)
1543
+ AND ($5::text IS NULL OR revision.revision_id = $5)
1544
+ AND $3::integer > 0
1545
+ AND row_set.row_count > 0
1546
+ AND row_set.rows_state IN ('live', 'unavailable')
1547
+ AND successor.created_at + ($2::bigint * INTERVAL '1 second') <= clock_timestamp()
1548
+ AND NOT EXISTS (
1549
+ SELECT 1 FROM adcp_reporting_revisions current_revision
1550
+ WHERE current_revision.supersedes_revision_id = revision.revision_id
1551
+ AND NOT EXISTS (
1552
+ SELECT 1 FROM adcp_reporting_revisions next_revision
1553
+ WHERE next_revision.supersedes_revision_id = current_revision.revision_id
1554
+ )
1555
+ )
1556
+ AND (obligation.lease_expires_at IS NULL OR obligation.lease_expires_at <= clock_timestamp())
1557
+ AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1558
+ WHERE tombstone.obligation_id = obligation.obligation_id)
1559
+ AND (
1560
+ successor.created_at + ($1::integer * INTERVAL '1 day') <= clock_timestamp()
1561
+ OR (
1562
+ NOT EXISTS (
1563
+ SELECT 1 FROM adcp_reporting_consumer_statuses statement
1564
+ WHERE statement.revision_id = revision.revision_id AND statement.is_current
1565
+ AND COALESCE(statement.data->>'consumer_status', statement.data->'status'->>'consumer_status')
1566
+ IN ('received', 'unreadable', 'content_mismatch')
1567
+ )
1568
+ ${tables.has("adcp_reporting_receipts") ? `AND NOT EXISTS (
1569
+ SELECT 1 FROM adcp_reporting_receipts receipt
1570
+ WHERE receipt.receipt_kind = 'revision' AND receipt.subject_id = revision.revision_id
1571
+ )` : ""}
1572
+ )
1573
+ )
1574
+ ${tables.has("adcp_reporting_materializations") ? `AND NOT EXISTS (
1575
+ SELECT 1 FROM adcp_reporting_materializations hold
1576
+ WHERE hold.revision_id = revision.revision_id AND hold.status = 'pending'
1577
+ )` : ""}
1578
+ ${tables.has("adcp_reporting_changes") && tables.has("adcp_reporting_feed_consumers") ? `AND NOT EXISTS (
1579
+ SELECT 1 FROM adcp_reporting_changes change
1580
+ JOIN adcp_reporting_feed_consumers consumer
1581
+ ON consumer.updated_at > clock_timestamp() - ($3::integer * INTERVAL '1 day')
1582
+ WHERE change.record_kind = 'revision' AND change.record_id = revision.revision_id
1583
+ AND ((consumer.account_id IS NULL
1584
+ AND (change.xid, change.seq) > (consumer.cursor_xid, consumer.cursor_seq))
1585
+ OR (consumer.account_id = change.account_id AND change.seq > consumer.cursor_seq)))` : ""}`;
1586
+ const baseValues = [input.statusRetentionDays, graceSeconds, holdDays, input.account_id ?? null];
1587
+ const candidates = await this.query(
1588
+ `SELECT revision_id, obligation_id, account_id FROM (
1589
+ SELECT row_set.row_set_id AS revision_id, row_set.obligation_id, row_set.account_id, 0 AS resume,
1590
+ row_set.recorded_at
1591
+ FROM adcp_reporting_row_sets row_set
1592
+ WHERE row_set.row_set_kind = 'revision' AND row_set.rows_state = 'pruning'
1593
+ AND ($4::text IS NULL OR row_set.account_id = $4)
1594
+ AND EXISTS (SELECT 1 FROM adcp_reporting_revisions revision
1595
+ WHERE revision.revision_id = row_set.row_set_id)
1596
+ AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1597
+ WHERE tombstone.obligation_id = row_set.obligation_id)
1598
+ UNION ALL
1599
+ SELECT revision.revision_id, revision.obligation_id, obligation.account_id, 1, row_set.recorded_at
1600
+ ${eligible}
1601
+ ) candidate
1602
+ ORDER BY resume, recorded_at, revision_id
1603
+ LIMIT $6`,
1604
+ [...baseValues, null, limit]
1605
+ );
1606
+ let compacted = 0;
1607
+ const failed = [];
1608
+ const failures = [];
1609
+ for (const candidate of candidates.rows) {
1610
+ input.signal?.throwIfAborted();
1611
+ try {
1612
+ const done = await this.compactSnapshot(
1613
+ candidate.revision_id,
1614
+ candidate.obligation_id,
1615
+ candidate.account_id,
1616
+ eligible,
1617
+ baseValues,
1618
+ input.signal
1619
+ );
1620
+ if (done) compacted += 1;
1621
+ } catch (error) {
1622
+ failed.push(candidate.revision_id);
1623
+ failures.push({
1624
+ reporting_obligation_id: candidate.obligation_id,
1625
+ reporting_revision_id: candidate.revision_id,
1626
+ cause: isReportingRowStoreError(error) ? error.code : error instanceof Error ? error.name : "Error"
1627
+ });
1628
+ }
1629
+ }
1630
+ return { compacted, failed, failures };
1631
+ }
1632
+ async compactSnapshot(revisionId, obligationId, accountId, eligible, baseValues, signal) {
1633
+ const claimed = await this.transaction(
1634
+ async (client) => {
1635
+ const marked = await client.query(
1636
+ `UPDATE adcp_reporting_row_sets SET rows_state = 'pruning', rows_state_changed_at = clock_timestamp()
1637
+ WHERE row_set_id = $5 AND rows_state IN ('live', 'unavailable')
1638
+ AND row_set_id IN (SELECT revision.revision_id ${eligible})`,
1639
+ [...baseValues, revisionId]
1640
+ );
1641
+ if (marked.rowCount === 1) return true;
1642
+ const resumed = await client.query(
1643
+ `SELECT 1 FROM adcp_reporting_row_sets row_set
1644
+ WHERE row_set.row_set_id = $1 AND row_set.rows_state = 'pruning'
1645
+ AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1646
+ WHERE tombstone.obligation_id = row_set.obligation_id)`,
1647
+ [revisionId]
1648
+ );
1649
+ return resumed.rows.length === 1;
1650
+ },
1651
+ { preBeginAdvisoryLock: accountLock(accountId) }
1652
+ );
1653
+ if (!claimed) return false;
1654
+ const binding = await this.query(
1655
+ `SELECT row_binding_id FROM adcp_reporting_row_sets WHERE row_set_id = $1`,
1656
+ [revisionId]
1657
+ );
1658
+ if (binding.rows.length === 0) return false;
1659
+ const objects = await this.query(
1660
+ `SELECT DISTINCT chunk.object_key, chunk.native_version
1661
+ FROM adcp_reporting_row_chunks chunk
1662
+ WHERE chunk.row_set_id = $1 AND chunk.object_key IS NOT NULL`,
1663
+ [revisionId]
1664
+ );
1665
+ if (objects.rows.length > 0) {
1666
+ await this.deleteUnreferencedObjects(
1667
+ binding.rows[0].row_binding_id,
1668
+ objects.rows.map((row) => ({ objectKey: row.object_key, nativeVersion: row.native_version })),
1669
+ { rowSetId: revisionId },
1670
+ signal
1671
+ );
1672
+ }
1673
+ await this.transaction(
1674
+ async (client) => {
1675
+ const dropped = await client.query(
1676
+ `DELETE FROM adcp_reporting_row_chunks WHERE row_set_id = $1 RETURNING sha256, object_key`,
1677
+ [revisionId]
1678
+ );
1679
+ const bodies = [...new Set(dropped.rows.filter((row) => row.object_key === null).map((row) => row.sha256))];
1680
+ if (bodies.length > 0) {
1681
+ await client.query(
1682
+ `DELETE FROM adcp_reporting_chunk_bodies body
1683
+ WHERE body.account_id = $1 AND body.obligation_id = $2 AND body.sha256 = ANY($3::text[])
1684
+ AND NOT EXISTS (
1685
+ SELECT 1 FROM adcp_reporting_row_chunks other
1686
+ WHERE other.account_id = body.account_id AND other.obligation_id = body.obligation_id
1687
+ AND other.sha256 = body.sha256 AND other.object_key IS NULL
1688
+ )`,
1689
+ [accountId, obligationId, bodies]
1690
+ );
1691
+ }
1692
+ await client.query(
1693
+ `UPDATE adcp_reporting_row_sets SET rows_state = 'pruned', rows_state_changed_at = clock_timestamp()
1694
+ WHERE row_set_id = $1 AND rows_state = 'pruning'`,
1695
+ [revisionId]
1696
+ );
1697
+ },
1698
+ { preBeginAdvisoryLock: accountLock(accountId) }
1699
+ );
1700
+ return true;
1701
+ }
1463
1702
  /**
1464
1703
  * Resolve abandoned object uploads (spec §4.3). An expired `open` write
1465
1704
  * intent whose ledger commit never happened is moved to `sweeping` under
@@ -1501,35 +1740,85 @@ ${managedDueArm} )
1501
1740
  { preBeginAdvisoryLock: accountLock(intent.account_id) }
1502
1741
  );
1503
1742
  if (!claimed) continue;
1504
- const orphaned = [];
1505
- for (const object of claimed) {
1506
- const referenced = await this.query(
1507
- `SELECT 1 FROM adcp_reporting_row_chunks WHERE object_key = $1 AND native_version = $2 LIMIT 1`,
1508
- [object.objectKey, object.nativeVersion]
1509
- );
1510
- if (referenced.rows.length === 0) orphaned.push(object);
1511
- }
1512
- if (orphaned.length > 0) await this.rowStorage.deleteObjects(intent.row_binding_id, orphaned, input.signal);
1743
+ const deleted = await this.deleteUnreferencedObjects(intent.row_binding_id, claimed, {}, input.signal);
1513
1744
  await this.query(
1514
1745
  `DELETE FROM adcp_reporting_row_write_intents
1515
1746
  WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3 AND state = 'sweeping'`,
1516
1747
  key
1517
1748
  );
1518
1749
  swept += 1;
1519
- objectsDeleted += orphaned.length;
1750
+ objectsDeleted += deleted;
1520
1751
  } catch {
1521
1752
  failed += 1;
1522
1753
  }
1523
1754
  }
1524
1755
  return { swept, objectsDeleted, failed };
1525
1756
  }
1757
+ /**
1758
+ * Delete external row objects, skipping any object another row set still references.
1759
+ *
1760
+ * A custom `keyTemplate` need only include `{content_sha256}` and `{chunk_index}`, so row sets
1761
+ * from different obligations or accounts with identical chunk content can share one object (a
1762
+ * create-only write adopts an existing object). An object is therefore deleted only when no
1763
+ * chunk of a live (or unavailable) row set outside this operation references the same binding,
1764
+ * key and native version, and no other open write intent created it. Row sets already `pruning`
1765
+ * or `pruned` do not count: their own operation deletes the object, and counting them would let
1766
+ * two concurrent retirements each skip the object and leak it. `scope` names what this
1767
+ * operation is deleting: an obligation (retirement) or a row set (compaction).
1768
+ *
1769
+ * The check and the provider delete run in one transaction under a per-object advisory lock.
1770
+ * A commit that adopts an existing object takes the same lock before it inserts its chunk rows
1771
+ * and re-verifies the object under it (`writePreparedRowSet`), and holds it until commit. So
1772
+ * either the delete wins the lock and the adopting commit then fails cleanly, or the commit wins
1773
+ * and the delete sees its chunk rows and skips. A skipped object stays for the row set that owns
1774
+ * it: that row set's own retirement, compaction or sweep deletes it, because by then nothing
1775
+ * else references it. Locks are taken one object at a time, never while waiting on the account
1776
+ * lock, so commits (account lock, then object locks) cannot deadlock with these deletes.
1777
+ */
1778
+ async deleteUnreferencedObjects(bindingId, objects, scope, signal) {
1779
+ let deleted = 0;
1780
+ for (const object of objects) {
1781
+ signal?.throwIfAborted();
1782
+ const removed = await this.transaction(async (client) => {
1783
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
1784
+ objectLock(bindingId, object.objectKey)
1785
+ ]);
1786
+ const referenced = await client.query(
1787
+ `(SELECT 1 FROM adcp_reporting_row_chunks chunk
1788
+ JOIN adcp_reporting_row_sets row_set ON row_set.row_set_id = chunk.row_set_id
1789
+ WHERE row_set.row_binding_id = $1 AND chunk.object_key = $2 AND chunk.native_version = $3
1790
+ AND row_set.rows_state IN ('live', 'unavailable')
1791
+ AND ($4::text IS NULL OR chunk.obligation_id <> $4)
1792
+ AND ($5::text IS NULL OR chunk.row_set_id <> $5)
1793
+ LIMIT 1)
1794
+ UNION ALL
1795
+ (SELECT 1 FROM adcp_reporting_row_write_intents intent
1796
+ WHERE intent.row_binding_id = $1 AND intent.state = 'open' AND intent.created_objects @> $6::jsonb
1797
+ LIMIT 1)`,
1798
+ [
1799
+ bindingId,
1800
+ object.objectKey,
1801
+ object.nativeVersion,
1802
+ scope.obligationId ?? null,
1803
+ scope.rowSetId ?? null,
1804
+ JSON.stringify([{ objectKey: object.objectKey, nativeVersion: object.nativeVersion }])
1805
+ ]
1806
+ );
1807
+ if (referenced.rows.length > 0) return false;
1808
+ await this.rowStorage.deleteObjects(bindingId, [object], signal);
1809
+ return true;
1810
+ });
1811
+ if (removed) deleted += 1;
1812
+ }
1813
+ return deleted;
1814
+ }
1526
1815
  async optionalTables() {
1527
1816
  const result = await this.query(
1528
1817
  `SELECT table_name AS name FROM information_schema.tables
1529
1818
  WHERE table_schema = current_schema()
1530
1819
  AND table_name IN ('adcp_reporting_row_sets', 'adcp_reporting_row_write_intents',
1531
1820
  'adcp_reporting_materializations', 'adcp_reporting_changes',
1532
- 'adcp_reporting_feed_consumers')`
1821
+ 'adcp_reporting_feed_consumers', 'adcp_reporting_receipts')`
1533
1822
  );
1534
1823
  return new Set(result.rows.map((row) => row.name));
1535
1824
  }
@@ -1633,6 +1922,15 @@ ${managedDueArm} )
1633
1922
  throw new ReportingRowStoreError("STATE_UNAVAILABLE", "the row upload intent was taken over or swept");
1634
1923
  }
1635
1924
  }
1925
+ if (!upload.sharedFrom) {
1926
+ const adopted = upload.locators.filter((locator) => !locator.created).sort((left, right) => left.objectKey < right.objectKey ? -1 : left.objectKey > right.objectKey ? 1 : 0);
1927
+ for (const locator of adopted) {
1928
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
1929
+ objectLock(prepared.bindingId, locator.objectKey)
1930
+ ]);
1931
+ }
1932
+ if (adopted.length > 0) await this.rowStorage.assertObjectsPresent(prepared.bindingId, adopted);
1933
+ }
1636
1934
  await this.rowStorage.writeInTransaction(client, prepared, upload.locators, upload.sharedFrom);
1637
1935
  }
1638
1936
  /** Attach verified rows to a revision document whose rows live in row storage. */
@@ -3821,6 +4119,9 @@ function rfc3339Microseconds(expression) {
3821
4119
  function accountLock(accountId) {
3822
4120
  return `adcp-reporting-account:${accountId}`;
3823
4121
  }
4122
+ function objectLock(bindingId, objectKey) {
4123
+ return `adcp-reporting-object:${bindingId}:${objectKey}`;
4124
+ }
3824
4125
  async function resolveStoredFinalityBaseline(transaction, obligationId) {
3825
4126
  const latest = await transaction.query(
3826
4127
  `SELECT transition_id, data->>'finality' AS finality
@@ -152,6 +152,18 @@ export declare class ReportingRowStorageV1 {
152
152
  constructor(pool: QueryableV1, options?: ReportingRowStorageOptionsV1);
153
153
  /** Deadline budget a writer must still hold on its lease before uploading. */
154
154
  get writeDeadlineMilliseconds(): number;
155
+ /**
156
+ * Refuse a configuration in which compaction could delete an object a concurrent commit is
157
+ * adopting. An object binding whose key template omits `{revision_key}` can map two revisions
158
+ * with identical chunk content to one object key, and a create-only write adopts an existing
159
+ * object with identical bytes. Compaction deleting that object at the same moment would leave
160
+ * the newer revision without its rows. The default template includes `{revision_key}`.
161
+ *
162
+ * Since the reference-checked, per-object-locked delete this is no longer the only guard: every
163
+ * delete path now skips an object another row set still references. It is kept as defense in
164
+ * depth for compaction, the one path that deletes content while the obligation stays live.
165
+ */
166
+ assertCompactionSafeKeyTemplates(): void;
155
167
  /** Probe the schema and every provider, and register (or verify) every configured binding. Idempotent. */
156
168
  ready(): Promise<void>;
157
169
  /** Encode rows, prove the chunks reproduce the committed binding, select a binding and plan objects. */
@@ -199,6 +211,17 @@ export declare class ReportingRowStorageV1 {
199
211
  objectKey: string;
200
212
  nativeVersion: string;
201
213
  }[], signal?: AbortSignal): Promise<void>;
214
+ /**
215
+ * Prove each object is still present at its recorded version with the recorded bytes.
216
+ * A commit that adopted an object another row set created calls this under the object's
217
+ * lock, so a concurrent delete either finished first (this throws, the commit fails and
218
+ * the writer re-uploads) or sees the commit's chunk reference and skips the delete.
219
+ */
220
+ assertObjectsPresent(bindingId: string, objects: readonly {
221
+ objectKey: string;
222
+ nativeVersion: string;
223
+ physicalSha256: string;
224
+ }[], signal?: AbortSignal): Promise<void>;
202
225
  /**
203
226
  * A live row set in the same obligation and binding whose rows are
204
227
  * byte-identical (same manifest digest), with its chunk locations. Sharing
@@ -152,6 +152,18 @@ export declare class ReportingRowStorageV1 {
152
152
  constructor(pool: QueryableV1, options?: ReportingRowStorageOptionsV1);
153
153
  /** Deadline budget a writer must still hold on its lease before uploading. */
154
154
  get writeDeadlineMilliseconds(): number;
155
+ /**
156
+ * Refuse a configuration in which compaction could delete an object a concurrent commit is
157
+ * adopting. An object binding whose key template omits `{revision_key}` can map two revisions
158
+ * with identical chunk content to one object key, and a create-only write adopts an existing
159
+ * object with identical bytes. Compaction deleting that object at the same moment would leave
160
+ * the newer revision without its rows. The default template includes `{revision_key}`.
161
+ *
162
+ * Since the reference-checked, per-object-locked delete this is no longer the only guard: every
163
+ * delete path now skips an object another row set still references. It is kept as defense in
164
+ * depth for compaction, the one path that deletes content while the obligation stays live.
165
+ */
166
+ assertCompactionSafeKeyTemplates(): void;
155
167
  /** Probe the schema and every provider, and register (or verify) every configured binding. Idempotent. */
156
168
  ready(): Promise<void>;
157
169
  /** Encode rows, prove the chunks reproduce the committed binding, select a binding and plan objects. */
@@ -199,6 +211,17 @@ export declare class ReportingRowStorageV1 {
199
211
  objectKey: string;
200
212
  nativeVersion: string;
201
213
  }[], signal?: AbortSignal): Promise<void>;
214
+ /**
215
+ * Prove each object is still present at its recorded version with the recorded bytes.
216
+ * A commit that adopted an object another row set created calls this under the object's
217
+ * lock, so a concurrent delete either finished first (this throws, the commit fails and
218
+ * the writer re-uploads) or sees the commit's chunk reference and skips the delete.
219
+ */
220
+ assertObjectsPresent(bindingId: string, objects: readonly {
221
+ objectKey: string;
222
+ nativeVersion: string;
223
+ physicalSha256: string;
224
+ }[], signal?: AbortSignal): Promise<void>;
202
225
  /**
203
226
  * A live row set in the same obligation and binding whose rows are
204
227
  * byte-identical (same manifest digest), with its chunk locations. Sharing
@@ -81,6 +81,28 @@ class ReportingRowStorageV1 {
81
81
  get writeDeadlineMilliseconds() {
82
82
  return this.writeDeadline;
83
83
  }
84
+ /**
85
+ * Refuse a configuration in which compaction could delete an object a concurrent commit is
86
+ * adopting. An object binding whose key template omits `{revision_key}` can map two revisions
87
+ * with identical chunk content to one object key, and a create-only write adopts an existing
88
+ * object with identical bytes. Compaction deleting that object at the same moment would leave
89
+ * the newer revision without its rows. The default template includes `{revision_key}`.
90
+ *
91
+ * Since the reference-checked, per-object-locked delete this is no longer the only guard: every
92
+ * delete path now skips an object another row set still references. It is kept as defense in
93
+ * depth for compaction, the one path that deletes content while the obligation stays live.
94
+ */
95
+ assertCompactionSafeKeyTemplates() {
96
+ const unsafe = Object.entries(this.bindings).filter(
97
+ ([, binding]) => binding.kind === "object" && !(binding.keyTemplate ?? import_row_storage_object.REPORTING_ROW_DEFAULT_KEY_TEMPLATE).includes("{revision_key}")
98
+ ).map(([id]) => id);
99
+ if (unsafe.length > 0) {
100
+ throw new import_row_storage_errors.ReportingRowStoreError(
101
+ "INVALID_INPUT",
102
+ `snapshot compaction requires every object row binding's keyTemplate to include {revision_key}; binding(s) ${unsafe.join(", ")} omit it, so two revisions with identical content could share an object that compaction deletes while a concurrent commit adopts it`
103
+ );
104
+ }
105
+ }
84
106
  /** Probe the schema and every provider, and register (or verify) every configured binding. Idempotent. */
85
107
  ready() {
86
108
  this.readied ??= this.register().catch((error) => {
@@ -461,6 +483,40 @@ class ReportingRowStorageV1 {
461
483
  );
462
484
  }
463
485
  }
486
+ /**
487
+ * Prove each object is still present at its recorded version with the recorded bytes.
488
+ * A commit that adopted an object another row set created calls this under the object's
489
+ * lock, so a concurrent delete either finished first (this throws, the commit fails and
490
+ * the writer re-uploads) or sees the commit's chunk reference and skips the delete.
491
+ */
492
+ async assertObjectsPresent(bindingId, objects, signal) {
493
+ await this.ready();
494
+ const binding = this.bindings[bindingId];
495
+ if (!binding || binding.kind !== "object") {
496
+ throw new import_row_storage_errors.ReportingRowStoreError("STATE_UNAVAILABLE", `row binding ${bindingId} is not configured for objects`);
497
+ }
498
+ const provider = this.providers[binding.provider];
499
+ const context = { signal: deadlineSignal(this.writeDeadline, signal) };
500
+ for (const object of objects) {
501
+ const bytes = await this.provider(
502
+ () => provider.get(
503
+ {
504
+ ...providerLocation(binding),
505
+ key: object.objectKey,
506
+ nativeVersion: object.nativeVersion,
507
+ maxBytes: MAX_ADOPTED_OBJECT_BYTES
508
+ },
509
+ context
510
+ )
511
+ );
512
+ if (!bytes || sha256Hex(bytes) !== object.physicalSha256) {
513
+ throw new import_row_storage_errors.ReportingRowStoreError(
514
+ "STATE_UNAVAILABLE",
515
+ "an adopted row object was deleted or replaced before its commit; retry the upload"
516
+ );
517
+ }
518
+ }
519
+ }
464
520
  /**
465
521
  * A live row set in the same obligation and binding whose rows are
466
522
  * byte-identical (same manifest digest), with its chunk locations. Sharing
@@ -70,6 +70,28 @@ class ReportingRowStorageV1 {
70
70
  get writeDeadlineMilliseconds() {
71
71
  return this.writeDeadline;
72
72
  }
73
+ /**
74
+ * Refuse a configuration in which compaction could delete an object a concurrent commit is
75
+ * adopting. An object binding whose key template omits `{revision_key}` can map two revisions
76
+ * with identical chunk content to one object key, and a create-only write adopts an existing
77
+ * object with identical bytes. Compaction deleting that object at the same moment would leave
78
+ * the newer revision without its rows. The default template includes `{revision_key}`.
79
+ *
80
+ * Since the reference-checked, per-object-locked delete this is no longer the only guard: every
81
+ * delete path now skips an object another row set still references. It is kept as defense in
82
+ * depth for compaction, the one path that deletes content while the obligation stays live.
83
+ */
84
+ assertCompactionSafeKeyTemplates() {
85
+ const unsafe = Object.entries(this.bindings).filter(
86
+ ([, binding]) => binding.kind === "object" && !(binding.keyTemplate ?? REPORTING_ROW_DEFAULT_KEY_TEMPLATE).includes("{revision_key}")
87
+ ).map(([id]) => id);
88
+ if (unsafe.length > 0) {
89
+ throw new ReportingRowStoreError(
90
+ "INVALID_INPUT",
91
+ `snapshot compaction requires every object row binding's keyTemplate to include {revision_key}; binding(s) ${unsafe.join(", ")} omit it, so two revisions with identical content could share an object that compaction deletes while a concurrent commit adopts it`
92
+ );
93
+ }
94
+ }
73
95
  /** Probe the schema and every provider, and register (or verify) every configured binding. Idempotent. */
74
96
  ready() {
75
97
  this.readied ??= this.register().catch((error) => {
@@ -450,6 +472,40 @@ class ReportingRowStorageV1 {
450
472
  );
451
473
  }
452
474
  }
475
+ /**
476
+ * Prove each object is still present at its recorded version with the recorded bytes.
477
+ * A commit that adopted an object another row set created calls this under the object's
478
+ * lock, so a concurrent delete either finished first (this throws, the commit fails and
479
+ * the writer re-uploads) or sees the commit's chunk reference and skips the delete.
480
+ */
481
+ async assertObjectsPresent(bindingId, objects, signal) {
482
+ await this.ready();
483
+ const binding = this.bindings[bindingId];
484
+ if (!binding || binding.kind !== "object") {
485
+ throw new ReportingRowStoreError("STATE_UNAVAILABLE", `row binding ${bindingId} is not configured for objects`);
486
+ }
487
+ const provider = this.providers[binding.provider];
488
+ const context = { signal: deadlineSignal(this.writeDeadline, signal) };
489
+ for (const object of objects) {
490
+ const bytes = await this.provider(
491
+ () => provider.get(
492
+ {
493
+ ...providerLocation(binding),
494
+ key: object.objectKey,
495
+ nativeVersion: object.nativeVersion,
496
+ maxBytes: MAX_ADOPTED_OBJECT_BYTES
497
+ },
498
+ context
499
+ )
500
+ );
501
+ if (!bytes || sha256Hex(bytes) !== object.physicalSha256) {
502
+ throw new ReportingRowStoreError(
503
+ "STATE_UNAVAILABLE",
504
+ "an adopted row object was deleted or replaced before its commit; retry the upload"
505
+ );
506
+ }
507
+ }
508
+ }
453
509
  /**
454
510
  * A live row set in the same obligation and binding whose rows are
455
511
  * byte-identical (same manifest digest), with its chunk locations. Sharing
@@ -1009,6 +1009,32 @@ export interface ReportingProducerV1 {
1009
1009
  export type ReportingSourceWithReaderV1 = ReportingSourceExecutorV1 & ReportingSourceStagedObjectReaderV1;
1010
1010
  export type ReportingStatusHandlerV1 = (request: AdcpToolMap['get_reporting_status']['params'], context: HandlerContext<unknown>) => Promise<GetReportingStatusResponse>;
1011
1011
  export type ReportingDeliveryHandlerV1 = (request: AdcpToolMap['get_media_buy_delivery']['params'], context: HandlerContext<unknown>) => Promise<AdcpToolMap['get_media_buy_delivery']['result']>;
1012
+ /**
1013
+ * Exact-revision reader for revisions the SDK ledger does not own, typically a
1014
+ * seller's pre-cutover ledger tables. It receives the same request and context
1015
+ * as `get_media_buy_delivery` and returns the protocol response, or
1016
+ * `null`/`undefined` when it has no such revision.
1017
+ *
1018
+ * Authorization is the reader's responsibility. The SDK has already resolved
1019
+ * the account (`context.account`) and cannot tell whether a legacy revision
1020
+ * belongs to it, so the reader MUST scope the lookup by that account and return
1021
+ * `null` for a revision that belongs to another one. Never return a response
1022
+ * for a revision the resolved account cannot read.
1023
+ */
1024
+ export type ReportingLegacyRevisionReaderV1 = (request: AdcpToolMap['get_media_buy_delivery']['params'], context: HandlerContext<unknown>) => Promise<AdcpToolMap['get_media_buy_delivery']['result'] | null | undefined>;
1025
+ export interface CreateReportingDeliveryHandlerOptionsV1 {
1026
+ /**
1027
+ * Consulted only when the SDK ledger has no such revision for the resolved
1028
+ * account (unknown id, or an id owned by a different account). Never
1029
+ * consulted for an SDK-owned revision that is expired, has unavailable or
1030
+ * corrupt rows, or carries an unusable cursor, nor for capacity or other
1031
+ * errors: falling back there could serve different content under an id the
1032
+ * SDK owns. A `null`/`undefined` result is the same nondisclosing
1033
+ * `REFERENCE_NOT_FOUND` the SDK returns itself; an error the reader throws
1034
+ * propagates unchanged.
1035
+ */
1036
+ legacyRevisionReader?: ReportingLegacyRevisionReaderV1;
1037
+ }
1012
1038
  export interface CreateReportingProducerOptionsV1 {
1013
1039
  store: ReportingLedgerStore;
1014
1040
  source: ReportingSourceWithReaderV1;