@adcp/sdk 15.7.1 → 15.8.1

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 +316 -15
  24. package/dist/lib/reporting/ledger/postgres.mjs +316 -15
  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
@@ -444,6 +444,14 @@ class PostgresReportingLedgerStore {
444
444
  [accountId, kind, recordId, obligationId]
445
445
  );
446
446
  }
447
+ /**
448
+ * Throws `INVALID_INPUT` when any configured object row binding's key template omits
449
+ * `{revision_key}`, which makes snapshot compaction unsafe (see `compactSupersededSnapshots`).
450
+ * Called by `compactSupersededSnapshots` and by service construction when compaction is enabled.
451
+ */
452
+ assertCompactionSafeRowBindings() {
453
+ this.rowStorage.assertCompactionSafeKeyTemplates();
454
+ }
447
455
  /** Probe and register row storage when enabled. Safe to call repeatedly. */
448
456
  async readyRowStorage() {
449
457
  if (this.rowStorageWrites) await this.rowStorage.ready();
@@ -954,7 +962,14 @@ ${managedDueArm} )
954
962
  [obligationId]
955
963
  );
956
964
  const revisions = [];
957
- for (const row of result.rows) revisions.push(await this.hydrateRevision(clone(row.data), row.account_id));
965
+ for (const row of result.rows) {
966
+ try {
967
+ revisions.push(await this.hydrateRevision(clone(row.data), row.account_id));
968
+ } catch (error) {
969
+ if ((0, import_row_storage.isReportingRowStoreError)(error) && error.code === "ROWS_EXPIRED") continue;
970
+ throw error;
971
+ }
972
+ }
958
973
  return revisions;
959
974
  }
960
975
  async listRevisionMetadata(obligationId, accountId) {
@@ -1178,14 +1193,14 @@ ${managedDueArm} )
1178
1193
  const result = account !== null ? await this.query(
1179
1194
  `SELECT ${columns} FROM adcp_reporting_changes
1180
1195
  WHERE account_id = $1 AND seq > $2::bigint AND ($3::text[] IS NULL OR record_kind = ANY($3))
1181
- ORDER BY seq LIMIT $4`,
1196
+ ORDER BY adcp_reporting_changes.seq LIMIT $4`,
1182
1197
  [account, position.seq, kinds, limit]
1183
1198
  ) : await this.query(
1184
1199
  `SELECT ${columns} FROM adcp_reporting_changes
1185
1200
  WHERE (xid, seq) > ($1::text::xid8, $2::bigint)
1186
1201
  AND xid < pg_snapshot_xmin(pg_current_snapshot())
1187
1202
  AND ($3::text[] IS NULL OR record_kind = ANY($3))
1188
- ORDER BY xid, seq LIMIT $4`,
1203
+ ORDER BY adcp_reporting_changes.xid, adcp_reporting_changes.seq LIMIT $4`,
1189
1204
  [position.xid ?? "0", position.seq, kinds, limit]
1190
1205
  );
1191
1206
  const last = result.rows.at(-1);
@@ -1425,7 +1440,9 @@ ${managedDueArm} )
1425
1440
  list.push({ objectKey: row.object_key, nativeVersion: row.native_version });
1426
1441
  byBinding.set(row.row_binding_id, list);
1427
1442
  }
1428
- for (const [bindingId, list] of byBinding) await this.rowStorage.deleteObjects(bindingId, list, signal);
1443
+ for (const [bindingId, list] of byBinding) {
1444
+ await this.deleteUnreferencedObjects(bindingId, list, { obligationId }, signal);
1445
+ }
1429
1446
  }
1430
1447
  await this.transaction(
1431
1448
  async (client) => {
@@ -1461,6 +1478,228 @@ ${managedDueArm} )
1461
1478
  { preBeginAdvisoryLock: accountLock(accountId) }
1462
1479
  );
1463
1480
  }
1481
+ /**
1482
+ * Compact superseded snapshot revisions to metadata only, as adcontextprotocol/adcp#8100
1483
+ * permits (`reporting-revision.json` `content_retention`). Opt-in: nothing calls this
1484
+ * unless the host enables `compaction` in maintenance, and it must not be enabled in
1485
+ * production before the AdCP release that takes compaction into effect (the next
1486
+ * eligible minor after November 20, 2026).
1487
+ *
1488
+ * A snapshot revision is compacted only when every one of these holds:
1489
+ *
1490
+ * - it is superseded: a later revision names it in `supersedes_reporting_revision_id`
1491
+ * (so a current revision, and every official revision, is never compacted);
1492
+ * - it is not the revision the current, unsuperseded revision names;
1493
+ * - its recovery grace has passed: `max(automatedRecoveryWindowSeconds, 3600)` seconds
1494
+ * since its successor's `created_at`;
1495
+ * - while its window is open (`statusRetentionDays` since its successor's `created_at`),
1496
+ * no caller's reporting receipt (any status) names it and no caller's current consumer
1497
+ * status statement of `received`, `unreadable` or `content_mismatch` names it. The
1498
+ * check spans every caller, so content any caller is entitled to is kept;
1499
+ * - it has rows (nothing to reclaim otherwise, and an empty exact read is still correct);
1500
+ * - no live obligation lease, no pending materialization of it, and no live change-feed
1501
+ * consumer that has not yet passed its change row (the holds retirement applies,
1502
+ * narrowed to this revision).
1503
+ *
1504
+ * Only row content is dropped: the revision header, its manifest and every metadata
1505
+ * listing stay. The row set is marked `pruned`, so an exact read answers
1506
+ * `ROWS_EXPIRED`, which the handlers surface as the same nondisclosing
1507
+ * `REFERENCE_NOT_FOUND` as an unknown ID. No change-feed row is recorded: the revision's
1508
+ * metadata is unchanged, and a sink still catching up holds compaction back.
1509
+ *
1510
+ * Compaction is resumable and runs in the same three phases as retirement, under the
1511
+ * account lock where eligibility is decided:
1512
+ *
1513
+ * 1. re-check eligibility and mark the row set `pruning`, so reads answer as expired and
1514
+ * no new revision can share its chunks;
1515
+ * 2. delete external objects at their recorded versions, skipping any object another
1516
+ * row set that is not being pruned still references (chunk sharing);
1517
+ * 3. delete the chunk manifest rows and any PostgreSQL chunk body nothing else
1518
+ * references, and mark the row set `pruned`.
1519
+ *
1520
+ * A crash after phase 1 leaves a `pruning` row set that the next pass resumes.
1521
+ */
1522
+ async compactSupersededSnapshots(input) {
1523
+ this.assertCompactionSafeRowBindings();
1524
+ positiveInteger(input.statusRetentionDays, "statusRetentionDays");
1525
+ positiveInteger(input.automatedRecoveryWindowSeconds, "automatedRecoveryWindowSeconds");
1526
+ const limit = input.limit ?? 100;
1527
+ positiveInteger(limit, "limit");
1528
+ const holdDays = input.maxFeedHoldDays ?? 7;
1529
+ positiveInteger(holdDays, "maxFeedHoldDays");
1530
+ const tables = await this.optionalTables();
1531
+ if (!tables.has("adcp_reporting_row_sets")) return { compacted: 0, failed: [], failures: [] };
1532
+ const graceSeconds = Math.max(input.automatedRecoveryWindowSeconds, 3600);
1533
+ const eligible = `
1534
+ FROM adcp_reporting_revisions revision
1535
+ JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
1536
+ JOIN adcp_reporting_row_sets row_set
1537
+ ON row_set.row_set_id = revision.revision_id AND row_set.row_set_kind = 'revision'
1538
+ JOIN LATERAL (
1539
+ SELECT MAX(later.created_at) AS created_at FROM adcp_reporting_revisions later
1540
+ WHERE later.supersedes_revision_id = revision.revision_id
1541
+ ) successor ON successor.created_at IS NOT NULL
1542
+ WHERE revision.finality = 'snapshot' AND revision.kind = 'snapshot'
1543
+ AND ($4::text IS NULL OR obligation.account_id = $4)
1544
+ AND ($5::text IS NULL OR revision.revision_id = $5)
1545
+ AND $3::integer > 0
1546
+ AND row_set.row_count > 0
1547
+ AND row_set.rows_state IN ('live', 'unavailable')
1548
+ AND successor.created_at + ($2::bigint * INTERVAL '1 second') <= clock_timestamp()
1549
+ AND NOT EXISTS (
1550
+ SELECT 1 FROM adcp_reporting_revisions current_revision
1551
+ WHERE current_revision.supersedes_revision_id = revision.revision_id
1552
+ AND NOT EXISTS (
1553
+ SELECT 1 FROM adcp_reporting_revisions next_revision
1554
+ WHERE next_revision.supersedes_revision_id = current_revision.revision_id
1555
+ )
1556
+ )
1557
+ AND (obligation.lease_expires_at IS NULL OR obligation.lease_expires_at <= clock_timestamp())
1558
+ AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1559
+ WHERE tombstone.obligation_id = obligation.obligation_id)
1560
+ AND (
1561
+ successor.created_at + ($1::integer * INTERVAL '1 day') <= clock_timestamp()
1562
+ OR (
1563
+ NOT EXISTS (
1564
+ SELECT 1 FROM adcp_reporting_consumer_statuses statement
1565
+ WHERE statement.revision_id = revision.revision_id AND statement.is_current
1566
+ AND COALESCE(statement.data->>'consumer_status', statement.data->'status'->>'consumer_status')
1567
+ IN ('received', 'unreadable', 'content_mismatch')
1568
+ )
1569
+ ${tables.has("adcp_reporting_receipts") ? `AND NOT EXISTS (
1570
+ SELECT 1 FROM adcp_reporting_receipts receipt
1571
+ WHERE receipt.receipt_kind = 'revision' AND receipt.subject_id = revision.revision_id
1572
+ )` : ""}
1573
+ )
1574
+ )
1575
+ ${tables.has("adcp_reporting_materializations") ? `AND NOT EXISTS (
1576
+ SELECT 1 FROM adcp_reporting_materializations hold
1577
+ WHERE hold.revision_id = revision.revision_id AND hold.status = 'pending'
1578
+ )` : ""}
1579
+ ${tables.has("adcp_reporting_changes") && tables.has("adcp_reporting_feed_consumers") ? `AND NOT EXISTS (
1580
+ SELECT 1 FROM adcp_reporting_changes change
1581
+ JOIN adcp_reporting_feed_consumers consumer
1582
+ ON consumer.updated_at > clock_timestamp() - ($3::integer * INTERVAL '1 day')
1583
+ WHERE change.record_kind = 'revision' AND change.record_id = revision.revision_id
1584
+ AND ((consumer.account_id IS NULL
1585
+ AND (change.xid, change.seq) > (consumer.cursor_xid, consumer.cursor_seq))
1586
+ OR (consumer.account_id = change.account_id AND change.seq > consumer.cursor_seq)))` : ""}`;
1587
+ const baseValues = [input.statusRetentionDays, graceSeconds, holdDays, input.account_id ?? null];
1588
+ const candidates = await this.query(
1589
+ `SELECT revision_id, obligation_id, account_id FROM (
1590
+ SELECT row_set.row_set_id AS revision_id, row_set.obligation_id, row_set.account_id, 0 AS resume,
1591
+ row_set.recorded_at
1592
+ FROM adcp_reporting_row_sets row_set
1593
+ WHERE row_set.row_set_kind = 'revision' AND row_set.rows_state = 'pruning'
1594
+ AND ($4::text IS NULL OR row_set.account_id = $4)
1595
+ AND EXISTS (SELECT 1 FROM adcp_reporting_revisions revision
1596
+ WHERE revision.revision_id = row_set.row_set_id)
1597
+ AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1598
+ WHERE tombstone.obligation_id = row_set.obligation_id)
1599
+ UNION ALL
1600
+ SELECT revision.revision_id, revision.obligation_id, obligation.account_id, 1, row_set.recorded_at
1601
+ ${eligible}
1602
+ ) candidate
1603
+ ORDER BY resume, recorded_at, revision_id
1604
+ LIMIT $6`,
1605
+ [...baseValues, null, limit]
1606
+ );
1607
+ let compacted = 0;
1608
+ const failed = [];
1609
+ const failures = [];
1610
+ for (const candidate of candidates.rows) {
1611
+ input.signal?.throwIfAborted();
1612
+ try {
1613
+ const done = await this.compactSnapshot(
1614
+ candidate.revision_id,
1615
+ candidate.obligation_id,
1616
+ candidate.account_id,
1617
+ eligible,
1618
+ baseValues,
1619
+ input.signal
1620
+ );
1621
+ if (done) compacted += 1;
1622
+ } catch (error) {
1623
+ failed.push(candidate.revision_id);
1624
+ failures.push({
1625
+ reporting_obligation_id: candidate.obligation_id,
1626
+ reporting_revision_id: candidate.revision_id,
1627
+ cause: (0, import_row_storage.isReportingRowStoreError)(error) ? error.code : error instanceof Error ? error.name : "Error"
1628
+ });
1629
+ }
1630
+ }
1631
+ return { compacted, failed, failures };
1632
+ }
1633
+ async compactSnapshot(revisionId, obligationId, accountId, eligible, baseValues, signal) {
1634
+ const claimed = await this.transaction(
1635
+ async (client) => {
1636
+ const marked = await client.query(
1637
+ `UPDATE adcp_reporting_row_sets SET rows_state = 'pruning', rows_state_changed_at = clock_timestamp()
1638
+ WHERE row_set_id = $5 AND rows_state IN ('live', 'unavailable')
1639
+ AND row_set_id IN (SELECT revision.revision_id ${eligible})`,
1640
+ [...baseValues, revisionId]
1641
+ );
1642
+ if (marked.rowCount === 1) return true;
1643
+ const resumed = await client.query(
1644
+ `SELECT 1 FROM adcp_reporting_row_sets row_set
1645
+ WHERE row_set.row_set_id = $1 AND row_set.rows_state = 'pruning'
1646
+ AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1647
+ WHERE tombstone.obligation_id = row_set.obligation_id)`,
1648
+ [revisionId]
1649
+ );
1650
+ return resumed.rows.length === 1;
1651
+ },
1652
+ { preBeginAdvisoryLock: accountLock(accountId) }
1653
+ );
1654
+ if (!claimed) return false;
1655
+ const binding = await this.query(
1656
+ `SELECT row_binding_id FROM adcp_reporting_row_sets WHERE row_set_id = $1`,
1657
+ [revisionId]
1658
+ );
1659
+ if (binding.rows.length === 0) return false;
1660
+ const objects = await this.query(
1661
+ `SELECT DISTINCT chunk.object_key, chunk.native_version
1662
+ FROM adcp_reporting_row_chunks chunk
1663
+ WHERE chunk.row_set_id = $1 AND chunk.object_key IS NOT NULL`,
1664
+ [revisionId]
1665
+ );
1666
+ if (objects.rows.length > 0) {
1667
+ await this.deleteUnreferencedObjects(
1668
+ binding.rows[0].row_binding_id,
1669
+ objects.rows.map((row) => ({ objectKey: row.object_key, nativeVersion: row.native_version })),
1670
+ { rowSetId: revisionId },
1671
+ signal
1672
+ );
1673
+ }
1674
+ await this.transaction(
1675
+ async (client) => {
1676
+ const dropped = await client.query(
1677
+ `DELETE FROM adcp_reporting_row_chunks WHERE row_set_id = $1 RETURNING sha256, object_key`,
1678
+ [revisionId]
1679
+ );
1680
+ const bodies = [...new Set(dropped.rows.filter((row) => row.object_key === null).map((row) => row.sha256))];
1681
+ if (bodies.length > 0) {
1682
+ await client.query(
1683
+ `DELETE FROM adcp_reporting_chunk_bodies body
1684
+ WHERE body.account_id = $1 AND body.obligation_id = $2 AND body.sha256 = ANY($3::text[])
1685
+ AND NOT EXISTS (
1686
+ SELECT 1 FROM adcp_reporting_row_chunks other
1687
+ WHERE other.account_id = body.account_id AND other.obligation_id = body.obligation_id
1688
+ AND other.sha256 = body.sha256 AND other.object_key IS NULL
1689
+ )`,
1690
+ [accountId, obligationId, bodies]
1691
+ );
1692
+ }
1693
+ await client.query(
1694
+ `UPDATE adcp_reporting_row_sets SET rows_state = 'pruned', rows_state_changed_at = clock_timestamp()
1695
+ WHERE row_set_id = $1 AND rows_state = 'pruning'`,
1696
+ [revisionId]
1697
+ );
1698
+ },
1699
+ { preBeginAdvisoryLock: accountLock(accountId) }
1700
+ );
1701
+ return true;
1702
+ }
1464
1703
  /**
1465
1704
  * Resolve abandoned object uploads (spec §4.3). An expired `open` write
1466
1705
  * intent whose ledger commit never happened is moved to `sweeping` under
@@ -1502,35 +1741,85 @@ ${managedDueArm} )
1502
1741
  { preBeginAdvisoryLock: accountLock(intent.account_id) }
1503
1742
  );
1504
1743
  if (!claimed) continue;
1505
- const orphaned = [];
1506
- for (const object of claimed) {
1507
- const referenced = await this.query(
1508
- `SELECT 1 FROM adcp_reporting_row_chunks WHERE object_key = $1 AND native_version = $2 LIMIT 1`,
1509
- [object.objectKey, object.nativeVersion]
1510
- );
1511
- if (referenced.rows.length === 0) orphaned.push(object);
1512
- }
1513
- if (orphaned.length > 0) await this.rowStorage.deleteObjects(intent.row_binding_id, orphaned, input.signal);
1744
+ const deleted = await this.deleteUnreferencedObjects(intent.row_binding_id, claimed, {}, input.signal);
1514
1745
  await this.query(
1515
1746
  `DELETE FROM adcp_reporting_row_write_intents
1516
1747
  WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3 AND state = 'sweeping'`,
1517
1748
  key
1518
1749
  );
1519
1750
  swept += 1;
1520
- objectsDeleted += orphaned.length;
1751
+ objectsDeleted += deleted;
1521
1752
  } catch {
1522
1753
  failed += 1;
1523
1754
  }
1524
1755
  }
1525
1756
  return { swept, objectsDeleted, failed };
1526
1757
  }
1758
+ /**
1759
+ * Delete external row objects, skipping any object another row set still references.
1760
+ *
1761
+ * A custom `keyTemplate` need only include `{content_sha256}` and `{chunk_index}`, so row sets
1762
+ * from different obligations or accounts with identical chunk content can share one object (a
1763
+ * create-only write adopts an existing object). An object is therefore deleted only when no
1764
+ * chunk of a live (or unavailable) row set outside this operation references the same binding,
1765
+ * key and native version, and no other open write intent created it. Row sets already `pruning`
1766
+ * or `pruned` do not count: their own operation deletes the object, and counting them would let
1767
+ * two concurrent retirements each skip the object and leak it. `scope` names what this
1768
+ * operation is deleting: an obligation (retirement) or a row set (compaction).
1769
+ *
1770
+ * The check and the provider delete run in one transaction under a per-object advisory lock.
1771
+ * A commit that adopts an existing object takes the same lock before it inserts its chunk rows
1772
+ * and re-verifies the object under it (`writePreparedRowSet`), and holds it until commit. So
1773
+ * either the delete wins the lock and the adopting commit then fails cleanly, or the commit wins
1774
+ * and the delete sees its chunk rows and skips. A skipped object stays for the row set that owns
1775
+ * it: that row set's own retirement, compaction or sweep deletes it, because by then nothing
1776
+ * else references it. Locks are taken one object at a time, never while waiting on the account
1777
+ * lock, so commits (account lock, then object locks) cannot deadlock with these deletes.
1778
+ */
1779
+ async deleteUnreferencedObjects(bindingId, objects, scope, signal) {
1780
+ let deleted = 0;
1781
+ for (const object of objects) {
1782
+ signal?.throwIfAborted();
1783
+ const removed = await this.transaction(async (client) => {
1784
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
1785
+ objectLock(bindingId, object.objectKey)
1786
+ ]);
1787
+ const referenced = await client.query(
1788
+ `(SELECT 1 FROM adcp_reporting_row_chunks chunk
1789
+ JOIN adcp_reporting_row_sets row_set ON row_set.row_set_id = chunk.row_set_id
1790
+ WHERE row_set.row_binding_id = $1 AND chunk.object_key = $2 AND chunk.native_version = $3
1791
+ AND row_set.rows_state IN ('live', 'unavailable')
1792
+ AND ($4::text IS NULL OR chunk.obligation_id <> $4)
1793
+ AND ($5::text IS NULL OR chunk.row_set_id <> $5)
1794
+ LIMIT 1)
1795
+ UNION ALL
1796
+ (SELECT 1 FROM adcp_reporting_row_write_intents intent
1797
+ WHERE intent.row_binding_id = $1 AND intent.state = 'open' AND intent.created_objects @> $6::jsonb
1798
+ LIMIT 1)`,
1799
+ [
1800
+ bindingId,
1801
+ object.objectKey,
1802
+ object.nativeVersion,
1803
+ scope.obligationId ?? null,
1804
+ scope.rowSetId ?? null,
1805
+ JSON.stringify([{ objectKey: object.objectKey, nativeVersion: object.nativeVersion }])
1806
+ ]
1807
+ );
1808
+ if (referenced.rows.length > 0) return false;
1809
+ await this.rowStorage.deleteObjects(bindingId, [object], signal);
1810
+ return true;
1811
+ });
1812
+ if (removed) deleted += 1;
1813
+ }
1814
+ return deleted;
1815
+ }
1527
1816
  async optionalTables() {
1528
1817
  const result = await this.query(
1529
1818
  `SELECT table_name AS name FROM information_schema.tables
1530
1819
  WHERE table_schema = current_schema()
1531
1820
  AND table_name IN ('adcp_reporting_row_sets', 'adcp_reporting_row_write_intents',
1532
1821
  'adcp_reporting_materializations', 'adcp_reporting_changes',
1533
- 'adcp_reporting_feed_consumers')`
1822
+ 'adcp_reporting_feed_consumers', 'adcp_reporting_receipts')`
1534
1823
  );
1535
1824
  return new Set(result.rows.map((row) => row.name));
1536
1825
  }
@@ -1634,6 +1923,15 @@ ${managedDueArm} )
1634
1923
  throw new import_row_storage.ReportingRowStoreError("STATE_UNAVAILABLE", "the row upload intent was taken over or swept");
1635
1924
  }
1636
1925
  }
1926
+ if (!upload.sharedFrom) {
1927
+ const adopted = upload.locators.filter((locator) => !locator.created).sort((left, right) => left.objectKey < right.objectKey ? -1 : left.objectKey > right.objectKey ? 1 : 0);
1928
+ for (const locator of adopted) {
1929
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
1930
+ objectLock(prepared.bindingId, locator.objectKey)
1931
+ ]);
1932
+ }
1933
+ if (adopted.length > 0) await this.rowStorage.assertObjectsPresent(prepared.bindingId, adopted);
1934
+ }
1637
1935
  await this.rowStorage.writeInTransaction(client, prepared, upload.locators, upload.sharedFrom);
1638
1936
  }
1639
1937
  /** Attach verified rows to a revision document whose rows live in row storage. */
@@ -3822,6 +4120,9 @@ function rfc3339Microseconds(expression) {
3822
4120
  function accountLock(accountId) {
3823
4121
  return `adcp-reporting-account:${accountId}`;
3824
4122
  }
4123
+ function objectLock(bindingId, objectKey) {
4124
+ return `adcp-reporting-object:${bindingId}:${objectKey}`;
4125
+ }
3825
4126
  async function resolveStoredFinalityBaseline(transaction, obligationId) {
3826
4127
  const latest = await transaction.query(
3827
4128
  `SELECT transition_id, data->>'finality' AS finality