@adcp/sdk 16.0.0-beta.3 → 16.0.0-beta.5

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 (185) hide show
  1. package/dist/lib/auth/oauth/diagnose.js +3 -11
  2. package/dist/lib/auth/oauth/diagnose.mjs +4 -12
  3. package/dist/lib/auth/oauth/diagnostics.d.mts +2 -1
  4. package/dist/lib/auth/oauth/diagnostics.d.ts +2 -1
  5. package/dist/lib/auth/oauth/diagnostics.js +8 -10
  6. package/dist/lib/auth/oauth/diagnostics.mjs +7 -10
  7. package/dist/lib/index.d.mts +1 -1
  8. package/dist/lib/index.d.ts +1 -1
  9. package/dist/lib/index.js +2 -0
  10. package/dist/lib/index.mjs +7 -1
  11. package/dist/lib/reporting/consumer-runtime.js +2 -1
  12. package/dist/lib/reporting/consumer-runtime.mjs +2 -1
  13. package/dist/lib/reporting/ledger/handler.js +50 -11
  14. package/dist/lib/reporting/ledger/handler.mjs +50 -12
  15. package/dist/lib/reporting/ledger/index.d.mts +17 -14
  16. package/dist/lib/reporting/ledger/index.d.ts +17 -14
  17. package/dist/lib/reporting/ledger/index.js +32 -20
  18. package/dist/lib/reporting/ledger/index.mjs +21 -10
  19. package/dist/lib/reporting/ledger/offering-validation.d.mts +9 -0
  20. package/dist/lib/reporting/ledger/offering-validation.d.ts +9 -0
  21. package/dist/lib/reporting/{gcs/bucket-policy.js → ledger/offering-validation.js} +9 -8
  22. package/dist/lib/reporting/ledger/offering-validation.mjs +8 -0
  23. package/dist/lib/reporting/ledger/receipts.d.mts +48 -0
  24. package/dist/lib/reporting/ledger/receipts.d.ts +48 -0
  25. package/dist/lib/reporting/ledger/receipts.js +186 -0
  26. package/dist/lib/reporting/ledger/receipts.mjs +161 -0
  27. package/dist/lib/reporting/ledger/status-ingest.js +3 -3
  28. package/dist/lib/reporting/ledger/status-ingest.mjs +4 -5
  29. package/dist/lib/reporting/ledger/types.d.mts +43 -0
  30. package/dist/lib/reporting/ledger/types.d.ts +43 -0
  31. package/dist/lib/reporting/ledger/types.js +31 -2
  32. package/dist/lib/reporting/ledger/types.mjs +26 -1
  33. package/dist/lib/reporting/pagination.d.mts +6 -0
  34. package/dist/lib/reporting/pagination.d.ts +6 -0
  35. package/dist/lib/reporting/{gcs/errors.js → pagination.js} +12 -17
  36. package/dist/lib/reporting/pagination.mjs +12 -0
  37. package/dist/lib/reporting/reconciliation.d.mts +1 -0
  38. package/dist/lib/reporting/reconciliation.d.ts +1 -0
  39. package/dist/lib/reporting/reconciliation.js +3 -2
  40. package/dist/lib/reporting/reconciliation.mjs +3 -2
  41. package/dist/lib/reporting/service/index.d.mts +79 -153
  42. package/dist/lib/reporting/service/index.d.ts +79 -153
  43. package/dist/lib/reporting/service/index.js +34 -491
  44. package/dist/lib/reporting/service/index.mjs +36 -510
  45. package/dist/lib/reporting/service/scheduler-support.d.mts +43 -0
  46. package/dist/lib/reporting/service/scheduler-support.d.ts +43 -0
  47. package/dist/lib/reporting/service/scheduler-support.js +125 -0
  48. package/dist/lib/reporting/service/scheduler-support.mjs +96 -0
  49. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  50. package/dist/lib/signing/canonicalize.d.mts +4 -0
  51. package/dist/lib/signing/canonicalize.d.ts +4 -0
  52. package/dist/lib/signing/canonicalize.js +14 -8
  53. package/dist/lib/signing/canonicalize.mjs +14 -8
  54. package/dist/lib/signing/content-digest.d.mts +8 -0
  55. package/dist/lib/signing/content-digest.d.ts +8 -0
  56. package/dist/lib/signing/content-digest.js +11 -5
  57. package/dist/lib/signing/content-digest.mjs +10 -5
  58. package/dist/lib/signing/signer.js +9 -6
  59. package/dist/lib/signing/signer.mjs +10 -7
  60. package/dist/lib/signing/web-bot-auth.js +103 -37
  61. package/dist/lib/signing/web-bot-auth.mjs +108 -37
  62. package/dist/lib/testing/storyboard/junit.js +16 -3
  63. package/dist/lib/testing/storyboard/junit.mjs +16 -3
  64. package/dist/lib/testing/storyboard/request-signing/builder.js +10 -9
  65. package/dist/lib/testing/storyboard/request-signing/builder.mjs +10 -9
  66. package/dist/lib/testing/storyboard/runner.js +94 -25
  67. package/dist/lib/testing/storyboard/runner.mjs +94 -25
  68. package/dist/lib/testing/storyboard/types.d.mts +6 -0
  69. package/dist/lib/testing/storyboard/types.d.ts +6 -0
  70. package/dist/lib/testing/storyboard/validations.d.mts +1 -1
  71. package/dist/lib/testing/storyboard/validations.d.ts +1 -1
  72. package/dist/lib/types/schemas.generated.d.ts +907 -60
  73. package/dist/lib/types/schemas.generated.js +7 -7
  74. package/dist/lib/types/schemas.generated.mjs +7 -7
  75. package/dist/lib/version.d.mts +3 -3
  76. package/dist/lib/version.d.ts +3 -3
  77. package/dist/lib/version.js +3 -3
  78. package/dist/lib/version.mjs +3 -3
  79. package/docs/TYPE-SUMMARY.md +23 -5
  80. package/docs/guides/REPORTING-GCS-FENCE.md +3 -3
  81. package/docs/guides/REPORTING-GCS-MANAGED.md +4 -4
  82. package/docs/guides/REPORTING-LEDGER-CUTOVER.md +7 -7
  83. package/docs/guides/REPORTING-LEDGER.md +107 -51
  84. package/docs/guides/REPORTING-OPERATIONS.md +66 -5
  85. package/docs/guides/REPORTING-STABILITY.md +36 -32
  86. package/docs/llms.txt +4 -3
  87. package/docs/migration-14.x-rc-worksheet.md +4 -7
  88. package/docs/migration-reporting-storage-package.md +186 -0
  89. package/examples/README.md +1 -1
  90. package/package.json +13 -78
  91. package/dist/lib/reporting/azure/index.d.mts +0 -31
  92. package/dist/lib/reporting/azure/index.d.ts +0 -31
  93. package/dist/lib/reporting/azure/index.js +0 -266
  94. package/dist/lib/reporting/azure/index.mjs +0 -249
  95. package/dist/lib/reporting/bigquery/index.d.mts +0 -246
  96. package/dist/lib/reporting/bigquery/index.d.ts +0 -246
  97. package/dist/lib/reporting/bigquery/index.js +0 -633
  98. package/dist/lib/reporting/bigquery/index.mjs +0 -594
  99. package/dist/lib/reporting/gcs/adapter.d.mts +0 -38
  100. package/dist/lib/reporting/gcs/adapter.d.ts +0 -38
  101. package/dist/lib/reporting/gcs/adapter.js +0 -342
  102. package/dist/lib/reporting/gcs/adapter.mjs +0 -325
  103. package/dist/lib/reporting/gcs/bucket-policy.d.mts +0 -3
  104. package/dist/lib/reporting/gcs/bucket-policy.d.ts +0 -3
  105. package/dist/lib/reporting/gcs/bucket-policy.mjs +0 -7
  106. package/dist/lib/reporting/gcs/errors.d.mts +0 -11
  107. package/dist/lib/reporting/gcs/errors.d.ts +0 -11
  108. package/dist/lib/reporting/gcs/errors.mjs +0 -16
  109. package/dist/lib/reporting/gcs/fence.d.mts +0 -41
  110. package/dist/lib/reporting/gcs/fence.d.ts +0 -41
  111. package/dist/lib/reporting/gcs/fence.js +0 -250
  112. package/dist/lib/reporting/gcs/fence.mjs +0 -230
  113. package/dist/lib/reporting/gcs/index.d.mts +0 -18
  114. package/dist/lib/reporting/gcs/index.d.ts +0 -18
  115. package/dist/lib/reporting/gcs/index.js +0 -44
  116. package/dist/lib/reporting/gcs/index.mjs +0 -14
  117. package/dist/lib/reporting/gcs/io.d.mts +0 -7
  118. package/dist/lib/reporting/gcs/io.d.ts +0 -7
  119. package/dist/lib/reporting/gcs/io.js +0 -95
  120. package/dist/lib/reporting/gcs/io.mjs +0 -69
  121. package/dist/lib/reporting/gcs/reader.d.mts +0 -67
  122. package/dist/lib/reporting/gcs/reader.d.ts +0 -67
  123. package/dist/lib/reporting/gcs/reader.js +0 -193
  124. package/dist/lib/reporting/gcs/reader.mjs +0 -169
  125. package/dist/lib/reporting/gcs/row-provider.d.mts +0 -39
  126. package/dist/lib/reporting/gcs/row-provider.d.ts +0 -39
  127. package/dist/lib/reporting/gcs/row-provider.js +0 -225
  128. package/dist/lib/reporting/gcs/row-provider.mjs +0 -208
  129. package/dist/lib/reporting/gcs/validation.d.mts +0 -5
  130. package/dist/lib/reporting/gcs/validation.d.ts +0 -5
  131. package/dist/lib/reporting/gcs/validation.js +0 -49
  132. package/dist/lib/reporting/gcs/validation.mjs +0 -25
  133. package/dist/lib/reporting/ledger/change-feed.d.mts +0 -69
  134. package/dist/lib/reporting/ledger/change-feed.d.ts +0 -69
  135. package/dist/lib/reporting/ledger/change-feed.js +0 -107
  136. package/dist/lib/reporting/ledger/change-feed.mjs +0 -80
  137. package/dist/lib/reporting/ledger/managed-postgres.d.mts +0 -293
  138. package/dist/lib/reporting/ledger/managed-postgres.d.ts +0 -293
  139. package/dist/lib/reporting/ledger/managed-postgres.js +0 -2346
  140. package/dist/lib/reporting/ledger/managed-postgres.mjs +0 -2332
  141. package/dist/lib/reporting/ledger/managed-router.d.mts +0 -148
  142. package/dist/lib/reporting/ledger/managed-router.d.ts +0 -148
  143. package/dist/lib/reporting/ledger/managed-router.js +0 -280
  144. package/dist/lib/reporting/ledger/managed-router.mjs +0 -258
  145. package/dist/lib/reporting/ledger/managed.d.mts +0 -535
  146. package/dist/lib/reporting/ledger/managed.d.ts +0 -535
  147. package/dist/lib/reporting/ledger/managed.js +0 -761
  148. package/dist/lib/reporting/ledger/managed.mjs +0 -729
  149. package/dist/lib/reporting/ledger/notification-activity.d.mts +0 -332
  150. package/dist/lib/reporting/ledger/notification-activity.d.ts +0 -332
  151. package/dist/lib/reporting/ledger/notification-activity.js +0 -1366
  152. package/dist/lib/reporting/ledger/notification-activity.mjs +0 -1337
  153. package/dist/lib/reporting/ledger/object-writes.d.mts +0 -128
  154. package/dist/lib/reporting/ledger/object-writes.d.ts +0 -128
  155. package/dist/lib/reporting/ledger/object-writes.js +0 -147
  156. package/dist/lib/reporting/ledger/object-writes.mjs +0 -114
  157. package/dist/lib/reporting/ledger/postgres.d.mts +0 -689
  158. package/dist/lib/reporting/ledger/postgres.d.ts +0 -689
  159. package/dist/lib/reporting/ledger/postgres.js +0 -4462
  160. package/dist/lib/reporting/ledger/postgres.mjs +0 -4462
  161. package/dist/lib/reporting/ledger/row-storage-migration.d.mts +0 -59
  162. package/dist/lib/reporting/ledger/row-storage-migration.d.ts +0 -59
  163. package/dist/lib/reporting/ledger/row-storage-migration.js +0 -365
  164. package/dist/lib/reporting/ledger/row-storage-migration.mjs +0 -339
  165. package/dist/lib/reporting/ledger/row-storage-object.d.mts +0 -203
  166. package/dist/lib/reporting/ledger/row-storage-object.d.ts +0 -203
  167. package/dist/lib/reporting/ledger/row-storage-object.js +0 -379
  168. package/dist/lib/reporting/ledger/row-storage-object.mjs +0 -335
  169. package/dist/lib/reporting/ledger/row-storage-provider-io.d.mts +0 -44
  170. package/dist/lib/reporting/ledger/row-storage-provider-io.d.ts +0 -44
  171. package/dist/lib/reporting/ledger/row-storage-provider-io.js +0 -142
  172. package/dist/lib/reporting/ledger/row-storage-provider-io.mjs +0 -111
  173. package/dist/lib/reporting/ledger/row-storage.d.mts +0 -316
  174. package/dist/lib/reporting/ledger/row-storage.d.ts +0 -316
  175. package/dist/lib/reporting/ledger/row-storage.js +0 -812
  176. package/dist/lib/reporting/ledger/row-storage.mjs +0 -802
  177. package/dist/lib/reporting/s3/index.d.mts +0 -34
  178. package/dist/lib/reporting/s3/index.d.ts +0 -34
  179. package/dist/lib/reporting/s3/index.js +0 -347
  180. package/dist/lib/reporting/s3/index.mjs +0 -340
  181. package/dist/lib/reporting/service/maintenance.d.mts +0 -148
  182. package/dist/lib/reporting/service/maintenance.d.ts +0 -148
  183. package/dist/lib/reporting/service/maintenance.js +0 -185
  184. package/dist/lib/reporting/service/maintenance.mjs +0 -159
  185. package/examples/reliable-reporting-service/README.md +0 -169
@@ -1,4462 +0,0 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { canonicalize } from "../../utils/jcs.mjs";
3
- import { DEFAULT_UNKNOWN_ERROR_RECOVERY, getErrorRecovery } from "../../types/error-codes.mjs";
4
- import { canonicalJsonV1 } from "../source/index.mjs";
5
- import { isWellFormedUnicodeString } from "../../utils/well-formed-unicode.mjs";
6
- import {
7
- evaluateReportingLedgerCoverageV1,
8
- reportingLedgerEffectivePeriod,
9
- reportingLedgerScopeClosed
10
- } from "./coverage.mjs";
11
- import {
12
- assertReportingConsumerMismatchEscalation,
13
- projectReportingConsumerStatusMismatchV1,
14
- projectReportingObligationHealthV1,
15
- projectReportingUnreadableRevisionHealthV1,
16
- ReportingLedgerIssueIntegrityError
17
- } from "./health.mjs";
18
- import { compareReportingInstants } from "./instant.mjs";
19
- import {
20
- REPORTING_CONSUMER_STATUS_BATCH_RESULT_MAX_BYTES,
21
- REPORTING_CONSUMER_STATUS_ERROR_FIELD_MAX_BYTES,
22
- REPORTING_CONSUMER_STATUS_ERROR_MESSAGE_MAX_BYTES,
23
- REPORTING_CONSUMER_STATUS_MAX_BYTES,
24
- REPORTING_LEDGER_AUTHORITY,
25
- ReportingLedgerSnapshotUnavailableError
26
- } from "./types.mjs";
27
- import { ReportingConsumerStatusConflictError } from "./types.mjs";
28
- import { moreSevereReportingHealthV1, projectManagedDelivery } from "./handler.mjs";
29
- import { reportingCanonicalAdjustmentSha256V1 } from "./producer.mjs";
30
- import {
31
- ReportingChangeCursorExpiredError,
32
- decodeReportingChangeCursorV1,
33
- encodeReportingChangeCursorV1
34
- } from "./change-feed.mjs";
35
- import {
36
- ReportingRowStorageV1,
37
- ReportingRowStoreError,
38
- isReportingRowStoreError,
39
- reportingRowWriteIntentIdV1
40
- } from "./row-storage.mjs";
41
- import { isFrozenCalendarRulesMismatch, reportingPeriodSchedule } from "./schedule.mjs";
42
- import {
43
- normalizeReportingConsumerStatusIdsV1,
44
- reportingConsumerStatusChainKeyFromIdentityV1,
45
- reportingConsumerStatusChainKeyV1,
46
- reportingConsumerStatusFingerprintV1
47
- } from "./consumer-status-identity.mjs";
48
- const REPORTING_LEDGER_MIGRATION = `
49
- CREATE TABLE IF NOT EXISTS adcp_reporting_configurations (
50
- configuration_id TEXT PRIMARY KEY,
51
- account_id TEXT NOT NULL,
52
- delivery_config_id TEXT NOT NULL,
53
- delivery_config_version INTEGER NOT NULL,
54
- semantic_fingerprint TEXT NOT NULL,
55
- data JSONB NOT NULL,
56
- created_at TIMESTAMPTZ NOT NULL,
57
- UNIQUE (account_id, delivery_config_id, delivery_config_version)
58
- );
59
-
60
- CREATE TABLE IF NOT EXISTS adcp_reporting_obligations (
61
- obligation_id TEXT PRIMARY KEY,
62
- configuration_id TEXT NOT NULL REFERENCES adcp_reporting_configurations(configuration_id),
63
- account_id TEXT NOT NULL,
64
- period_ordinal BIGINT NOT NULL,
65
- period_start TIMESTAMPTZ NOT NULL,
66
- period_end TIMESTAMPTZ NOT NULL,
67
- next_attempt_at TIMESTAMPTZ NOT NULL,
68
- state TEXT NOT NULL,
69
- semantic_fingerprint TEXT NOT NULL,
70
- data JSONB NOT NULL,
71
- created_at TIMESTAMPTZ NOT NULL,
72
- changed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
73
- lease_owner TEXT,
74
- lease_generation BIGINT NOT NULL DEFAULT 0,
75
- lease_expires_at TIMESTAMPTZ,
76
- UNIQUE (configuration_id, period_ordinal),
77
- CHECK (period_start < period_end),
78
- CHECK (state IN ('pending', 'terminal'))
79
- );
80
-
81
- ALTER TABLE adcp_reporting_obligations ADD COLUMN IF NOT EXISTS period_ordinal BIGINT;
82
- UPDATE adcp_reporting_obligations
83
- SET period_ordinal = (data->>'periodOrdinal')::bigint
84
- WHERE period_ordinal IS NULL AND data->>'periodOrdinal' ~ '^-?[0-9]+$';
85
- ALTER TABLE adcp_reporting_obligations ALTER COLUMN period_ordinal SET NOT NULL;
86
- CREATE UNIQUE INDEX IF NOT EXISTS adcp_reporting_obligations_configuration_ordinal
87
- ON adcp_reporting_obligations (configuration_id, period_ordinal);
88
-
89
- CREATE INDEX IF NOT EXISTS adcp_reporting_obligations_due
90
- ON adcp_reporting_obligations (next_attempt_at, obligation_id)
91
- WHERE state = 'pending';
92
- CREATE INDEX IF NOT EXISTS adcp_reporting_obligations_account_due
93
- ON adcp_reporting_obligations (account_id, next_attempt_at, obligation_id)
94
- WHERE state = 'pending';
95
- CREATE INDEX IF NOT EXISTS adcp_reporting_obligations_account_period
96
- ON adcp_reporting_obligations (account_id, period_start, obligation_id);
97
- CREATE INDEX IF NOT EXISTS adcp_reporting_obligations_changed
98
- ON adcp_reporting_obligations (account_id, changed_at);
99
-
100
- CREATE TABLE IF NOT EXISTS adcp_reporting_revisions (
101
- revision_id TEXT PRIMARY KEY,
102
- obligation_id TEXT NOT NULL REFERENCES adcp_reporting_obligations(obligation_id),
103
- revision_number INTEGER NOT NULL,
104
- finality TEXT NOT NULL,
105
- kind TEXT NOT NULL,
106
- supersedes_revision_id TEXT REFERENCES adcp_reporting_revisions(revision_id),
107
- content_sha256 TEXT NOT NULL,
108
- data JSONB NOT NULL,
109
- created_at TIMESTAMPTZ NOT NULL,
110
- recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
111
- UNIQUE (obligation_id, revision_number),
112
- CHECK (finality IN ('snapshot', 'official')),
113
- CHECK (kind IN ('snapshot', 'official'))
114
- );
115
- CREATE INDEX IF NOT EXISTS adcp_reporting_revisions_obligation
116
- ON adcp_reporting_revisions (obligation_id, revision_number);
117
- CREATE INDEX IF NOT EXISTS adcp_reporting_revisions_created
118
- ON adcp_reporting_revisions (obligation_id, recorded_at);
119
-
120
- CREATE TABLE IF NOT EXISTS adcp_reporting_adjustments (
121
- adjustment_id TEXT PRIMARY KEY,
122
- obligation_id TEXT NOT NULL REFERENCES adcp_reporting_obligations(obligation_id),
123
- adjusts_revision_id TEXT NOT NULL REFERENCES adcp_reporting_revisions(revision_id),
124
- adjustment_number INTEGER NOT NULL,
125
- content_sha256 TEXT NOT NULL,
126
- data JSONB NOT NULL,
127
- created_at TIMESTAMPTZ NOT NULL,
128
- recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
129
- UNIQUE (obligation_id, adjustment_number)
130
- );
131
- CREATE INDEX IF NOT EXISTS adcp_reporting_adjustments_obligation
132
- ON adcp_reporting_adjustments (obligation_id, adjustment_number);
133
- CREATE INDEX IF NOT EXISTS adcp_reporting_adjustments_created
134
- ON adcp_reporting_adjustments (obligation_id, recorded_at);
135
-
136
- CREATE TABLE IF NOT EXISTS adcp_reporting_consumer_statuses (
137
- account_id TEXT NOT NULL,
138
- consumer_id TEXT NOT NULL,
139
- consumer_status_id TEXT NOT NULL,
140
- chain_key TEXT NOT NULL,
141
- revision_id TEXT REFERENCES adcp_reporting_revisions(revision_id),
142
- obligation_id TEXT REFERENCES adcp_reporting_obligations(obligation_id),
143
- supersedes_consumer_status_id TEXT,
144
- is_current BOOLEAN NOT NULL DEFAULT true,
145
- semantic_fingerprint TEXT NOT NULL,
146
- data JSONB NOT NULL,
147
- created_at TIMESTAMPTZ NOT NULL,
148
- recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
149
- PRIMARY KEY (account_id, consumer_id, consumer_status_id)
150
- );
151
- ALTER TABLE adcp_reporting_consumer_statuses ALTER COLUMN revision_id DROP NOT NULL;
152
- ALTER TABLE adcp_reporting_consumer_statuses ALTER COLUMN obligation_id DROP NOT NULL;
153
- ALTER TABLE adcp_reporting_consumer_statuses ADD COLUMN IF NOT EXISTS account_id TEXT;
154
- ALTER TABLE adcp_reporting_consumer_statuses ADD COLUMN IF NOT EXISTS consumer_id TEXT;
155
- ALTER TABLE adcp_reporting_consumer_statuses ADD COLUMN IF NOT EXISTS chain_key TEXT;
156
- ALTER TABLE adcp_reporting_consumer_statuses ADD COLUMN IF NOT EXISTS is_current BOOLEAN NOT NULL DEFAULT true;
157
- ALTER TABLE adcp_reporting_consumer_statuses ADD COLUMN IF NOT EXISTS semantic_fingerprint TEXT;
158
- -- Preserve predecessor API rows under a reserved compatibility principal;
159
- -- putConsumerStatus/listConsumerStatuses continue to serve this namespace.
160
- UPDATE adcp_reporting_consumer_statuses AS status
161
- SET account_id = COALESCE(
162
- status.account_id,
163
- obligation.data->'account'->>'account_id',
164
- '__legacy_unscoped_account__'
165
- ),
166
- consumer_id = COALESCE(status.consumer_id, '__legacy_unscoped_consumer__'),
167
- chain_key = COALESCE(status.chain_key, 'legacy:' || status.consumer_status_id),
168
- semantic_fingerprint = COALESCE(status.semantic_fingerprint, 'legacy:' || status.consumer_status_id)
169
- FROM adcp_reporting_obligations AS obligation
170
- WHERE status.obligation_id = obligation.obligation_id
171
- AND (status.account_id IS NULL OR status.consumer_id IS NULL OR status.chain_key IS NULL
172
- OR status.semantic_fingerprint IS NULL);
173
- ALTER TABLE adcp_reporting_consumer_statuses ALTER COLUMN account_id SET NOT NULL;
174
- ALTER TABLE adcp_reporting_consumer_statuses ALTER COLUMN consumer_id SET NOT NULL;
175
- ALTER TABLE adcp_reporting_consumer_statuses ALTER COLUMN chain_key SET NOT NULL;
176
- ALTER TABLE adcp_reporting_consumer_statuses ALTER COLUMN semantic_fingerprint SET NOT NULL;
177
- DO $migration$
178
- DECLARE self_fk RECORD;
179
- BEGIN
180
- FOR self_fk IN
181
- SELECT conname
182
- FROM pg_constraint
183
- WHERE conrelid = 'adcp_reporting_consumer_statuses'::regclass
184
- AND confrelid = 'adcp_reporting_consumer_statuses'::regclass
185
- AND contype = 'f'
186
- LOOP
187
- EXECUTE format(
188
- 'ALTER TABLE adcp_reporting_consumer_statuses DROP CONSTRAINT %I',
189
- self_fk.conname
190
- );
191
- END LOOP;
192
- END
193
- $migration$;
194
- ALTER TABLE adcp_reporting_consumer_statuses DROP CONSTRAINT IF EXISTS adcp_reporting_consumer_statuses_pkey;
195
- ALTER TABLE adcp_reporting_consumer_statuses
196
- ADD CONSTRAINT adcp_reporting_consumer_statuses_pkey PRIMARY KEY (account_id, consumer_id, consumer_status_id);
197
- CREATE INDEX IF NOT EXISTS adcp_reporting_consumer_statuses_revision
198
- ON adcp_reporting_consumer_statuses (revision_id, created_at, consumer_status_id);
199
- CREATE INDEX IF NOT EXISTS adcp_reporting_consumer_statuses_scope
200
- ON adcp_reporting_consumer_statuses (account_id, consumer_id, created_at, consumer_status_id);
201
- CREATE UNIQUE INDEX IF NOT EXISTS adcp_reporting_consumer_statuses_current
202
- ON adcp_reporting_consumer_statuses (account_id, consumer_id, chain_key) WHERE is_current;
203
- -- The managed state digest is read inside the apply transaction, under the
204
- -- account lock. Filtering this table by obligation alone matched no index, so
205
- -- every apply scanned every consumer status in the deployment while holding
206
- -- that lock and pushed concurrent Core writes past their lock timeout.
207
- CREATE INDEX IF NOT EXISTS adcp_reporting_consumer_statuses_obligation
208
- ON adcp_reporting_consumer_statuses (obligation_id, created_at, consumer_status_id);
209
-
210
- CREATE TABLE IF NOT EXISTS adcp_reporting_consumer_status_batches (
211
- account_id TEXT NOT NULL,
212
- consumer_id TEXT NOT NULL,
213
- idempotency_key TEXT NOT NULL,
214
- request_fingerprint TEXT NOT NULL,
215
- status_ids JSONB NOT NULL,
216
- results JSONB NOT NULL DEFAULT '[]'::jsonb,
217
- recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
218
- PRIMARY KEY (account_id, consumer_id, idempotency_key)
219
- );
220
- ALTER TABLE adcp_reporting_consumer_status_batches ADD COLUMN IF NOT EXISTS results JSONB NOT NULL DEFAULT '[]'::jsonb;
221
-
222
- CREATE TABLE IF NOT EXISTS adcp_reporting_issues (
223
- issue_id TEXT PRIMARY KEY,
224
- obligation_id TEXT NOT NULL REFERENCES adcp_reporting_obligations(obligation_id),
225
- data JSONB NOT NULL,
226
- observed_at TIMESTAMPTZ NOT NULL,
227
- resolved_at TIMESTAMPTZ,
228
- changed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
229
- );
230
- CREATE INDEX IF NOT EXISTS adcp_reporting_issues_obligation
231
- ON adcp_reporting_issues (obligation_id, observed_at, issue_id);
232
-
233
- -- Per-obligation reconciliation watermark. A reconcile that changes nothing
234
- -- still has to record that it looked, or a change with no health effect stays
235
- -- a candidate forever and starves newer work behind it. Core, not managed:
236
- -- the lifecycle sweep is a Core concern and must not depend on the add-on.
237
- CREATE TABLE IF NOT EXISTS adcp_reporting_lifecycle_state (
238
- obligation_id TEXT PRIMARY KEY REFERENCES adcp_reporting_obligations(obligation_id),
239
- processed_state_version TEXT,
240
- processed_roster_version TEXT,
241
- processed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
242
- );
243
- CREATE INDEX IF NOT EXISTS adcp_reporting_lifecycle_state_processed
244
- ON adcp_reporting_lifecycle_state (processed_at, obligation_id);
245
- ALTER TABLE adcp_reporting_lifecycle_state
246
- ADD COLUMN IF NOT EXISTS current_roster_version TEXT;
247
- ALTER TABLE adcp_reporting_lifecycle_state
248
- ADD COLUMN IF NOT EXISTS failure_count INTEGER NOT NULL DEFAULT 0;
249
- ALTER TABLE adcp_reporting_lifecycle_state
250
- ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMPTZ;
251
- ALTER TABLE adcp_reporting_lifecycle_state
252
- ADD COLUMN IF NOT EXISTS roster_refreshed_at TIMESTAMPTZ;
253
- CREATE INDEX IF NOT EXISTS adcp_reporting_lifecycle_state_roster_refreshed
254
- ON adcp_reporting_lifecycle_state (roster_refreshed_at, obligation_id);
255
-
256
- CREATE TABLE IF NOT EXISTS adcp_reporting_transitions (
257
- transition_id TEXT PRIMARY KEY,
258
- transition_sequence BIGSERIAL NOT NULL UNIQUE,
259
- obligation_id TEXT NOT NULL REFERENCES adcp_reporting_obligations(obligation_id),
260
- data JSONB NOT NULL,
261
- occurred_at TIMESTAMPTZ NOT NULL,
262
- recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
263
- );
264
- CREATE INDEX IF NOT EXISTS adcp_reporting_transitions_obligation
265
- ON adcp_reporting_transitions (obligation_id, occurred_at, transition_id);
266
- CREATE INDEX IF NOT EXISTS adcp_reporting_transitions_pending
267
- ON adcp_reporting_transitions (recorded_at, transition_id)
268
- WHERE NOT (data ? 'notifiedAt');
269
-
270
- CREATE TABLE IF NOT EXISTS adcp_reporting_snapshots (
271
- snapshot_id UUID PRIMARY KEY,
272
- account_id TEXT NOT NULL,
273
- query_fingerprint TEXT NOT NULL,
274
- data JSONB NOT NULL,
275
- byte_count BIGINT NOT NULL,
276
- created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
277
- expires_at TIMESTAMPTZ NOT NULL
278
- );
279
- CREATE INDEX IF NOT EXISTS adcp_reporting_snapshots_created
280
- ON adcp_reporting_snapshots (created_at);
281
- CREATE INDEX IF NOT EXISTS adcp_reporting_snapshots_expiry
282
- ON adcp_reporting_snapshots (expires_at);
283
-
284
- CREATE TABLE IF NOT EXISTS adcp_reporting_checkpoints (
285
- checkpoint_id UUID PRIMARY KEY,
286
- account_id TEXT NOT NULL,
287
- scope_fingerprint TEXT NOT NULL,
288
- ledger_as_of TIMESTAMPTZ NOT NULL,
289
- created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
290
- expires_at TIMESTAMPTZ NOT NULL
291
- );
292
- CREATE INDEX IF NOT EXISTS adcp_reporting_checkpoints_expiry
293
- ON adcp_reporting_checkpoints (expires_at);
294
-
295
- -- A retired period keeps one compact record: it refuses replays that would
296
- -- resurrect the period and preserves the audit conclusion after the period's
297
- -- ledger records are deleted.
298
- CREATE TABLE IF NOT EXISTS adcp_reporting_obligation_tombstones (
299
- obligation_id TEXT PRIMARY KEY,
300
- account_id TEXT NOT NULL,
301
- configuration_id TEXT NOT NULL,
302
- period_ordinal BIGINT NOT NULL,
303
- period_start TIMESTAMPTZ NOT NULL,
304
- period_end TIMESTAMPTZ NOT NULL,
305
- state TEXT NOT NULL CHECK (state IN ('retiring', 'retired')),
306
- final_revision_id TEXT,
307
- final_content_sha256 TEXT,
308
- revision_count INTEGER NOT NULL CHECK (revision_count >= 0),
309
- adjustment_count INTEGER NOT NULL CHECK (adjustment_count >= 0),
310
- retiring_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
311
- retired_at TIMESTAMPTZ,
312
- UNIQUE (configuration_id, period_ordinal),
313
- CHECK ((state = 'retired') = (retired_at IS NOT NULL))
314
- );
315
- CREATE INDEX IF NOT EXISTS adcp_reporting_obligation_tombstones_account
316
- ON adcp_reporting_obligation_tombstones (account_id, configuration_id, period_ordinal);
317
- -- Retirement retry state. A period whose external deletes fail on every pass
318
- -- backs off instead of holding a slot at the head of every pass. Guarded on the
319
- -- catalog because ADD COLUMN IF NOT EXISTS takes ACCESS EXCLUSIVE even when the
320
- -- column exists, which would block a rerun behind ordinary traffic.
321
- DO $tombstone_retry$
322
- DECLARE
323
- tombstones regclass := to_regclass('adcp_reporting_obligation_tombstones');
324
- BEGIN
325
- IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = tombstones AND attname = 'attempts' AND NOT attisdropped) THEN
326
- ALTER TABLE adcp_reporting_obligation_tombstones ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0;
327
- END IF;
328
- IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = tombstones AND attname = 'next_attempt_at' AND NOT attisdropped) THEN
329
- ALTER TABLE adcp_reporting_obligation_tombstones ADD COLUMN next_attempt_at TIMESTAMPTZ;
330
- END IF;
331
- IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = tombstones AND attname = 'last_failure' AND NOT attisdropped) THEN
332
- ALTER TABLE adcp_reporting_obligation_tombstones ADD COLUMN last_failure TEXT;
333
- END IF;
334
- END
335
- $tombstone_retry$;
336
- `.trim();
337
- const REPORTING_LEDGER_FINALITY_FENCE_CONSTRAINT = "adcp_reporting_transitions_finality_recorded";
338
- const REPORTING_LEDGER_FINALITY_WRITER_FENCE_MIGRATION = `
339
- DO $$
340
- BEGIN
341
- IF NOT EXISTS (
342
- SELECT 1 FROM pg_constraint
343
- WHERE conrelid = 'adcp_reporting_transitions'::regclass
344
- AND conname = '${REPORTING_LEDGER_FINALITY_FENCE_CONSTRAINT}'
345
- ) THEN
346
- ALTER TABLE adcp_reporting_transitions
347
- ADD CONSTRAINT ${REPORTING_LEDGER_FINALITY_FENCE_CONSTRAINT}
348
- CHECK (data ? 'finality') NOT VALID;
349
- END IF;
350
- END $$;
351
- `.trim();
352
- const MAX_SNAPSHOT_ITEMS = 1e4;
353
- const MAX_LIFECYCLE_RECEIPT_LEAVES = 1e5;
354
- const MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024;
355
- const MAX_ACTIVE_SNAPSHOTS_PER_ACCOUNT = 32;
356
- const MAX_ACTIVE_SNAPSHOT_BYTES_PER_ACCOUNT = 128 * 1024 * 1024;
357
- const MAX_CONSUMER_STATUS_BATCHES = 1e4;
358
- const MAX_CONSUMER_STATUS_STATEMENTS = 1e5;
359
- const SNAPSHOT_RETENTION_MS = 15 * 60 * 1e3;
360
- const CHECKPOINT_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
361
- const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
362
- class ReportingLedgerLeaseLostError extends Error {
363
- constructor(message = "Reporting ledger lease was lost before commit") {
364
- super(message);
365
- this.name = "ReportingLedgerLeaseLostError";
366
- }
367
- }
368
- class ReportingLedgerPeriodRetiredError extends Error {
369
- constructor(message = "Reporting obligation period was retired by retention") {
370
- super(message);
371
- this.name = "ReportingLedgerPeriodRetiredError";
372
- }
373
- }
374
- class ReportingLedgerContinuityError extends Error {
375
- constructor(message = "Reporting ledger revision continuity invariant failed") {
376
- super(message);
377
- this.name = "ReportingLedgerContinuityError";
378
- }
379
- }
380
- const REPORTING_PERSISTENT_FAILURE_ATTEMPTS = 5;
381
- function backoffIntervalSql(attemptsExpr) {
382
- return `(LEAST(21600, 60 * power(2, LEAST(${attemptsExpr} - 1, 12))) * INTERVAL '1 second')`;
383
- }
384
- async function sweepExpiredReportingLedgerState(pool, limit = 1e3) {
385
- positiveInteger(limit, "limit");
386
- if (limit > 1e4) throw new RangeError("limit must not exceed 10000");
387
- try {
388
- const snapshots = await pool.query(
389
- `WITH expired AS (
390
- SELECT snapshot_id FROM adcp_reporting_snapshots
391
- WHERE expires_at <= clock_timestamp()
392
- ORDER BY expires_at, snapshot_id
393
- FOR UPDATE SKIP LOCKED LIMIT $1
394
- )
395
- DELETE FROM adcp_reporting_snapshots target
396
- USING expired WHERE target.snapshot_id = expired.snapshot_id`,
397
- [limit]
398
- );
399
- const checkpoints = await pool.query(
400
- `WITH expired AS (
401
- SELECT checkpoint_id FROM adcp_reporting_checkpoints
402
- WHERE expires_at <= clock_timestamp()
403
- ORDER BY expires_at, checkpoint_id
404
- FOR UPDATE SKIP LOCKED LIMIT $1
405
- )
406
- DELETE FROM adcp_reporting_checkpoints target
407
- USING expired WHERE target.checkpoint_id = expired.checkpoint_id`,
408
- [limit]
409
- );
410
- return { snapshotsDeleted: snapshots.rowCount ?? 0, checkpointsDeleted: checkpoints.rowCount ?? 0 };
411
- } catch (cause) {
412
- throw new Error("Reporting ledger expiry cleanup failed", { cause });
413
- }
414
- }
415
- class PostgresReportingLedgerStore {
416
- constructor(pool, options = {}) {
417
- this.pool = pool;
418
- this.managedDelivery = options.managedDelivery === true;
419
- this.obligatedConsumers = options.obligatedConsumers;
420
- this[REPORTING_LEDGER_AUTHORITY] = { substrate: pool, managedDelivery: this.managedDelivery };
421
- const development = process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development";
422
- if (!development && !options.acknowledgeIsolatedDatabase) {
423
- throw new Error(
424
- "PostgresReportingLedgerStore requires an isolated database/schema or acknowledgeIsolatedDatabase: true"
425
- );
426
- }
427
- this.consumerMismatchEscalation = assertReportingConsumerMismatchEscalation(options.consumerMismatchEscalation);
428
- this.notificationActivityPort = options.notificationActivityPort;
429
- this.transactionalNotificationActivity = options.notificationActivityPort !== void 0;
430
- this.rowStorage = new ReportingRowStorageV1(pool, typeof options.rowStorage === "object" ? options.rowStorage : {});
431
- this.rowStorageWrites = options.rowStorage !== void 0 && options.rowStorage !== false;
432
- this.changeFeed = options.changeFeed === true;
433
- }
434
- pool;
435
- /**
436
- * Exposed so `createReportingStatusHandler` can inherit it and refuse a
437
- * disagreement. The store applies `health` while building the snapshot and
438
- * the handler projects severity afterwards, so two independently configured
439
- * copies of the escalation window would let a filtered periods read
440
- * contradict the summary.
441
- */
442
- consumerMismatchEscalation;
443
- [REPORTING_LEDGER_AUTHORITY];
444
- managedDelivery;
445
- obligatedConsumers;
446
- transactionalNotificationActivity;
447
- notificationActivityPort;
448
- rowStorage;
449
- rowStorageWrites;
450
- changeFeed;
451
- /** Whether this store writes the host change feed (`changeFeed: true`). */
452
- get changeFeedEnabled() {
453
- return this.changeFeed;
454
- }
455
- /**
456
- * Delete expired cursor snapshots and changes checkpoints, a bounded batch
457
- * at a time. The store-bound form of `sweepExpiredReportingLedgerState`, for
458
- * hosts that hold the store but not the pool.
459
- */
460
- async sweepExpiredState(limit = 1e3) {
461
- return sweepExpiredReportingLedgerState(this.pool, limit);
462
- }
463
- /** Append one host change-feed row inside the caller's account-locked transaction. */
464
- async appendChange(client, kind, accountId, recordId, obligationId) {
465
- if (!this.changeFeed) return;
466
- await client.query(
467
- `INSERT INTO adcp_reporting_changes (account_id, record_kind, record_id, obligation_id) VALUES ($1, $2, $3, $4)`,
468
- [accountId, kind, recordId, obligationId]
469
- );
470
- }
471
- /**
472
- * Defense in depth for snapshot compaction (see `compactSupersededSnapshots`): throws `INVALID_INPUT`
473
- * if a writable object row binding's key template omits `{revision_key}`. Every such binding is
474
- * already refused when the store is configured, so this only guards a configuration changed after
475
- * construction; `readOnly` bindings are skipped. Called by `compactSupersededSnapshots` and by
476
- * service construction when compaction is enabled.
477
- */
478
- assertCompactionSafeRowBindings() {
479
- this.rowStorage.assertCompactionSafeKeyTemplates();
480
- }
481
- /** Probe and register row storage when enabled. Safe to call repeatedly. */
482
- async readyRowStorage() {
483
- if (this.rowStorageWrites) await this.rowStorage.ready();
484
- }
485
- /**
486
- * Move legacy inline revision and adjustment rows into row storage, a
487
- * bounded batch at a time. Each row set is re-encoded, proven to reproduce
488
- * its committed binding, written, and only then removed from the ledger
489
- * document, in one transaction under the account lock. A document whose
490
- * rows do not reproduce its binding is never rewritten: it is reported in
491
- * `quarantined` and left inline. Pass the returned `cursor` back to resume
492
- * after the last examined document; it is absent once every candidate has
493
- * been examined. Reclaiming the freed space needs `VACUUM FULL` or an online
494
- * repack.
495
- */
496
- async migrateInlineRows(input = {}) {
497
- if (!this.rowStorageWrites) throw new Error("migrateInlineRows requires rowStorage");
498
- const limit = input.limit ?? 100;
499
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e4) {
500
- throw new RangeError("migrateInlineRows limit must be between 1 and 10000");
501
- }
502
- let after;
503
- if (input.cursor !== void 0) {
504
- try {
505
- const parsed = JSON.parse(Buffer.from(input.cursor, "base64url").toString("utf8"));
506
- if (typeof parsed.at !== "string" || typeof parsed.id !== "string") throw new Error("invalid");
507
- after = { at: parsed.at, id: parsed.id };
508
- } catch {
509
- throw new RangeError("migrateInlineRows cursor is invalid");
510
- }
511
- }
512
- await this.rowStorage.ready();
513
- const candidates = await this.query(
514
- `SELECT kind, id, account_id, to_char(recorded_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS at
515
- FROM (
516
- SELECT 'revision' AS kind, revision.revision_id AS id, obligation.account_id, revision.recorded_at
517
- FROM adcp_reporting_revisions revision
518
- JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
519
- WHERE revision.data ? 'rows' AND ($2::text IS NULL OR obligation.account_id = $2)
520
- UNION ALL
521
- SELECT 'adjustment', adjustment.adjustment_id, obligation.account_id, adjustment.recorded_at
522
- FROM adcp_reporting_adjustments adjustment
523
- JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = adjustment.obligation_id
524
- WHERE adjustment.data ? 'rows' AND ($2::text IS NULL OR obligation.account_id = $2)
525
- ) inline_rows
526
- WHERE $3::timestamptz IS NULL OR (recorded_at, id) > ($3::timestamptz, $4::text)
527
- ORDER BY recorded_at, id
528
- LIMIT $1`,
529
- [limit + 1, input.account_id ?? null, after?.at ?? null, after?.id ?? null]
530
- );
531
- let migrated = 0;
532
- const quarantined = [];
533
- for (const candidate of candidates.rows.slice(0, limit)) {
534
- input.signal?.throwIfAborted();
535
- const table = candidate.kind === "revision" ? "adcp_reporting_revisions" : "adcp_reporting_adjustments";
536
- const idColumn = candidate.kind === "revision" ? "revision_id" : "adjustment_id";
537
- const outcome = await this.transaction(
538
- async (client) => {
539
- const locked = await client.query(
540
- `SELECT data FROM ${table} WHERE ${idColumn} = $1 AND data ? 'rows' FOR UPDATE`,
541
- [candidate.id]
542
- );
543
- const document = locked.rows[0]?.data;
544
- if (!document) return "skipped";
545
- let prepared;
546
- try {
547
- prepared = await this.rowStorage.prepare({
548
- rowSetId: candidate.id,
549
- rowSetKind: candidate.kind,
550
- accountId: candidate.account_id,
551
- obligationId: document.reporting_obligation_id,
552
- rows: document.rows,
553
- ...candidate.kind === "revision" ? { finality: document.finality, controlTotals: document.wireRevision.control_totals } : {},
554
- binding: document.binding,
555
- ...input.bindingId ? { bindingId: input.bindingId } : {}
556
- });
557
- } catch (error) {
558
- if (isReportingRowStoreError(error) && error.code === "INVALID_INPUT") return "quarantined";
559
- throw error;
560
- }
561
- if (prepared.binding.kind !== "postgres") {
562
- throw new ReportingRowStoreError(
563
- "INVALID_INPUT",
564
- "migrateInlineRows writes PostgreSQL-kind row sets; select a postgres binding for migrated rows"
565
- );
566
- }
567
- await this.rowStorage.writeInTransaction(client, prepared);
568
- await client.query(`UPDATE ${table} SET data = data - 'rows' WHERE ${idColumn} = $1`, [candidate.id]);
569
- return "migrated";
570
- },
571
- { preBeginAdvisoryLock: accountLock(candidate.account_id) }
572
- );
573
- if (outcome === "migrated") migrated += 1;
574
- if (outcome === "quarantined") quarantined.push(candidate.id);
575
- }
576
- const last = candidates.rows.length > limit ? candidates.rows[limit - 1] : void 0;
577
- return {
578
- migrated,
579
- quarantined,
580
- ...last ? { cursor: Buffer.from(JSON.stringify({ at: last.at, id: last.id }), "utf8").toString("base64url") } : {}
581
- };
582
- }
583
- async putConfiguration(configuration) {
584
- return this.transaction(
585
- async (client) => {
586
- await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
587
- `adcp-reporting-config:${configuration.account.account_id}:${configuration.delivery_config_id}`
588
- ]);
589
- const newer = await client.query(
590
- `SELECT MAX(delivery_config_version)::integer AS highest
591
- FROM adcp_reporting_configurations WHERE account_id = $1 AND delivery_config_id = $2`,
592
- [configuration.account.account_id, configuration.delivery_config_id]
593
- );
594
- if ((newer.rows[0]?.highest ?? -1) > configuration.delivery_config_version) {
595
- throw new Error("Reporting configuration version cannot regress");
596
- }
597
- const inserted = await client.query(
598
- `INSERT INTO adcp_reporting_configurations
599
- (configuration_id, account_id, delivery_config_id, delivery_config_version,
600
- semantic_fingerprint, data, created_at)
601
- VALUES ($1, $2, $3, $4, $5, $6::jsonb, clock_timestamp())
602
- ON CONFLICT DO NOTHING RETURNING data`,
603
- [
604
- configuration.configurationId,
605
- configuration.account.account_id,
606
- configuration.delivery_config_id,
607
- configuration.delivery_config_version,
608
- configuration.semanticFingerprint,
609
- JSON.stringify(configuration)
610
- ]
611
- );
612
- const value = inserted.rows[0]?.data ?? (await client.query(
613
- `SELECT data FROM adcp_reporting_configurations
614
- WHERE account_id = $1 AND delivery_config_id = $2 AND delivery_config_version = $3`,
615
- [
616
- configuration.account.account_id,
617
- configuration.delivery_config_id,
618
- configuration.delivery_config_version
619
- ]
620
- )).rows[0]?.data;
621
- if (!value || value.semanticFingerprint !== configuration.semanticFingerprint) {
622
- throw new Error("Immutable reporting configuration identity names different content");
623
- }
624
- return { inserted: inserted.rowCount === 1, value: clone(value) };
625
- },
626
- { preBeginAdvisoryLock: accountLock(configuration.account.account_id) }
627
- );
628
- }
629
- async listConfigurations(account_id) {
630
- const result = await this.query(
631
- `SELECT data FROM adcp_reporting_configurations
632
- WHERE ($1::text IS NULL OR account_id = $1)
633
- ORDER BY created_at, configuration_id`,
634
- [account_id ?? null]
635
- );
636
- return result.rows.map((row) => clone(row.data));
637
- }
638
- async putObligation(obligation) {
639
- const retired = await this.query(
640
- `SELECT 1 FROM adcp_reporting_obligation_tombstones WHERE configuration_id = $1 AND period_ordinal = $2`,
641
- [obligation.configurationId, obligation.periodOrdinal]
642
- );
643
- if (retired.rows.length > 0) throw new ReportingLedgerPeriodRetiredError();
644
- return this.putImmutable(
645
- `INSERT INTO adcp_reporting_obligations
646
- (obligation_id, configuration_id, account_id, period_ordinal, period_start, period_end,
647
- next_attempt_at, state, semantic_fingerprint, data, created_at)
648
- SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, clock_timestamp()
649
- WHERE NOT EXISTS (
650
- SELECT 1 FROM adcp_reporting_obligation_tombstones
651
- WHERE configuration_id = $2 AND period_ordinal = $4
652
- )
653
- ON CONFLICT DO NOTHING RETURNING data`,
654
- [
655
- obligation.reporting_obligation_id,
656
- obligation.configurationId,
657
- obligation.account.account_id,
658
- obligation.periodOrdinal,
659
- obligation.period.start,
660
- obligation.period.end,
661
- obligation.nextAttemptAt,
662
- obligation.state,
663
- obligation.semanticFingerprint,
664
- JSON.stringify(obligation)
665
- ],
666
- `SELECT data FROM adcp_reporting_obligations
667
- WHERE configuration_id = $1 AND period_ordinal = $2`,
668
- [obligation.configurationId, obligation.periodOrdinal],
669
- obligation,
670
- (value) => value.semanticFingerprint,
671
- accountLock(obligation.account.account_id),
672
- void 0,
673
- void 0,
674
- this.changeFeed ? (client) => this.appendChange(
675
- client,
676
- "obligation",
677
- obligation.account.account_id,
678
- obligation.reporting_obligation_id,
679
- obligation.reporting_obligation_id
680
- ) : void 0
681
- );
682
- }
683
- async getObligation(id, account_id) {
684
- return this.one(
685
- `SELECT data FROM adcp_reporting_obligations
686
- WHERE obligation_id = $1 AND ($2::text IS NULL OR account_id = $2)`,
687
- [id, account_id ?? null]
688
- );
689
- }
690
- /** Periods retired by retention; planners treat them as already covered. */
691
- async listRetiredObligationOrdinals(account_id) {
692
- const result = await this.query(
693
- `SELECT configuration_id, period_ordinal FROM adcp_reporting_obligation_tombstones
694
- WHERE ($1::text IS NULL OR account_id = $1) ORDER BY configuration_id, period_ordinal`,
695
- [account_id ?? null]
696
- );
697
- return result.rows.map((row) => ({
698
- configurationId: row.configuration_id,
699
- periodOrdinal: Number(row.period_ordinal)
700
- }));
701
- }
702
- async listObligations(account_id) {
703
- const result = await this.query(
704
- `SELECT data FROM adcp_reporting_obligations
705
- WHERE ($1::text IS NULL OR account_id = $1) ORDER BY period_start, obligation_id`,
706
- [account_id ?? null]
707
- );
708
- return result.rows.map((row) => clone(row.data));
709
- }
710
- async listLifecycleDueObligations(input) {
711
- positiveInteger(input.limit, "limit");
712
- if (input.limit > 1e3) throw new RangeError("limit must not exceed 1000");
713
- if (!Number.isFinite(Date.parse(input.ledgerAsOf))) throw new TypeError("ledgerAsOf must be an RFC 3339 instant");
714
- const managedDueArm = await this.managedDueTablesReady() ? MANAGED_DUE_ARM : "";
715
- const result = await this.query(
716
- `SELECT obligation.data FROM adcp_reporting_obligations obligation
717
- LEFT JOIN LATERAL (
718
- SELECT transition.data->>'health' AS health, transition.occurred_at
719
- FROM adcp_reporting_transitions transition
720
- WHERE transition.obligation_id = obligation.obligation_id
721
- ORDER BY transition.transition_sequence DESC LIMIT 1
722
- ) latest ON TRUE
723
- LEFT JOIN adcp_reporting_lifecycle_state state
724
- ON state.obligation_id = obligation.obligation_id
725
- CROSS JOIN LATERAL (
726
- -- A reconcile that changed no health still has to count as work done,
727
- -- or a managed change with no health effect keeps the obligation due
728
- -- forever and, at small page sizes, starves everything behind it.
729
- SELECT GREATEST(
730
- COALESCE(state.processed_at, obligation.created_at),
731
- COALESCE(latest.occurred_at, obligation.created_at)
732
- ) AS since
733
- ) watermark
734
- WHERE ($1::text IS NULL OR obligation.account_id = $1)
735
- AND ((COALESCE(latest.health, 'waiting') = 'waiting'
736
- AND (obligation.data->>'expectedAt')::timestamptz <= $2)
737
- OR (latest.health = 'delayed'
738
- AND (obligation.data->>'recoveryDeadlineAt')::timestamptz <= $2)
739
- OR (obligation.state = 'terminal'
740
- AND COALESCE(latest.health, 'waiting') <> 'complete'
741
- AND EXISTS (
742
- SELECT 1 FROM adcp_reporting_revisions revision
743
- WHERE revision.obligation_id = obligation.obligation_id
744
- ))
745
- ${managedDueArm} )
746
- -- A tenant that keeps failing must not monopolise every sweep. Its
747
- -- backoff cursor holds it out of selection until it is due again,
748
- -- while processed_at ordering still favours whoever has waited
749
- -- longest among the eligible.
750
- AND (state.next_attempt_at IS NULL OR state.next_attempt_at <= $2)
751
- ORDER BY watermark.since, LEAST(
752
- (obligation.data->>'expectedAt')::timestamptz,
753
- (obligation.data->>'recoveryDeadlineAt')::timestamptz
754
- ), obligation.obligation_id
755
- LIMIT $3`,
756
- [input.account_id ?? null, input.ledgerAsOf, input.limit]
757
- );
758
- return result.rows.map((row) => clone(row.data));
759
- }
760
- async updateObligation(obligation, lease, issue) {
761
- assertLeaseTarget(obligation.reporting_obligation_id, lease);
762
- await this.transaction(
763
- async (client) => {
764
- const result = await client.query(
765
- `UPDATE adcp_reporting_obligations
766
- SET next_attempt_at = $2, state = $3, data = $4::jsonb, changed_at = clock_timestamp()
767
- WHERE obligation_id = $1 AND semantic_fingerprint = $5
768
- AND lease_owner = $6 AND lease_generation = $7 AND lease_expires_at > clock_timestamp()`,
769
- [
770
- obligation.reporting_obligation_id,
771
- obligation.nextAttemptAt,
772
- obligation.state,
773
- JSON.stringify(obligation),
774
- obligation.semanticFingerprint,
775
- lease.owner,
776
- lease.generation
777
- ]
778
- );
779
- if (result.rowCount !== 1) throw new ReportingLedgerLeaseLostError();
780
- await this.appendChange(
781
- client,
782
- "obligation",
783
- lease.obligation.account.account_id,
784
- obligation.reporting_obligation_id,
785
- obligation.reporting_obligation_id
786
- );
787
- if (issue) {
788
- if (issue.reporting_obligation_id !== obligation.reporting_obligation_id) {
789
- throw new Error("Reporting issue must belong to the leased obligation");
790
- }
791
- const issueResult = await client.query(
792
- `INSERT INTO adcp_reporting_issues (issue_id, obligation_id, data, observed_at, resolved_at)
793
- VALUES ($1, $2, $3::jsonb, $4, $5)
794
- -- rc.3 fixes opened_at at first emission: it MUST NOT advance while
795
- -- the same issue_id is re-emitted, including across the delayed ->
796
- -- action_required severity change that re-upserts this row. Keep the
797
- -- stored anchor and let every other field advance.
798
- ON CONFLICT (issue_id) DO UPDATE SET
799
- data = jsonb_set(EXCLUDED.data, '{openedAt}', COALESCE(adcp_reporting_issues.data -> 'openedAt', EXCLUDED.data -> 'openedAt')),
800
- observed_at = EXCLUDED.observed_at,
801
- resolved_at = EXCLUDED.resolved_at,
802
- changed_at = clock_timestamp()
803
- WHERE adcp_reporting_issues.obligation_id = EXCLUDED.obligation_id`,
804
- [
805
- issue.issueId,
806
- issue.reporting_obligation_id,
807
- JSON.stringify(issue),
808
- issue.observedAt,
809
- issue.resolvedAt ?? null
810
- ]
811
- );
812
- if (issueResult.rowCount !== 1) throw new Error("Reporting issue identity belongs to another obligation");
813
- }
814
- },
815
- { preBeginAdvisoryLock: accountLock(obligation.account.account_id) }
816
- );
817
- }
818
- async claimObligation(input) {
819
- positiveInteger(input.leaseMilliseconds, "leaseMilliseconds");
820
- if (!Number.isFinite(Date.parse(input.now))) throw new TypeError("now must be an RFC 3339 instant");
821
- const result = await this.query(
822
- `WITH candidate AS (
823
- SELECT obligation_id FROM adcp_reporting_obligations
824
- WHERE state = 'pending' AND next_attempt_at <= $2
825
- AND ($4::text IS NULL OR account_id = $4)
826
- AND (lease_expires_at IS NULL OR lease_expires_at <= clock_timestamp())
827
- ORDER BY next_attempt_at, obligation_id
828
- FOR UPDATE SKIP LOCKED LIMIT 1
829
- )
830
- UPDATE adcp_reporting_obligations obligation
831
- SET lease_owner = $1, lease_generation = lease_generation + 1,
832
- lease_expires_at = clock_timestamp() + ($3::bigint * INTERVAL '1 millisecond')
833
- FROM candidate WHERE obligation.obligation_id = candidate.obligation_id
834
- RETURNING obligation.data, obligation.lease_generation::text, obligation.lease_expires_at`,
835
- [input.owner, input.now, input.leaseMilliseconds, input.account_id ?? null]
836
- );
837
- const row = result.rows[0];
838
- return row ? {
839
- obligation: clone(row.data),
840
- owner: input.owner,
841
- generation: Number(row.lease_generation),
842
- expiresAt: row.lease_expires_at.toISOString()
843
- } : null;
844
- }
845
- async releaseObligationLease(lease) {
846
- await this.query(
847
- `UPDATE adcp_reporting_obligations SET lease_owner = NULL, lease_expires_at = NULL
848
- WHERE obligation_id = $1 AND lease_owner = $2 AND lease_generation = $3`,
849
- [lease.obligation.reporting_obligation_id, lease.owner, lease.generation]
850
- );
851
- }
852
- async commitRevision(revision, lease) {
853
- assertLeaseTarget(revision.reporting_obligation_id, lease);
854
- validateRevisionBinding(revision);
855
- const prepared = this.rowStorageWrites ? await this.rowStorage.prepare({
856
- rowSetId: revision.reporting_revision_id,
857
- rowSetKind: "revision",
858
- accountId: lease.obligation.account.account_id,
859
- obligationId: revision.reporting_obligation_id,
860
- rows: revision.rows,
861
- finality: revision.finality,
862
- periodStart: lease.obligation.period.start,
863
- sourceTimezone: lease.obligation.period.sourceTimezone,
864
- binding: revision.binding,
865
- controlTotals: revision.wireRevision.control_totals
866
- }) : void 0;
867
- const upload = prepared?.binding.kind === "object" ? await this.uploadRowSetUnlessCommitted(prepared, lease, "adcp_reporting_revisions", "revision_id") : void 0;
868
- const port = this.notificationActivityPort?.recordLedgerChanged ? this.notificationActivityPort : void 0;
869
- const result = await this.putImmutable(
870
- `WITH leased_obligation AS (
871
- SELECT * FROM adcp_reporting_obligations obligation
872
- WHERE obligation.obligation_id = $2
873
- AND obligation.lease_owner = $9 AND obligation.lease_generation = $10
874
- AND obligation.lease_expires_at > clock_timestamp()
875
- FOR UPDATE
876
- )
877
- INSERT INTO adcp_reporting_revisions
878
- (revision_id, obligation_id, revision_number, finality, kind,
879
- supersedes_revision_id, content_sha256, data, created_at)
880
- SELECT $1, $2, $3, $4, $5, $6, $7, $8::jsonb, clock_timestamp()
881
- FROM leased_obligation obligation
882
- WHERE NOT EXISTS (
883
- SELECT 1 FROM adcp_reporting_revisions terminal_revision
884
- WHERE terminal_revision.obligation_id = obligation.obligation_id
885
- AND terminal_revision.finality = 'official'
886
- )
887
- AND $3 = COALESCE((
888
- SELECT MAX(sequence_revision.revision_number) + 1
889
- FROM adcp_reporting_revisions sequence_revision
890
- WHERE sequence_revision.obligation_id = obligation.obligation_id
891
- ), 1)
892
- AND (($3 = 1 AND $6::text IS NULL) OR $6 = (
893
- SELECT predecessor.revision_id FROM adcp_reporting_revisions predecessor
894
- WHERE predecessor.obligation_id = obligation.obligation_id
895
- AND predecessor.revision_number = $3 - 1
896
- ))
897
- ON CONFLICT DO NOTHING RETURNING data`,
898
- [
899
- revision.reporting_revision_id,
900
- revision.reporting_obligation_id,
901
- revision.revisionNumber,
902
- revision.finality,
903
- revision.kind,
904
- revision.supersedes_reporting_revision_id ?? null,
905
- revision.binding.sha256,
906
- JSON.stringify(prepared ? withoutRows(revision) : revision),
907
- lease.owner,
908
- lease.generation
909
- ],
910
- "SELECT data FROM adcp_reporting_revisions WHERE obligation_id = $1 AND revision_number = $2",
911
- [revision.reporting_obligation_id, revision.revisionNumber],
912
- revision,
913
- revisionIdentityFingerprint,
914
- accountLock(lease.obligation.account.account_id),
915
- {
916
- obligationId: revision.reporting_obligation_id,
917
- owner: lease.owner,
918
- generation: lease.generation
919
- },
920
- revisionLegacyCanonicalDigestReplay,
921
- prepared || port || this.changeFeed ? async (client, committed) => {
922
- if (prepared) await this.writePreparedRowSet(client, prepared, upload);
923
- await this.appendChange(
924
- client,
925
- "revision",
926
- lease.obligation.account.account_id,
927
- revision.reporting_revision_id,
928
- revision.reporting_obligation_id
929
- );
930
- await port?.recordLedgerChanged(
931
- { obligation: lease.obligation, revision: withRows(committed, revision.rows) },
932
- client
933
- );
934
- } : void 0
935
- );
936
- return { inserted: result.inserted, value: withRows(result.value, revision.rows) };
937
- }
938
- async getRevision(id, accountId) {
939
- const revision = await this.one(
940
- `SELECT revision.data FROM adcp_reporting_revisions revision
941
- JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
942
- WHERE revision.revision_id = $1 AND obligation.account_id = $2`,
943
- [id, accountId]
944
- );
945
- return revision ? this.hydrateRevision(revision, accountId) : null;
946
- }
947
- async getRevisionMetadata(id, accountId) {
948
- return this.one(
949
- `SELECT revision.data - 'rows' AS data FROM adcp_reporting_revisions revision
950
- JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
951
- WHERE revision.revision_id = $1 AND obligation.account_id = $2`,
952
- [id, accountId]
953
- );
954
- }
955
- async readRevisionRows(input) {
956
- if (!Number.isSafeInteger(input.offset) || input.offset < 0 || !Number.isSafeInteger(input.limit) || input.limit < 1) {
957
- throw new RangeError("Reporting row page bounds are invalid");
958
- }
959
- const result = await this.query(
960
- `SELECT revision.data ? 'rows' AS inline,
961
- CASE WHEN revision.data ? 'rows' THEN jsonb_array_length(revision.data->'rows') END AS total,
962
- CASE WHEN revision.data ? 'rows' THEN jsonb_path_query_array(
963
- revision.data->'rows',
964
- '$[$from to $to]',
965
- jsonb_build_object('from', $3::bigint, 'to', $3::bigint + $4::bigint - 1)
966
- ) END AS rows
967
- FROM adcp_reporting_revisions revision
968
- JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
969
- WHERE revision.revision_id = $1 AND obligation.account_id = $2`,
970
- [input.reporting_revision_id, input.account_id, input.offset, input.limit]
971
- );
972
- const row = result.rows[0];
973
- if (!row) return null;
974
- if (row.inline) return { rows: clone(row.rows ?? []), total: Number(row.total) };
975
- const handle = await this.rowStorage.find(input.reporting_revision_id, input.account_id);
976
- if (!handle) throw new ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "revision rows are not recorded");
977
- if (input.offset >= handle.rowCount) return { rows: [], total: handle.rowCount };
978
- return {
979
- rows: await this.rowStorage.readPage(handle, input.offset, input.limit, input.signal),
980
- total: handle.rowCount
981
- };
982
- }
983
- async listRevisions(obligationId) {
984
- const result = await this.query(
985
- `SELECT revision.data, obligation.account_id FROM adcp_reporting_revisions revision
986
- JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
987
- WHERE revision.obligation_id = $1
988
- ORDER BY revision.revision_number, revision.revision_id`,
989
- [obligationId]
990
- );
991
- const revisions = [];
992
- for (const row of result.rows) {
993
- try {
994
- revisions.push(await this.hydrateRevision(clone(row.data), row.account_id));
995
- } catch (error) {
996
- if (isReportingRowStoreError(error) && error.code === "ROWS_EXPIRED") continue;
997
- throw error;
998
- }
999
- }
1000
- return revisions;
1001
- }
1002
- async listRevisionMetadata(obligationId, accountId) {
1003
- const result = await this.query(
1004
- `SELECT revision.data - 'rows' AS data FROM adcp_reporting_revisions revision
1005
- JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
1006
- WHERE revision.obligation_id = $1 AND ($2::text IS NULL OR obligation.account_id = $2)
1007
- ORDER BY revision.revision_number, revision.revision_id`,
1008
- [obligationId, accountId ?? null]
1009
- );
1010
- return result.rows.map((row) => clone(row.data));
1011
- }
1012
- async commitAdjustment(adjustment, lease) {
1013
- assertLeaseTarget(adjustment.reporting_obligation_id, lease);
1014
- validateBoundRows(adjustment);
1015
- const prepared = this.rowStorageWrites ? await this.rowStorage.prepare({
1016
- rowSetId: adjustment.reporting_adjustment_id,
1017
- rowSetKind: "adjustment",
1018
- accountId: lease.obligation.account.account_id,
1019
- obligationId: adjustment.reporting_obligation_id,
1020
- rows: adjustment.rows,
1021
- periodStart: lease.obligation.period.start,
1022
- sourceTimezone: lease.obligation.period.sourceTimezone,
1023
- binding: adjustment.binding
1024
- }) : void 0;
1025
- const upload = prepared?.binding.kind === "object" ? await this.uploadRowSetUnlessCommitted(prepared, lease, "adcp_reporting_adjustments", "adjustment_id") : void 0;
1026
- const port = this.notificationActivityPort?.recordLedgerChanged ? this.notificationActivityPort : void 0;
1027
- const result = await this.putImmutable(
1028
- `WITH leased_obligation AS (
1029
- SELECT * FROM adcp_reporting_obligations obligation
1030
- WHERE obligation.obligation_id = $2
1031
- AND obligation.lease_owner = $7 AND obligation.lease_generation = $8
1032
- AND obligation.lease_expires_at > clock_timestamp()
1033
- FOR UPDATE
1034
- )
1035
- INSERT INTO adcp_reporting_adjustments
1036
- (adjustment_id, obligation_id, adjusts_revision_id, adjustment_number,
1037
- content_sha256, data, created_at)
1038
- SELECT $1, $2, $3, $4, $5, $6::jsonb, clock_timestamp()
1039
- FROM leased_obligation obligation
1040
- WHERE EXISTS (
1041
- SELECT 1 FROM adcp_reporting_revisions official_revision
1042
- WHERE official_revision.obligation_id = obligation.obligation_id
1043
- AND official_revision.revision_id = $3
1044
- AND official_revision.finality = 'official'
1045
- )
1046
- AND $4 = COALESCE((
1047
- SELECT MAX(sequence_adjustment.adjustment_number) + 1
1048
- FROM adcp_reporting_adjustments sequence_adjustment
1049
- WHERE sequence_adjustment.obligation_id = obligation.obligation_id
1050
- ), 1)
1051
- ON CONFLICT DO NOTHING RETURNING data`,
1052
- [
1053
- adjustment.reporting_adjustment_id,
1054
- adjustment.reporting_obligation_id,
1055
- adjustment.adjusts_reporting_revision_id,
1056
- adjustment.adjustmentNumber,
1057
- adjustment.binding.sha256,
1058
- JSON.stringify(prepared ? withoutRows(adjustment) : adjustment),
1059
- lease.owner,
1060
- lease.generation
1061
- ],
1062
- "SELECT data FROM adcp_reporting_adjustments WHERE obligation_id = $1 AND adjustment_number = $2",
1063
- [adjustment.reporting_obligation_id, adjustment.adjustmentNumber],
1064
- adjustment,
1065
- adjustmentIdentityFingerprint,
1066
- accountLock(lease.obligation.account.account_id),
1067
- {
1068
- obligationId: adjustment.reporting_obligation_id,
1069
- owner: lease.owner,
1070
- generation: lease.generation
1071
- },
1072
- adjustmentLegacyCanonicalDigestReplay,
1073
- prepared || port || this.changeFeed ? async (client, committed) => {
1074
- if (prepared) await this.writePreparedRowSet(client, prepared, upload);
1075
- await this.appendChange(
1076
- client,
1077
- "adjustment",
1078
- lease.obligation.account.account_id,
1079
- adjustment.reporting_adjustment_id,
1080
- adjustment.reporting_obligation_id
1081
- );
1082
- await port?.recordLedgerChanged(
1083
- { obligation: lease.obligation, adjustment: withRows(committed, adjustment.rows) },
1084
- client
1085
- );
1086
- } : void 0
1087
- );
1088
- return { inserted: result.inserted, value: withRows(result.value, adjustment.rows) };
1089
- }
1090
- async listAdjustments(obligationId) {
1091
- const result = await this.query(
1092
- `SELECT adjustment.data, obligation.account_id FROM adcp_reporting_adjustments adjustment
1093
- JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = adjustment.obligation_id
1094
- WHERE adjustment.obligation_id = $1
1095
- ORDER BY adjustment.adjustment_number, adjustment.adjustment_id`,
1096
- [obligationId]
1097
- );
1098
- const adjustments = [];
1099
- for (const row of result.rows) {
1100
- const adjustment = clone(row.data);
1101
- if (Array.isArray(adjustment.rows)) {
1102
- adjustments.push(adjustment);
1103
- continue;
1104
- }
1105
- const handle = await this.rowStorage.find(adjustment.reporting_adjustment_id, row.account_id);
1106
- if (!handle) throw new ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "adjustment rows are not recorded");
1107
- adjustments.push({ ...adjustment, rows: await this.rowStorage.readAll(handle) });
1108
- }
1109
- return adjustments;
1110
- }
1111
- /**
1112
- * The obligation's current revision header: its official revision if one
1113
- * exists, otherwise its highest-numbered snapshot. An official revision is
1114
- * terminal, so this is always the highest revision number — one index
1115
- * lookup. Rows are read separately through `readRevisionRows`.
1116
- */
1117
- async getCurrentRevision(input) {
1118
- return this.one(
1119
- `SELECT revision.data - 'rows' AS data
1120
- FROM adcp_reporting_obligations obligation
1121
- JOIN LATERAL (
1122
- SELECT data FROM adcp_reporting_revisions current_revision
1123
- WHERE current_revision.obligation_id = obligation.obligation_id
1124
- ORDER BY current_revision.revision_number DESC LIMIT 1
1125
- ) revision ON TRUE
1126
- WHERE obligation.obligation_id = $1 AND obligation.account_id = $2`,
1127
- [input.reporting_obligation_id, input.account_id]
1128
- );
1129
- }
1130
- /**
1131
- * Current revision headers for an account's obligations whose period starts
1132
- * in `[period_start_from, period_start_to)`, ordered by period start.
1133
- * Obligations without a revision yet are omitted. Page with `cursor`.
1134
- */
1135
- async listCurrentRevisions(input) {
1136
- const limit = input.limit ?? 100;
1137
- positiveInteger(limit, "limit");
1138
- if (limit > 1e3) throw new RangeError("listCurrentRevisions limit must not exceed 1000");
1139
- let after;
1140
- if (input.cursor !== void 0) {
1141
- try {
1142
- const parsed = JSON.parse(Buffer.from(input.cursor, "base64url").toString("utf8"));
1143
- if (typeof parsed.start !== "string" || typeof parsed.id !== "string") throw new Error("invalid");
1144
- after = { start: parsed.start, id: parsed.id };
1145
- } catch {
1146
- throw new RangeError("listCurrentRevisions cursor is invalid");
1147
- }
1148
- }
1149
- const result = await this.query(
1150
- `SELECT revision.data - 'rows' AS data,
1151
- to_char(obligation.period_start AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS start,
1152
- obligation.obligation_id AS id
1153
- FROM adcp_reporting_obligations obligation
1154
- JOIN LATERAL (
1155
- SELECT data FROM adcp_reporting_revisions current_revision
1156
- WHERE current_revision.obligation_id = obligation.obligation_id
1157
- ORDER BY current_revision.revision_number DESC LIMIT 1
1158
- ) revision ON TRUE
1159
- WHERE obligation.account_id = $1
1160
- AND obligation.period_start >= $2::timestamptz AND obligation.period_start < $3::timestamptz
1161
- AND ($4::text IS NULL OR obligation.data->>'delivery_config_id' = $4)
1162
- AND ($5::timestamptz IS NULL
1163
- OR (obligation.period_start, obligation.obligation_id) > ($5::timestamptz, $6::text))
1164
- ORDER BY obligation.period_start, obligation.obligation_id
1165
- LIMIT $7`,
1166
- [
1167
- input.account_id,
1168
- input.period_start_from,
1169
- input.period_start_to,
1170
- input.delivery_config_id ?? null,
1171
- after?.start ?? null,
1172
- after?.id ?? null,
1173
- limit + 1
1174
- ]
1175
- );
1176
- const page = result.rows.slice(0, limit);
1177
- const last = result.rows.length > limit ? page.at(-1) : void 0;
1178
- return {
1179
- revisions: page.map((row) => clone(row.data)),
1180
- ...last ? { cursor: Buffer.from(JSON.stringify({ start: last.start, id: last.id }), "utf8").toString("base64url") } : {}
1181
- };
1182
- }
1183
- /**
1184
- * Host change feed. Omit `cursor` to start from the oldest retained change.
1185
- * Account-scoped feeds order by sequence; the deployment-wide feed orders by
1186
- * `(xid, seq)` and returns only changes whose transaction precedes every
1187
- * transaction still in flight, so no commit is ever skipped. An account page
1188
- * also ends before the first change whose transaction has not finished, as
1189
- * defense in depth against a change that commits late. An empty page can
1190
- * therefore mean either caught up or held back by an older open transaction.
1191
- * A ledger database must have exactly one writer SDK; see
1192
- * `docs/guides/REPORTING-LEDGER.md`. A cursor older
1193
- * than the retained history throws `ReportingChangeCursorExpiredError`;
1194
- * resynchronize from `listCurrentRevisions`.
1195
- *
1196
- * A read without a `cursor` can throw `ReportingChangeCursorExpiredError`
1197
- * (`code === 'CURSOR_EXPIRED'`) too. The horizon is re-read after the page,
1198
- * so a prune that commits mid-read rejects the page instead of returning one
1199
- * that silently skips the pruned changes. Retrying the read starts from the
1200
- * new, later horizon. Match on `error.code` rather than `instanceof`, which
1201
- * fails when the error crosses a CJS/ESM module boundary.
1202
- */
1203
- async changesAfter(input = {}) {
1204
- if (!this.changeFeed) throw new Error("changesAfter requires the store changeFeed option");
1205
- const limit = input.limit ?? 500;
1206
- positiveInteger(limit, "limit");
1207
- if (limit > 1e4) throw new RangeError("changesAfter limit must not exceed 10000");
1208
- const account = input.account_id ?? null;
1209
- const cursor = input.cursor === void 0 ? void 0 : decodeReportingChangeCursorV1(input.cursor, account);
1210
- const readHorizon = async () => account !== null ? {
1211
- seq: (await this.query(
1212
- `SELECT pruned_through_seq::text AS seq FROM adcp_reporting_change_account_horizon
1213
- WHERE account_id = $1`,
1214
- [account]
1215
- )).rows[0]?.seq ?? "0"
1216
- } : (await this.query(
1217
- `SELECT pruned_through_xid::text AS xid, pruned_through_seq::text AS seq
1218
- FROM adcp_reporting_change_horizon WHERE singleton`
1219
- )).rows[0] ?? { xid: "0", seq: "0" };
1220
- const assertRetained = (position2, horizon2) => {
1221
- const behind = account !== null ? BigInt(horizon2.seq) > BigInt(position2.seq) : BigInt(horizon2.xid) > BigInt(position2.xid) || BigInt(horizon2.xid) === BigInt(position2.xid) && BigInt(horizon2.seq) > BigInt(position2.seq);
1222
- if (behind) {
1223
- throw new ReportingChangeCursorExpiredError(
1224
- encodeReportingChangeCursorV1({
1225
- v: 1,
1226
- account,
1227
- seq: position2.seq,
1228
- ...account === null ? { xid: position2.xid } : {}
1229
- })
1230
- );
1231
- }
1232
- };
1233
- const horizon = await readHorizon();
1234
- if (cursor) assertRetained(cursor, horizon);
1235
- const position = cursor ?? {
1236
- v: 1,
1237
- account,
1238
- seq: horizon.seq,
1239
- ...account === null ? { xid: horizon.xid } : {}
1240
- };
1241
- const kinds = input.kinds?.length ? [...input.kinds] : null;
1242
- const columns = `seq::text AS seq, xid::text AS xid, account_id, record_kind, record_id, obligation_id,
1243
- to_char(recorded_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS recorded_at`;
1244
- const result = account !== null ? await this.query(
1245
- `SELECT ${columns}, xid >= pg_snapshot_xmin(pg_current_snapshot()) AS in_flight
1246
- FROM adcp_reporting_changes
1247
- WHERE account_id = $1 AND seq > $2::bigint AND ($3::text[] IS NULL OR record_kind = ANY($3))
1248
- ORDER BY adcp_reporting_changes.seq LIMIT $4`,
1249
- [account, position.seq, kinds, limit]
1250
- ) : await this.query(
1251
- `SELECT ${columns} FROM adcp_reporting_changes
1252
- WHERE (xid, seq) > ($1::text::xid8, $2::bigint)
1253
- AND xid < pg_snapshot_xmin(pg_current_snapshot())
1254
- AND ($3::text[] IS NULL OR record_kind = ANY($3))
1255
- ORDER BY adcp_reporting_changes.xid, adcp_reporting_changes.seq LIMIT $4`,
1256
- [position.xid ?? "0", position.seq, kinds, limit]
1257
- );
1258
- if (account !== null) {
1259
- const blocked = result.rows.findIndex((row) => row.in_flight);
1260
- if (blocked !== -1) result.rows.length = blocked;
1261
- }
1262
- assertRetained(position, await readHorizon());
1263
- const last = result.rows.at(-1);
1264
- const next = last ? encodeReportingChangeCursorV1({ v: 1, account, seq: last.seq, ...account === null ? { xid: last.xid } : {} }) : input.cursor ?? encodeReportingChangeCursorV1(position);
1265
- return {
1266
- records: result.rows.map((row) => ({
1267
- kind: row.record_kind,
1268
- record_id: row.record_id,
1269
- reporting_obligation_id: row.obligation_id,
1270
- account_id: row.account_id,
1271
- recorded_at: row.recorded_at
1272
- })),
1273
- cursor: next
1274
- };
1275
- }
1276
- /**
1277
- * Record a named durable consumer's position. Registered consumers hold
1278
- * back change-table pruning until `maxFeedHoldDays` after their last update.
1279
- *
1280
- * Checkpoints are forward-only within a feed scope: a cursor behind the
1281
- * saved one is ignored, so a delayed or stale acknowledgment cannot pull the
1282
- * retention hold back. Resolves `true` when the checkpoint was written and
1283
- * `false` when it was ignored (behind the saved position, or equal with
1284
- * `refreshUnchanged: false`). To rewind a consumer deliberately, for example
1285
- * to replay changes, pass `rewind: true`; the saved position and retention
1286
- * hold then move to `cursor` and the write always applies, even with
1287
- * `refreshUnchanged: false`. The cursor is not checked against the
1288
- * retention horizon: it must still be inside the retained history or the
1289
- * next read throws `ReportingChangeCursorExpiredError`. Any change of
1290
- * `account_id` (account to deployment, deployment to account, or between
1291
- * accounts) always replaces the position. The feed scope is read from the
1292
- * cursor itself, so only pass cursors your own code obtained from
1293
- * `changesAfter`, never ones supplied by an untrusted caller.
1294
- *
1295
- * Set `refreshUnchanged: false` to recover an acknowledgment without
1296
- * renewing the retention hold when its position has not advanced.
1297
- */
1298
- async saveFeedConsumerCursor(name, cursor, options = {}) {
1299
- if (!this.changeFeed) throw new Error("saveFeedConsumerCursor requires the store changeFeed option");
1300
- let account;
1301
- try {
1302
- const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
1303
- account = typeof parsed.account === "string" ? parsed.account : null;
1304
- } catch {
1305
- throw new RangeError("feed consumer cursor is invalid");
1306
- }
1307
- const decoded = decodeReportingChangeCursorV1(cursor, account);
1308
- const result = await this.query(
1309
- `INSERT INTO adcp_reporting_feed_consumers (consumer_name, account_id, cursor_xid, cursor_seq)
1310
- VALUES ($1, $2, $3::text::xid8, $4::bigint)
1311
- ON CONFLICT (consumer_name) DO UPDATE
1312
- SET account_id = EXCLUDED.account_id, cursor_xid = EXCLUDED.cursor_xid,
1313
- cursor_seq = EXCLUDED.cursor_seq, updated_at = clock_timestamp()
1314
- WHERE $6::boolean
1315
- OR adcp_reporting_feed_consumers.account_id IS DISTINCT FROM EXCLUDED.account_id
1316
- OR CASE WHEN EXCLUDED.account_id IS NULL
1317
- THEN (adcp_reporting_feed_consumers.cursor_xid, adcp_reporting_feed_consumers.cursor_seq)
1318
- < (EXCLUDED.cursor_xid, EXCLUDED.cursor_seq)
1319
- OR ($5::boolean AND (adcp_reporting_feed_consumers.cursor_xid, adcp_reporting_feed_consumers.cursor_seq)
1320
- = (EXCLUDED.cursor_xid, EXCLUDED.cursor_seq))
1321
- ELSE adcp_reporting_feed_consumers.cursor_seq < EXCLUDED.cursor_seq
1322
- OR ($5::boolean AND adcp_reporting_feed_consumers.cursor_seq = EXCLUDED.cursor_seq) END
1323
- RETURNING consumer_name`,
1324
- [
1325
- name,
1326
- account,
1327
- account === null ? decoded.xid : null,
1328
- decoded.seq,
1329
- options.refreshUnchanged !== false,
1330
- options.rewind === true
1331
- ]
1332
- );
1333
- return result.rows.length > 0;
1334
- }
1335
- /**
1336
- * Unregister a durable consumer so it stops holding back change-table
1337
- * pruning and period retention. Resolves `true` when a registration was
1338
- * removed. Saving a cursor under the same name registers it again.
1339
- */
1340
- async removeFeedConsumer(name) {
1341
- if (!this.changeFeed) throw new Error("removeFeedConsumer requires the store changeFeed option");
1342
- const result = await this.query(
1343
- `DELETE FROM adcp_reporting_feed_consumers WHERE consumer_name = $1 RETURNING consumer_name`,
1344
- [name]
1345
- );
1346
- return result.rows.length > 0;
1347
- }
1348
- /**
1349
- * Delete change rows older than `changeRetentionDays` that every consumer
1350
- * updated within `maxFeedHoldDays` has passed, and advance the expiry
1351
- * horizon so older cursors fail closed.
1352
- */
1353
- async pruneChanges(input = {}) {
1354
- if (!this.changeFeed) return { deleted: 0 };
1355
- const retentionDays = input.changeRetentionDays ?? 30;
1356
- const holdDays = input.maxFeedHoldDays ?? 7;
1357
- positiveInteger(retentionDays, "changeRetentionDays");
1358
- positiveInteger(holdDays, "maxFeedHoldDays");
1359
- const limit = input.limit ?? 1e4;
1360
- positiveInteger(limit, "limit");
1361
- const deleted = await this.transaction(async (client) => {
1362
- const victims = await client.query(
1363
- `WITH victims AS (
1364
- SELECT change.seq FROM adcp_reporting_changes change
1365
- WHERE change.recorded_at < clock_timestamp() - ($1::integer * INTERVAL '1 day')
1366
- AND NOT EXISTS (
1367
- SELECT 1 FROM adcp_reporting_feed_consumers consumer
1368
- WHERE consumer.updated_at > clock_timestamp() - ($2::integer * INTERVAL '1 day')
1369
- AND ((consumer.account_id IS NULL
1370
- AND (change.xid, change.seq) > (consumer.cursor_xid, consumer.cursor_seq))
1371
- OR (consumer.account_id = change.account_id AND change.seq > consumer.cursor_seq))
1372
- )
1373
- ORDER BY change.seq LIMIT $3
1374
- )
1375
- DELETE FROM adcp_reporting_changes change USING victims
1376
- WHERE change.seq = victims.seq
1377
- RETURNING change.seq::text AS seq, change.xid::text AS xid, change.account_id`,
1378
- [retentionDays, holdDays, limit]
1379
- );
1380
- if (victims.rows.length === 0) return 0;
1381
- const highest = victims.rows.reduce(
1382
- (max, row) => BigInt(row.xid) > BigInt(max.xid) || row.xid === max.xid && BigInt(row.seq) > BigInt(max.seq) ? row : max
1383
- );
1384
- await client.query(
1385
- `UPDATE adcp_reporting_change_horizon
1386
- SET (pruned_through_xid, pruned_through_seq) = ($1::text::xid8, $2::bigint)
1387
- WHERE singleton AND ($1::text::xid8, $2::bigint) > (pruned_through_xid, pruned_through_seq)`,
1388
- [highest.xid, highest.seq]
1389
- );
1390
- const perAccount = /* @__PURE__ */ new Map();
1391
- for (const row of victims.rows) {
1392
- const seq = BigInt(row.seq);
1393
- if (seq > (perAccount.get(row.account_id) ?? 0n)) perAccount.set(row.account_id, seq);
1394
- }
1395
- for (const [accountId, seq] of perAccount) {
1396
- await client.query(
1397
- `INSERT INTO adcp_reporting_change_account_horizon (account_id, pruned_through_seq) VALUES ($1, $2::bigint)
1398
- ON CONFLICT (account_id) DO UPDATE
1399
- SET pruned_through_seq = GREATEST(adcp_reporting_change_account_horizon.pruned_through_seq,
1400
- EXCLUDED.pruned_through_seq)`,
1401
- [accountId, seq.toString()]
1402
- );
1403
- }
1404
- return victims.rows.length;
1405
- });
1406
- return { deleted };
1407
- }
1408
- /**
1409
- * Retire whole reporting periods whose retention has elapsed (shared SDK
1410
- * persistence spec §4). A period is eligible once both its end and its
1411
- * latest publication are older than `max(statusRetentionDays,
1412
- * recordRetentionDays)`, no worker holds its lease, and no Managed Delivery
1413
- * materialization still references it. Retirement is period-aligned and
1414
- * resumable:
1415
- *
1416
- * 1. under the account lock, record a `retiring` tombstone and mark the
1417
- * period's row sets `pruning`, so exact reads answer as expired;
1418
- * 2. delete the period's external row objects at their recorded versions;
1419
- * 3. under the account lock, delete the period's ledger records (rows,
1420
- * revisions, adjustments, consumer statuses, issues, transitions,
1421
- * lifecycle state, obligation) and mark the tombstone `retired`.
1422
- *
1423
- * Planners skip retired periods, and `ledger_retained_from` advances past
1424
- * them because their obligations no longer exist.
1425
- *
1426
- * When the change-feed tables exist, a period that is not yet retiring is
1427
- * also held while any live registered feed consumer (updated within
1428
- * `maxFeedHoldDays`, default 7) has not passed that period's change rows in
1429
- * the consumer's own feed order, the same rule `pruneChanges` applies. A
1430
- * held period is simply not a candidate this pass. A consumer that stays
1431
- * silent past `maxFeedHoldDays` stops holding, so retention is never
1432
- * blocked forever; it then falls behind retention and a sink sees the
1433
- * retired revisions as missing.
1434
- */
1435
- async retireExpiredPeriods(input) {
1436
- if (!Number.isSafeInteger(input.statusRetentionDays) || input.statusRetentionDays < 1) {
1437
- throw new RangeError("statusRetentionDays must be a positive integer");
1438
- }
1439
- const recordDays = input.recordRetentionDays ?? input.statusRetentionDays;
1440
- if (!Number.isSafeInteger(recordDays) || recordDays < input.statusRetentionDays) {
1441
- throw new RangeError("recordRetentionDays must be an integer no shorter than statusRetentionDays");
1442
- }
1443
- const limit = input.limit ?? 100;
1444
- positiveInteger(limit, "limit");
1445
- const holdDays = input.maxFeedHoldDays ?? 7;
1446
- positiveInteger(holdDays, "maxFeedHoldDays");
1447
- const tables = await this.optionalTables();
1448
- const materializationHold = tables.has("adcp_reporting_materializations") ? `AND NOT EXISTS (SELECT 1 FROM adcp_reporting_materializations hold WHERE hold.obligation_id = obligation.obligation_id)` : "";
1449
- const feedHold = tables.has("adcp_reporting_changes") && tables.has("adcp_reporting_feed_consumers") ? `AND NOT EXISTS (
1450
- SELECT 1 FROM adcp_reporting_changes change
1451
- JOIN adcp_reporting_feed_consumers consumer
1452
- ON consumer.updated_at > clock_timestamp() - ($4::integer * INTERVAL '1 day')
1453
- WHERE change.obligation_id = obligation.obligation_id
1454
- AND ((consumer.account_id IS NULL
1455
- AND (change.xid, change.seq) > (consumer.cursor_xid, consumer.cursor_seq))
1456
- OR (consumer.account_id = change.account_id AND change.seq > consumer.cursor_seq)))` : "";
1457
- const candidates = await this.query(
1458
- `SELECT obligation_id, account_id FROM (
1459
- SELECT tombstone.obligation_id, tombstone.account_id, 0 AS resume, tombstone.attempts, tombstone.period_end,
1460
- CASE WHEN row_number() OVER (ORDER BY tombstone.attempts, tombstone.period_end, tombstone.obligation_id)
1461
- <= (($1::integer + 1) / 2) THEN 0 ELSE 1 END AS tier
1462
- FROM adcp_reporting_obligation_tombstones tombstone
1463
- WHERE tombstone.state = 'retiring' AND ($2::text IS NULL OR tombstone.account_id = $2)
1464
- AND (tombstone.next_attempt_at IS NULL OR tombstone.next_attempt_at <= clock_timestamp())
1465
- UNION ALL
1466
- (SELECT obligation.obligation_id, obligation.account_id, 1, 0, obligation.period_end, 0
1467
- FROM adcp_reporting_obligations obligation
1468
- WHERE ($2::text IS NULL OR obligation.account_id = $2)
1469
- AND obligation.period_end + ($3::integer * INTERVAL '1 day') <= clock_timestamp()
1470
- AND (obligation.lease_expires_at IS NULL OR obligation.lease_expires_at <= clock_timestamp())
1471
- AND COALESCE((SELECT MAX(revision.recorded_at) FROM adcp_reporting_revisions revision
1472
- WHERE revision.obligation_id = obligation.obligation_id), obligation.created_at)
1473
- + ($3::integer * INTERVAL '1 day') <= clock_timestamp()
1474
- AND COALESCE((SELECT MAX(adjustment.recorded_at) FROM adcp_reporting_adjustments adjustment
1475
- WHERE adjustment.obligation_id = obligation.obligation_id), '-infinity'::timestamptz)
1476
- + ($3::integer * INTERVAL '1 day') <= clock_timestamp()
1477
- AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1478
- WHERE tombstone.obligation_id = obligation.obligation_id)
1479
- ${materializationHold}
1480
- ${feedHold}
1481
- ORDER BY obligation.period_end, obligation.obligation_id
1482
- LIMIT $1)
1483
- ) candidate
1484
- ORDER BY tier, resume, attempts, period_end, obligation_id
1485
- LIMIT $1`,
1486
- feedHold ? [limit, input.account_id ?? null, recordDays, holdDays] : [limit, input.account_id ?? null, recordDays]
1487
- );
1488
- let retired = 0;
1489
- const failed = [];
1490
- const failures = [];
1491
- for (const candidate of candidates.rows) {
1492
- input.signal?.throwIfAborted();
1493
- try {
1494
- await this.retirePeriod(candidate.obligation_id, candidate.account_id, tables, input.signal);
1495
- retired += 1;
1496
- } catch (error) {
1497
- if (error instanceof ReportingLedgerPeriodRetiredError) continue;
1498
- const cause = isReportingRowStoreError(error) ? error.code : error instanceof Error ? error.name : "Error";
1499
- failed.push(candidate.obligation_id);
1500
- failures.push({ reporting_obligation_id: candidate.obligation_id, cause });
1501
- if (!input.signal?.aborted) await this.recordRetirementFailure(candidate.obligation_id, cause);
1502
- }
1503
- }
1504
- const scope = input.account_id ?? null;
1505
- const deferred = Number(
1506
- (await this.query(
1507
- `SELECT count(*) AS n FROM adcp_reporting_obligation_tombstones
1508
- WHERE state = 'retiring' AND next_attempt_at > clock_timestamp()
1509
- AND ($1::text IS NULL OR account_id = $1)`,
1510
- [scope]
1511
- )).rows[0].n
1512
- );
1513
- const stuck = (await this.query(
1514
- `SELECT obligation_id, attempts, last_failure FROM adcp_reporting_obligation_tombstones
1515
- WHERE state = 'retiring' AND attempts >= $2 AND ($1::text IS NULL OR account_id = $1)
1516
- ORDER BY attempts DESC, retiring_at, obligation_id LIMIT 20`,
1517
- [scope, REPORTING_PERSISTENT_FAILURE_ATTEMPTS]
1518
- )).rows.map((row) => ({
1519
- reporting_obligation_id: row.obligation_id,
1520
- attempts: row.attempts,
1521
- cause: row.last_failure ?? "Error"
1522
- }));
1523
- return { retired, failed, failures, deferred, stuck };
1524
- }
1525
- /**
1526
- * Record a failed retirement attempt on the period's tombstone and push its next
1527
- * attempt out by an exponential backoff. Best effort: a failure to record only
1528
- * means the period is retried on the next pass.
1529
- */
1530
- async recordRetirementFailure(obligationId, cause) {
1531
- try {
1532
- await this.query(
1533
- `UPDATE adcp_reporting_obligation_tombstones
1534
- SET attempts = attempts + 1, last_failure = $2,
1535
- next_attempt_at = clock_timestamp() + ${backoffIntervalSql("attempts + 1")}
1536
- WHERE obligation_id = $1 AND state = 'retiring'`,
1537
- [obligationId, cause]
1538
- );
1539
- } catch {
1540
- }
1541
- }
1542
- async retirePeriod(obligationId, accountId, tables, signal) {
1543
- const rowStorage = tables.has("adcp_reporting_row_sets");
1544
- await this.transaction(
1545
- async (client) => {
1546
- await client.query(
1547
- `INSERT INTO adcp_reporting_obligation_tombstones
1548
- (obligation_id, account_id, configuration_id, period_ordinal, period_start, period_end, state,
1549
- final_revision_id, final_content_sha256, revision_count, adjustment_count)
1550
- SELECT obligation.obligation_id, obligation.account_id, obligation.configuration_id,
1551
- obligation.period_ordinal, obligation.period_start, obligation.period_end, 'retiring',
1552
- latest.revision_id, latest.content_sha256,
1553
- (SELECT count(*) FROM adcp_reporting_revisions revision WHERE revision.obligation_id = obligation.obligation_id),
1554
- (SELECT count(*) FROM adcp_reporting_adjustments adjustment WHERE adjustment.obligation_id = obligation.obligation_id)
1555
- FROM adcp_reporting_obligations obligation
1556
- LEFT JOIN LATERAL (
1557
- SELECT revision.revision_id, revision.content_sha256 FROM adcp_reporting_revisions revision
1558
- WHERE revision.obligation_id = obligation.obligation_id
1559
- ORDER BY revision.revision_number DESC LIMIT 1
1560
- ) latest ON TRUE
1561
- WHERE obligation.obligation_id = $1
1562
- ON CONFLICT (obligation_id) DO NOTHING`,
1563
- [obligationId]
1564
- );
1565
- if (rowStorage) {
1566
- await client.query(
1567
- `UPDATE adcp_reporting_row_sets SET rows_state = 'pruning', rows_state_changed_at = clock_timestamp()
1568
- WHERE obligation_id = $1 AND rows_state IN ('live', 'unavailable')`,
1569
- [obligationId]
1570
- );
1571
- }
1572
- },
1573
- { preBeginAdvisoryLock: accountLock(accountId) }
1574
- );
1575
- if (rowStorage) {
1576
- const objects = await this.query(
1577
- `SELECT DISTINCT row_set.row_binding_id, chunk.object_ref, chunk.native_version_ref
1578
- FROM adcp_reporting_row_chunks chunk
1579
- JOIN adcp_reporting_row_sets row_set ON row_set.row_set_id = chunk.row_set_id
1580
- WHERE chunk.obligation_id = $1 AND chunk.object_ref IS NOT NULL`,
1581
- [obligationId]
1582
- );
1583
- const byBinding = /* @__PURE__ */ new Map();
1584
- for (const row of objects.rows) {
1585
- const list = byBinding.get(row.row_binding_id) ?? [];
1586
- list.push({ objectRef: row.object_ref, nativeVersionRef: row.native_version_ref });
1587
- byBinding.set(row.row_binding_id, list);
1588
- }
1589
- for (const [bindingId, list] of byBinding) {
1590
- await this.deleteUnreferencedObjects(bindingId, list, { obligationId }, signal);
1591
- }
1592
- }
1593
- await this.transaction(
1594
- async (client) => {
1595
- const revisionIds = `SELECT revision_id FROM adcp_reporting_revisions WHERE obligation_id = $1`;
1596
- if (rowStorage) {
1597
- await client.query(`DELETE FROM adcp_reporting_row_chunks WHERE obligation_id = $1`, [obligationId]);
1598
- await client.query(`DELETE FROM adcp_reporting_row_sets WHERE obligation_id = $1`, [obligationId]);
1599
- await client.query(`DELETE FROM adcp_reporting_chunk_bodies WHERE account_id = $2 AND obligation_id = $1`, [
1600
- obligationId,
1601
- accountId
1602
- ]);
1603
- }
1604
- await client.query(
1605
- `DELETE FROM adcp_reporting_consumer_statuses
1606
- WHERE obligation_id = $1 OR revision_id IN (${revisionIds})`,
1607
- [obligationId]
1608
- );
1609
- await client.query(`DELETE FROM adcp_reporting_adjustments WHERE obligation_id = $1`, [obligationId]);
1610
- await client.query(`DELETE FROM adcp_reporting_revisions WHERE obligation_id = $1`, [obligationId]);
1611
- await client.query(`DELETE FROM adcp_reporting_issues WHERE obligation_id = $1`, [obligationId]);
1612
- await client.query(`DELETE FROM adcp_reporting_transitions WHERE obligation_id = $1`, [obligationId]);
1613
- await client.query(`DELETE FROM adcp_reporting_lifecycle_state WHERE obligation_id = $1`, [obligationId]);
1614
- await client.query(`DELETE FROM adcp_reporting_obligations WHERE obligation_id = $1`, [obligationId]);
1615
- const retiredNow = await client.query(
1616
- `UPDATE adcp_reporting_obligation_tombstones SET state = 'retired', retired_at = clock_timestamp()
1617
- WHERE obligation_id = $1 AND state = 'retiring'`,
1618
- [obligationId]
1619
- );
1620
- if (retiredNow.rowCount === 1) {
1621
- await this.appendChange(client, "retirement", accountId, obligationId, obligationId);
1622
- }
1623
- },
1624
- { preBeginAdvisoryLock: accountLock(accountId) }
1625
- );
1626
- }
1627
- /**
1628
- * Compact superseded snapshot revisions to metadata only, as adcontextprotocol/adcp#8100
1629
- * permits (`reporting-revision.json` `content_retention`). Opt-in: nothing calls this
1630
- * unless the host enables `compaction` in maintenance, and it must not be enabled in
1631
- * production before the AdCP release that takes compaction into effect (the next
1632
- * eligible minor after November 20, 2026).
1633
- *
1634
- * A snapshot revision is compacted only when every one of these holds:
1635
- *
1636
- * - it is superseded: a later revision names it in `supersedes_reporting_revision_id`
1637
- * (so a current revision, and every official revision, is never compacted);
1638
- * - it is not the revision the current, unsuperseded revision names;
1639
- * - its recovery grace has passed: `max(automatedRecoveryWindowSeconds, 3600)` seconds
1640
- * since its successor's `created_at`;
1641
- * - while its window is open (`statusRetentionDays` since its successor's `created_at`),
1642
- * no caller's reporting receipt (any status) names it and no caller's current consumer
1643
- * status statement of `received`, `unreadable` or `content_mismatch` names it. The
1644
- * check spans every caller, so content any caller is entitled to is kept;
1645
- * - it has rows (nothing to reclaim otherwise, and an empty exact read is still correct);
1646
- * - no live obligation lease, no pending materialization of it, and no live change-feed
1647
- * consumer that has not yet passed its change row (the holds retirement applies,
1648
- * narrowed to this revision).
1649
- *
1650
- * Only row content is dropped: the revision header, its manifest and every metadata
1651
- * listing stay. The row set is marked `pruned`, so an exact read answers
1652
- * `ROWS_EXPIRED`, which the handlers surface as the same nondisclosing
1653
- * `REFERENCE_NOT_FOUND` as an unknown ID. No change-feed row is recorded: the revision's
1654
- * metadata is unchanged, and a sink still catching up holds compaction back.
1655
- *
1656
- * Compaction is resumable and runs in the same three phases as retirement, under the
1657
- * account lock where eligibility is decided:
1658
- *
1659
- * 1. re-check eligibility and mark the row set `pruning`, so reads answer as expired and
1660
- * no new revision can share its chunks;
1661
- * 2. delete external objects at their recorded versions, skipping any object another
1662
- * row set that is not being pruned still references (chunk sharing);
1663
- * 3. delete the chunk manifest rows and any PostgreSQL chunk body nothing else
1664
- * references, and mark the row set `pruned`.
1665
- *
1666
- * A crash after phase 1 leaves a `pruning` row set that the next pass resumes.
1667
- */
1668
- async compactSupersededSnapshots(input) {
1669
- this.assertCompactionSafeRowBindings();
1670
- positiveInteger(input.statusRetentionDays, "statusRetentionDays");
1671
- positiveInteger(input.automatedRecoveryWindowSeconds, "automatedRecoveryWindowSeconds");
1672
- const limit = input.limit ?? 100;
1673
- positiveInteger(limit, "limit");
1674
- const holdDays = input.maxFeedHoldDays ?? 7;
1675
- positiveInteger(holdDays, "maxFeedHoldDays");
1676
- const tables = await this.optionalTables();
1677
- if (!tables.has("adcp_reporting_row_sets")) return { compacted: 0, failed: [], failures: [] };
1678
- const graceSeconds = Math.max(input.automatedRecoveryWindowSeconds, 3600);
1679
- const eligible = `
1680
- FROM adcp_reporting_revisions revision
1681
- JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
1682
- JOIN adcp_reporting_row_sets row_set
1683
- ON row_set.row_set_id = revision.revision_id AND row_set.row_set_kind = 'revision'
1684
- JOIN LATERAL (
1685
- SELECT MAX(later.created_at) AS created_at FROM adcp_reporting_revisions later
1686
- WHERE later.supersedes_revision_id = revision.revision_id
1687
- ) successor ON successor.created_at IS NOT NULL
1688
- WHERE revision.finality = 'snapshot' AND revision.kind = 'snapshot'
1689
- AND ($4::text IS NULL OR obligation.account_id = $4)
1690
- AND ($5::text IS NULL OR revision.revision_id = $5)
1691
- AND $3::integer > 0
1692
- AND row_set.row_count > 0
1693
- AND row_set.rows_state IN ('live', 'unavailable')
1694
- AND successor.created_at + ($2::bigint * INTERVAL '1 second') <= clock_timestamp()
1695
- AND NOT EXISTS (
1696
- SELECT 1 FROM adcp_reporting_revisions current_revision
1697
- WHERE current_revision.supersedes_revision_id = revision.revision_id
1698
- AND NOT EXISTS (
1699
- SELECT 1 FROM adcp_reporting_revisions next_revision
1700
- WHERE next_revision.supersedes_revision_id = current_revision.revision_id
1701
- )
1702
- )
1703
- AND (obligation.lease_expires_at IS NULL OR obligation.lease_expires_at <= clock_timestamp())
1704
- AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1705
- WHERE tombstone.obligation_id = obligation.obligation_id)
1706
- AND (
1707
- successor.created_at + ($1::integer * INTERVAL '1 day') <= clock_timestamp()
1708
- OR (
1709
- NOT EXISTS (
1710
- SELECT 1 FROM adcp_reporting_consumer_statuses statement
1711
- WHERE statement.revision_id = revision.revision_id AND statement.is_current
1712
- AND COALESCE(statement.data->>'consumer_status', statement.data->'status'->>'consumer_status')
1713
- IN ('received', 'unreadable', 'content_mismatch')
1714
- )
1715
- ${tables.has("adcp_reporting_receipts") ? `AND NOT EXISTS (
1716
- SELECT 1 FROM adcp_reporting_receipts receipt
1717
- WHERE receipt.receipt_kind = 'revision' AND receipt.subject_id = revision.revision_id
1718
- )` : ""}
1719
- )
1720
- )
1721
- ${tables.has("adcp_reporting_materializations") ? `AND NOT EXISTS (
1722
- SELECT 1 FROM adcp_reporting_materializations hold
1723
- WHERE hold.revision_id = revision.revision_id AND hold.status = 'pending'
1724
- )` : ""}
1725
- ${tables.has("adcp_reporting_changes") && tables.has("adcp_reporting_feed_consumers") ? `AND NOT EXISTS (
1726
- SELECT 1 FROM adcp_reporting_changes change
1727
- JOIN adcp_reporting_feed_consumers consumer
1728
- ON consumer.updated_at > clock_timestamp() - ($3::integer * INTERVAL '1 day')
1729
- WHERE change.record_kind = 'revision' AND change.record_id = revision.revision_id
1730
- AND ((consumer.account_id IS NULL
1731
- AND (change.xid, change.seq) > (consumer.cursor_xid, consumer.cursor_seq))
1732
- OR (consumer.account_id = change.account_id AND change.seq > consumer.cursor_seq)))` : ""}`;
1733
- const baseValues = [input.statusRetentionDays, graceSeconds, holdDays, input.account_id ?? null];
1734
- const candidates = await this.query(
1735
- `SELECT revision_id, obligation_id, account_id FROM (
1736
- SELECT row_set.row_set_id AS revision_id, row_set.obligation_id, row_set.account_id, 0 AS resume,
1737
- row_set.recorded_at
1738
- FROM adcp_reporting_row_sets row_set
1739
- WHERE row_set.row_set_kind = 'revision' AND row_set.rows_state = 'pruning'
1740
- AND ($4::text IS NULL OR row_set.account_id = $4)
1741
- AND EXISTS (SELECT 1 FROM adcp_reporting_revisions revision
1742
- WHERE revision.revision_id = row_set.row_set_id)
1743
- AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1744
- WHERE tombstone.obligation_id = row_set.obligation_id)
1745
- UNION ALL
1746
- SELECT revision.revision_id, revision.obligation_id, obligation.account_id, 1, row_set.recorded_at
1747
- ${eligible}
1748
- ) candidate
1749
- ORDER BY resume, recorded_at, revision_id
1750
- LIMIT $6`,
1751
- [...baseValues, null, limit]
1752
- );
1753
- let compacted = 0;
1754
- const failed = [];
1755
- const failures = [];
1756
- for (const candidate of candidates.rows) {
1757
- input.signal?.throwIfAborted();
1758
- try {
1759
- const done = await this.compactSnapshot(
1760
- candidate.revision_id,
1761
- candidate.obligation_id,
1762
- candidate.account_id,
1763
- eligible,
1764
- baseValues,
1765
- input.signal
1766
- );
1767
- if (done) compacted += 1;
1768
- } catch (error) {
1769
- failed.push(candidate.revision_id);
1770
- failures.push({
1771
- reporting_obligation_id: candidate.obligation_id,
1772
- reporting_revision_id: candidate.revision_id,
1773
- cause: isReportingRowStoreError(error) ? error.code : error instanceof Error ? error.name : "Error"
1774
- });
1775
- }
1776
- }
1777
- return { compacted, failed, failures };
1778
- }
1779
- async compactSnapshot(revisionId, obligationId, accountId, eligible, baseValues, signal) {
1780
- const claimed = await this.transaction(
1781
- async (client) => {
1782
- const marked = await client.query(
1783
- `UPDATE adcp_reporting_row_sets SET rows_state = 'pruning', rows_state_changed_at = clock_timestamp()
1784
- WHERE row_set_id = $5 AND rows_state IN ('live', 'unavailable')
1785
- AND row_set_id IN (SELECT revision.revision_id ${eligible})`,
1786
- [...baseValues, revisionId]
1787
- );
1788
- if (marked.rowCount === 1) return true;
1789
- const resumed = await client.query(
1790
- `SELECT 1 FROM adcp_reporting_row_sets row_set
1791
- WHERE row_set.row_set_id = $1 AND row_set.rows_state = 'pruning'
1792
- AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1793
- WHERE tombstone.obligation_id = row_set.obligation_id)`,
1794
- [revisionId]
1795
- );
1796
- return resumed.rows.length === 1;
1797
- },
1798
- { preBeginAdvisoryLock: accountLock(accountId) }
1799
- );
1800
- if (!claimed) return false;
1801
- const binding = await this.query(
1802
- `SELECT row_binding_id FROM adcp_reporting_row_sets WHERE row_set_id = $1`,
1803
- [revisionId]
1804
- );
1805
- if (binding.rows.length === 0) return false;
1806
- const objects = await this.query(
1807
- `SELECT DISTINCT chunk.object_ref, chunk.native_version_ref
1808
- FROM adcp_reporting_row_chunks chunk
1809
- WHERE chunk.row_set_id = $1 AND chunk.object_ref IS NOT NULL`,
1810
- [revisionId]
1811
- );
1812
- if (objects.rows.length > 0) {
1813
- await this.deleteUnreferencedObjects(
1814
- binding.rows[0].row_binding_id,
1815
- objects.rows.map((row) => ({ objectRef: row.object_ref, nativeVersionRef: row.native_version_ref })),
1816
- { rowSetId: revisionId },
1817
- signal
1818
- );
1819
- }
1820
- await this.transaction(
1821
- async (client) => {
1822
- const dropped = await client.query(
1823
- `DELETE FROM adcp_reporting_row_chunks WHERE row_set_id = $1 RETURNING sha256, object_ref`,
1824
- [revisionId]
1825
- );
1826
- const bodies = [...new Set(dropped.rows.filter((row) => row.object_ref === null).map((row) => row.sha256))];
1827
- if (bodies.length > 0) {
1828
- await client.query(
1829
- `DELETE FROM adcp_reporting_chunk_bodies body
1830
- WHERE body.account_id = $1 AND body.obligation_id = $2 AND body.sha256 = ANY($3::text[])
1831
- AND NOT EXISTS (
1832
- SELECT 1 FROM adcp_reporting_row_chunks other
1833
- WHERE other.account_id = body.account_id AND other.obligation_id = body.obligation_id
1834
- AND other.sha256 = body.sha256 AND other.object_ref IS NULL
1835
- )`,
1836
- [accountId, obligationId, bodies]
1837
- );
1838
- }
1839
- await client.query(
1840
- `UPDATE adcp_reporting_row_sets SET rows_state = 'pruned', rows_state_changed_at = clock_timestamp()
1841
- WHERE row_set_id = $1 AND rows_state = 'pruning'`,
1842
- [revisionId]
1843
- );
1844
- },
1845
- { preBeginAdvisoryLock: accountLock(accountId) }
1846
- );
1847
- return true;
1848
- }
1849
- /**
1850
- * Resolve abandoned object uploads (spec §4.3). An expired `open` write
1851
- * intent whose ledger commit never happened is moved to `sweeping` under
1852
- * the account lock, so no writer can commit against it, then only the
1853
- * objects that upload created — never adopted ones, never any object a
1854
- * committed chunk references — are deleted at their recorded versions.
1855
- * Interrupted sweeps resume.
1856
- */
1857
- async sweepRowWriteIntents(input = {}) {
1858
- const limit = input.limit ?? 100;
1859
- positiveInteger(limit, "limit");
1860
- const overdueAfter = input.overdueAfterSeconds ?? 24 * 3600;
1861
- positiveInteger(overdueAfter, "overdueAfterSeconds");
1862
- const tables = await this.optionalTables();
1863
- if (!tables.has("adcp_reporting_row_write_intents")) {
1864
- return { swept: 0, objectsDeleted: 0, failed: 0, skippedUnconfigured: 0, deferred: 0, overdue: 0 };
1865
- }
1866
- const configured = this.rowStorage.objectBindingIds();
1867
- const due = `($1::text IS NULL OR account_id = $1) AND (state = 'sweeping' OR expires_at <= clock_timestamp())`;
1868
- const expired = await this.query(
1869
- `SELECT row_binding_id, account_id, row_set_id, content_sha256 FROM adcp_reporting_row_write_intents
1870
- WHERE ${due}
1871
- AND (next_sweep_at IS NULL OR next_sweep_at <= clock_timestamp())
1872
- AND (row_binding_id = ANY($2::text[]) OR created_objects = '[]'::jsonb)
1873
- ORDER BY sweep_attempts, expires_at LIMIT $3`,
1874
- [input.account_id ?? null, configured, limit]
1875
- );
1876
- let swept = 0;
1877
- let objectsDeleted = 0;
1878
- let failed = 0;
1879
- for (const intent of expired.rows) {
1880
- input.signal?.throwIfAborted();
1881
- const key = [intent.row_binding_id, intent.row_set_id, intent.content_sha256];
1882
- try {
1883
- const claimed = await this.transaction(
1884
- async (client) => {
1885
- const updated = await client.query(
1886
- `UPDATE adcp_reporting_row_write_intents SET state = 'sweeping'
1887
- WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3
1888
- AND (state = 'sweeping' OR expires_at <= clock_timestamp())
1889
- RETURNING created_objects`,
1890
- key
1891
- );
1892
- return updated.rows[0]?.created_objects.map((object) => ({
1893
- objectRef: object.object_ref ?? object.objectKey,
1894
- nativeVersionRef: object.native_version_ref ?? object.nativeVersion
1895
- })).filter(
1896
- (object) => typeof object.objectRef === "string" && typeof object.nativeVersionRef === "string"
1897
- );
1898
- },
1899
- { preBeginAdvisoryLock: accountLock(intent.account_id) }
1900
- );
1901
- if (!claimed) continue;
1902
- const deleted = await this.deleteUnreferencedObjects(intent.row_binding_id, claimed, {}, input.signal);
1903
- await this.query(
1904
- `DELETE FROM adcp_reporting_row_write_intents
1905
- WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3 AND state = 'sweeping'`,
1906
- key
1907
- );
1908
- swept += 1;
1909
- objectsDeleted += deleted;
1910
- } catch {
1911
- failed += 1;
1912
- if (!input.signal?.aborted) {
1913
- await this.query(
1914
- `UPDATE adcp_reporting_row_write_intents
1915
- SET sweep_attempts = sweep_attempts + 1,
1916
- next_sweep_at = clock_timestamp() + ${backoffIntervalSql("sweep_attempts + 1")}
1917
- WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3`,
1918
- key
1919
- ).catch(() => void 0);
1920
- }
1921
- }
1922
- }
1923
- const backlog = (await this.query(
1924
- `SELECT count(*) FILTER (WHERE row_binding_id <> ALL($2::text[]) AND created_objects <> '[]'::jsonb) AS skipped,
1925
- count(*) FILTER (WHERE (row_binding_id = ANY($2::text[]) OR created_objects = '[]'::jsonb)
1926
- AND next_sweep_at > clock_timestamp()) AS deferred,
1927
- count(*) FILTER (WHERE expires_at <= clock_timestamp() - ($3::integer * INTERVAL '1 second')) AS overdue
1928
- FROM adcp_reporting_row_write_intents
1929
- WHERE ${due}`,
1930
- [input.account_id ?? null, configured, overdueAfter]
1931
- )).rows[0];
1932
- return {
1933
- swept,
1934
- objectsDeleted,
1935
- failed,
1936
- skippedUnconfigured: Number(backlog.skipped),
1937
- deferred: Number(backlog.deferred),
1938
- overdue: Number(backlog.overdue)
1939
- };
1940
- }
1941
- /**
1942
- * Delete external row objects, skipping any object another row set still references.
1943
- *
1944
- * Object keys are unique per row set (every writable template includes `{revision_key}`), but header-only
1945
- * revisions share chunks within an obligation and rows written under an older template can name one
1946
- * object from several row sets. An object is therefore deleted only when no
1947
- * chunk of a live (or unavailable) row set outside this operation references the same binding,
1948
- * key and native version, and no other open write intent created it. Row sets already `pruning`
1949
- * or `pruned` do not count: their own operation deletes the object, and counting them would let
1950
- * two concurrent retirements each skip the object and leak it. `scope` names what this
1951
- * operation is deleting: an obligation (retirement) or a row set (compaction).
1952
- *
1953
- * The check and the provider delete run in one transaction under a per-object advisory lock.
1954
- * A commit that adopts an existing object takes the same lock before it inserts its chunk rows
1955
- * and re-verifies the object under it (`writePreparedRowSet`), and holds it until commit. So
1956
- * either the delete wins the lock and the adopting commit then fails cleanly, or the commit wins
1957
- * and the delete sees its chunk rows and skips. A skipped object stays for the row set that owns
1958
- * it: that row set's own retirement, compaction or sweep deletes it, because by then nothing
1959
- * else references it. Locks are taken one object at a time, never while waiting on the account
1960
- * lock, so commits (account lock, then object locks) cannot deadlock with these deletes.
1961
- */
1962
- async deleteUnreferencedObjects(bindingId, objects, scope, signal) {
1963
- let deleted = 0;
1964
- for (const object of objects) {
1965
- signal?.throwIfAborted();
1966
- const removed = await this.transaction(async (client) => {
1967
- await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
1968
- objectLock(bindingId, object.objectRef)
1969
- ]);
1970
- const referenced = await client.query(
1971
- `(SELECT 1 FROM adcp_reporting_row_chunks chunk
1972
- JOIN adcp_reporting_row_sets row_set ON row_set.row_set_id = chunk.row_set_id
1973
- WHERE row_set.row_binding_id = $1 AND chunk.object_ref = $2 AND chunk.native_version_ref = $3
1974
- AND row_set.rows_state IN ('live', 'unavailable')
1975
- AND ($4::text IS NULL OR chunk.obligation_id <> $4)
1976
- AND ($5::text IS NULL OR chunk.row_set_id <> $5)
1977
- LIMIT 1)
1978
- UNION ALL
1979
- (SELECT 1 FROM adcp_reporting_row_write_intents intent
1980
- WHERE intent.row_binding_id = $1 AND intent.state = 'open'
1981
- AND (intent.created_objects @> $6::jsonb OR intent.created_objects @> $7::jsonb)
1982
- LIMIT 1)`,
1983
- [
1984
- bindingId,
1985
- object.objectRef,
1986
- object.nativeVersionRef,
1987
- scope.obligationId ?? null,
1988
- scope.rowSetId ?? null,
1989
- JSON.stringify([{ object_ref: object.objectRef, native_version_ref: object.nativeVersionRef }]),
1990
- // An older writer still running during a rolling upgrade records the pre-alignment keys.
1991
- JSON.stringify([{ objectKey: object.objectRef, nativeVersion: object.nativeVersionRef }])
1992
- ]
1993
- );
1994
- if (referenced.rows.length > 0) return false;
1995
- await this.rowStorage.deleteObjects(bindingId, [object], signal);
1996
- return true;
1997
- });
1998
- if (removed) deleted += 1;
1999
- }
2000
- return deleted;
2001
- }
2002
- async optionalTables() {
2003
- const result = await this.query(
2004
- `SELECT table_name AS name FROM information_schema.tables
2005
- WHERE table_schema = current_schema()
2006
- AND table_name IN ('adcp_reporting_row_sets', 'adcp_reporting_row_write_intents',
2007
- 'adcp_reporting_materializations', 'adcp_reporting_changes',
2008
- 'adcp_reporting_feed_consumers', 'adcp_reporting_receipts')`
2009
- );
2010
- return new Set(result.rows.map((row) => row.name));
2011
- }
2012
- /**
2013
- * Upload an object-kind row set before its ledger commit (spec §3.5):
2014
- * skip when the document already exists (replay), refuse unless the lease
2015
- * still covers the upload deadline, open or take over an expired write
2016
- * intent under the account lock, then write every chunk create-only,
2017
- * recording each object this upload created in the intent first.
2018
- */
2019
- async uploadRowSetUnlessCommitted(prepared, lease, table, idColumn) {
2020
- const { input } = prepared;
2021
- const committed = await this.query(`SELECT 1 FROM ${table} WHERE ${idColumn} = $1`, [input.rowSetId]);
2022
- if (committed.rows.length > 0) return void 0;
2023
- const shared = await this.rowStorage.findShareable(this.pool, prepared);
2024
- if (shared) return { locators: shared.locators, sharedFrom: shared.rowSetId };
2025
- const ownerToken = reportingRowWriteIntentIdV1();
2026
- const budget = this.rowStorage.writeDeadlineMilliseconds;
2027
- const intentKey = [prepared.bindingId, input.rowSetId, input.binding.sha256];
2028
- await this.transaction(
2029
- async (client) => {
2030
- const opened = await client.query(
2031
- `INSERT INTO adcp_reporting_row_write_intents
2032
- (row_binding_id, account_id, row_set_id, content_sha256, state, owner_token, expires_at)
2033
- SELECT $1, $2, $3, $4, 'open', $5, obligation.lease_expires_at + ($6::bigint * INTERVAL '1 millisecond')
2034
- FROM adcp_reporting_obligations obligation
2035
- WHERE obligation.obligation_id = $7 AND obligation.lease_owner = $8
2036
- AND obligation.lease_generation = $9
2037
- AND obligation.lease_expires_at - clock_timestamp() >= (($6::bigint + 5000) * INTERVAL '1 millisecond')
2038
- ON CONFLICT (row_binding_id, row_set_id, content_sha256) DO UPDATE
2039
- SET owner_token = EXCLUDED.owner_token, expires_at = EXCLUDED.expires_at
2040
- WHERE adcp_reporting_row_write_intents.state = 'open'
2041
- AND adcp_reporting_row_write_intents.expires_at <= clock_timestamp()
2042
- RETURNING owner_token`,
2043
- [
2044
- prepared.bindingId,
2045
- input.accountId,
2046
- input.rowSetId,
2047
- input.binding.sha256,
2048
- ownerToken,
2049
- budget,
2050
- input.obligationId,
2051
- lease.owner,
2052
- lease.generation
2053
- ]
2054
- );
2055
- if (opened.rows.length === 1) return;
2056
- const held = await client.query(
2057
- `SELECT lease_expires_at - clock_timestamp() >= (($4::bigint + 5000) * INTERVAL '1 millisecond') AS budget
2058
- FROM adcp_reporting_obligations
2059
- WHERE obligation_id = $1 AND lease_owner = $2 AND lease_generation = $3
2060
- AND lease_expires_at > clock_timestamp()`,
2061
- [input.obligationId, lease.owner, lease.generation, budget]
2062
- );
2063
- if (held.rows.length === 0) throw new ReportingLedgerLeaseLostError();
2064
- if (!held.rows[0].budget) {
2065
- throw new ReportingRowStoreError("DEADLINE_EXCEEDED", "the lease does not cover the row upload deadline");
2066
- }
2067
- throw new ReportingRowStoreError("STATE_UNAVAILABLE", "another writer holds this row upload");
2068
- },
2069
- { preBeginAdvisoryLock: accountLock(input.accountId) }
2070
- );
2071
- const locators = await this.rowStorage.upload(prepared, ownerToken, async (object) => {
2072
- const recorded = await this.query(
2073
- `UPDATE adcp_reporting_row_write_intents
2074
- SET created_objects = created_objects || $5::jsonb
2075
- WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3
2076
- AND owner_token = $4 AND state = 'open'`,
2077
- [
2078
- ...intentKey,
2079
- ownerToken,
2080
- JSON.stringify([{ object_ref: object.objectRef, native_version_ref: object.nativeVersionRef }])
2081
- ]
2082
- );
2083
- if (recorded.rowCount !== 1) {
2084
- throw new ReportingRowStoreError("STATE_UNAVAILABLE", "the row upload intent was taken over or swept");
2085
- }
2086
- });
2087
- return { locators, ownerToken };
2088
- }
2089
- /** Persist a prepared row set and close its upload intent in the ledger commit transaction. */
2090
- async writePreparedRowSet(client, prepared, upload) {
2091
- if (prepared.binding.kind === "postgres") {
2092
- const shared = await this.rowStorage.findShareable(client, prepared);
2093
- await this.rowStorage.writeInTransaction(client, prepared, void 0, shared?.rowSetId);
2094
- return;
2095
- }
2096
- if (!upload) throw new ReportingRowStoreError("STATE_UNAVAILABLE", "object rows were not uploaded");
2097
- if (upload.sharedFrom) {
2098
- const live = await client.query(
2099
- `SELECT 1 FROM adcp_reporting_row_sets WHERE row_set_id = $1 AND rows_state = 'live' FOR SHARE`,
2100
- [upload.sharedFrom]
2101
- );
2102
- if (live.rows.length !== 1) {
2103
- throw new ReportingRowStoreError("STATE_UNAVAILABLE", "the shared row set is no longer live");
2104
- }
2105
- } else {
2106
- const closed = await client.query(
2107
- `DELETE FROM adcp_reporting_row_write_intents
2108
- WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3
2109
- AND owner_token = $4 AND state = 'open'`,
2110
- [prepared.bindingId, prepared.input.rowSetId, prepared.input.binding.sha256, upload.ownerToken]
2111
- );
2112
- if (closed.rowCount !== 1) {
2113
- throw new ReportingRowStoreError("STATE_UNAVAILABLE", "the row upload intent was taken over or swept");
2114
- }
2115
- }
2116
- if (!upload.sharedFrom) {
2117
- const adopted = upload.locators.filter((locator) => !locator.created).sort((left, right) => left.objectRef < right.objectRef ? -1 : left.objectRef > right.objectRef ? 1 : 0);
2118
- for (const locator of adopted) {
2119
- await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
2120
- objectLock(prepared.bindingId, locator.objectRef)
2121
- ]);
2122
- }
2123
- if (adopted.length > 0) await this.rowStorage.assertObjectsPresent(prepared.bindingId, adopted);
2124
- }
2125
- await this.rowStorage.writeInTransaction(client, prepared, upload.locators, upload.sharedFrom);
2126
- }
2127
- /** Attach verified rows to a revision document whose rows live in row storage. */
2128
- async hydrateRevision(revision, accountId) {
2129
- if (Array.isArray(revision.rows)) return revision;
2130
- const handle = await this.rowStorage.find(revision.reporting_revision_id, accountId);
2131
- if (!handle) throw new ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "revision rows are not recorded");
2132
- if (handle.contentSha256 !== revision.binding.sha256 || handle.rowCount !== revision.binding.rowCount) {
2133
- throw new ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "row set does not match the revision binding");
2134
- }
2135
- return { ...revision, rows: await this.rowStorage.readAll(handle, revision.wireRevision.control_totals) };
2136
- }
2137
- async listAdjustmentMetadata(obligationId) {
2138
- const result = await this.query(
2139
- `SELECT data - 'rows' AS data FROM adcp_reporting_adjustments WHERE obligation_id = $1
2140
- ORDER BY adjustment_number, adjustment_id`,
2141
- [obligationId]
2142
- );
2143
- return result.rows.map((row) => clone(row.data));
2144
- }
2145
- async putConsumerStatus(status) {
2146
- const obligation = await this.getObligation(status.reporting_obligation_id);
2147
- if (!obligation) throw new Error("Reporting consumer status obligation is unavailable");
2148
- return this.putImmutable(
2149
- `INSERT INTO adcp_reporting_consumer_statuses
2150
- (account_id, consumer_id, consumer_status_id, chain_key, revision_id, obligation_id,
2151
- supersedes_consumer_status_id, is_current, semantic_fingerprint, data, created_at)
2152
- SELECT $1, '__legacy_unscoped_consumer__', $2, $3, $4, $5, $6, true, $7, $8::jsonb, $9
2153
- WHERE NOT EXISTS (
2154
- SELECT 1 FROM adcp_reporting_consumer_statuses
2155
- WHERE consumer_id = '__legacy_unscoped_consumer__' AND consumer_status_id = $2
2156
- )
2157
- ON CONFLICT DO NOTHING RETURNING data`,
2158
- [
2159
- obligation.account.account_id,
2160
- status.consumerStatusId,
2161
- `legacy:${status.consumerStatusId}`,
2162
- status.reporting_revision_id,
2163
- status.reporting_obligation_id,
2164
- status.supersedesConsumerStatusId ?? null,
2165
- digest(status),
2166
- JSON.stringify(status),
2167
- status.createdAt
2168
- ],
2169
- `SELECT data FROM adcp_reporting_consumer_statuses
2170
- WHERE consumer_id = '__legacy_unscoped_consumer__' AND consumer_status_id = $1
2171
- ORDER BY created_at LIMIT 1`,
2172
- [status.consumerStatusId],
2173
- status,
2174
- (value) => digest(value),
2175
- `adcp-reporting-legacy-consumer-status:${status.consumerStatusId}`
2176
- );
2177
- }
2178
- async getConsumerStatusBatchReplay(input) {
2179
- const batch = await this.query(
2180
- `SELECT request_fingerprint, results FROM adcp_reporting_consumer_status_batches
2181
- WHERE account_id = $1 AND consumer_id = $2 AND idempotency_key = $3`,
2182
- [input.account_id, input.consumerId, input.idempotencyKey]
2183
- );
2184
- if (!batch.rows[0]) return null;
2185
- if (batch.rows[0].request_fingerprint !== input.requestFingerprint) {
2186
- throw new ReportingConsumerStatusConflictError("Reporting status idempotency key was reused");
2187
- }
2188
- const replay = [];
2189
- for (const result of batch.rows[0].results) {
2190
- if (result.kind === "failed") {
2191
- replay.push({
2192
- inserted: false,
2193
- reporting_status_id: result.id,
2194
- errorCode: result.errorCode ?? "VALIDATION_ERROR",
2195
- recovery: result.recovery,
2196
- retryAfterSeconds: result.retryAfterSeconds,
2197
- safeMessage: boundedConsumerStatusSafeMessage(result.safeMessage ?? "Reporting consumer status was rejected"),
2198
- ...boundedConsumerStatusErrorField(result.errorField),
2199
- ...boundedConsumerStatusErrorKeyword(result.errorKeyword)
2200
- });
2201
- continue;
2202
- }
2203
- const row = await this.query(
2204
- `SELECT data FROM adcp_reporting_consumer_statuses
2205
- WHERE consumer_status_id = $1 AND account_id = $2 AND consumer_id = $3`,
2206
- [result.id, input.account_id, input.consumerId]
2207
- );
2208
- if (!row.rows[0]) throw new ReportingConsumerStatusConflictError("Reporting status replay is unavailable");
2209
- replay.push({ inserted: result.kind === "recorded", value: clone(row.rows[0].data) });
2210
- }
2211
- return replay;
2212
- }
2213
- async syncConsumerStatusBatch(input) {
2214
- return this.transaction(
2215
- async (client) => {
2216
- const priorBatch = await client.query(
2217
- `SELECT request_fingerprint, results FROM adcp_reporting_consumer_status_batches
2218
- WHERE account_id = $1 AND consumer_id = $2 AND idempotency_key = $3`,
2219
- [input.account_id, input.consumerId, input.idempotencyKey]
2220
- );
2221
- if (priorBatch.rows[0]) {
2222
- if (priorBatch.rows[0].request_fingerprint !== input.requestFingerprint) {
2223
- throw new ReportingConsumerStatusConflictError("Reporting status idempotency key was reused");
2224
- }
2225
- const replay = [];
2226
- for (const result of priorBatch.rows[0].results) {
2227
- if (result.kind === "failed") {
2228
- replay.push({
2229
- inserted: false,
2230
- reporting_status_id: result.id,
2231
- errorCode: result.errorCode ?? "VALIDATION_ERROR",
2232
- recovery: result.recovery,
2233
- retryAfterSeconds: result.retryAfterSeconds,
2234
- safeMessage: boundedConsumerStatusSafeMessage(
2235
- result.safeMessage ?? "Reporting consumer status was rejected"
2236
- ),
2237
- ...boundedConsumerStatusErrorField(result.errorField),
2238
- ...boundedConsumerStatusErrorKeyword(result.errorKeyword)
2239
- });
2240
- continue;
2241
- }
2242
- const row = await client.query(
2243
- `SELECT data FROM adcp_reporting_consumer_statuses
2244
- WHERE consumer_status_id = $1 AND account_id = $2 AND consumer_id = $3`,
2245
- [result.id, input.account_id, input.consumerId]
2246
- );
2247
- if (!row.rows[0]) throw new ReportingConsumerStatusConflictError("Reporting status replay is unavailable");
2248
- replay.push({
2249
- inserted: input.replayOriginalResults !== false && result.kind === "recorded",
2250
- value: clone(row.rows[0].data)
2251
- });
2252
- }
2253
- return replay;
2254
- }
2255
- const normalizedIds = normalizeReportingConsumerStatusIdsV1(input.entries);
2256
- const chainKeys = input.entries.map(
2257
- (entry) => "status" in entry ? reportingConsumerStatusChainKeyV1(entry.status) : entry.chainIdentity ? reportingConsumerStatusChainKeyFromIdentityV1(entry.chainIdentity) : null
2258
- );
2259
- const duplicateChains = new Set(
2260
- chainKeys.filter(
2261
- (value, index) => value !== null && (chainKeys.indexOf(value) !== index || chainKeys.lastIndexOf(value) !== index)
2262
- )
2263
- );
2264
- const duplicateStatusIds = new Set(
2265
- normalizedIds.values.filter((_, index) => !normalizedIds.invalidIndexes.has(index)).filter((value, index, values) => values.indexOf(value) !== index || values.lastIndexOf(value) !== index)
2266
- );
2267
- const capacity = await client.query(
2268
- `SELECT
2269
- (SELECT COUNT(*) FROM adcp_reporting_consumer_status_batches
2270
- WHERE account_id = $1 AND consumer_id = $2)::text AS batches,
2271
- (SELECT COUNT(*) FROM adcp_reporting_consumer_statuses
2272
- WHERE account_id = $1 AND consumer_id = $2)::text AS statuses`,
2273
- [input.account_id, input.consumerId]
2274
- );
2275
- const batchCapacityExhausted = Number(capacity.rows[0]?.batches ?? 0) >= MAX_CONSUMER_STATUS_BATCHES;
2276
- let remainingStatements = MAX_CONSUMER_STATUS_STATEMENTS - Number(capacity.rows[0]?.statuses ?? 0);
2277
- const results = [];
2278
- const storedResults = [];
2279
- const fail = (statusId, errorCode, safeMessage, errorField, recovery = getErrorRecovery(errorCode) ?? DEFAULT_UNKNOWN_ERROR_RECOVERY, errorKeyword, retryAfterSeconds) => {
2280
- const boundedErrorField = boundedConsumerStatusErrorField(errorField);
2281
- const boundedErrorKeyword = boundedConsumerStatusErrorKeyword(errorKeyword);
2282
- const boundedSafeMessage = boundedConsumerStatusSafeMessage(safeMessage);
2283
- results.push({
2284
- inserted: false,
2285
- reporting_status_id: statusId,
2286
- errorCode,
2287
- recovery,
2288
- retryAfterSeconds,
2289
- safeMessage: boundedSafeMessage,
2290
- ...boundedErrorField,
2291
- ...boundedErrorKeyword
2292
- });
2293
- storedResults.push({
2294
- kind: "failed",
2295
- id: statusId,
2296
- errorCode,
2297
- recovery,
2298
- retryAfterSeconds,
2299
- safeMessage: boundedSafeMessage,
2300
- ...boundedErrorField,
2301
- ...boundedErrorKeyword
2302
- });
2303
- };
2304
- for (const [index, entry] of input.entries.entries()) {
2305
- const statusId = normalizedIds.values[index];
2306
- if (normalizedIds.invalidIndexes.has(index)) {
2307
- fail(statusId, "VALIDATION_ERROR", "reporting_status_id is invalid", void 0, "correctable", "pattern");
2308
- continue;
2309
- }
2310
- if (duplicateStatusIds.has(statusId)) {
2311
- fail(statusId, "VALIDATION_ERROR", "reporting_status_id must be unique in a batch");
2312
- continue;
2313
- }
2314
- if (!("status" in entry)) {
2315
- fail(
2316
- statusId,
2317
- "VALIDATION_ERROR",
2318
- entry.validationError,
2319
- entry.validationField,
2320
- "correctable",
2321
- entry.validationKeyword
2322
- );
2323
- continue;
2324
- }
2325
- const status = entry.status;
2326
- if (duplicateChains.has(chainKeys[index])) {
2327
- fail(status.reporting_status_id, "VALIDATION_ERROR", "A batch may update each status chain only once");
2328
- continue;
2329
- }
2330
- if (status.account_id !== input.account_id || status.consumerId !== input.consumerId) {
2331
- fail(status.reporting_status_id, "PERMISSION_DENIED", "Reporting status scope is unavailable");
2332
- continue;
2333
- }
2334
- const fingerprint = reportingConsumerStatusFingerprintV1(status);
2335
- const existing = await client.query(
2336
- `SELECT data, semantic_fingerprint, account_id, consumer_id FROM adcp_reporting_consumer_statuses
2337
- WHERE consumer_status_id = $1 AND account_id = $2 AND consumer_id = $3 FOR UPDATE`,
2338
- [status.reporting_status_id, input.account_id, input.consumerId]
2339
- );
2340
- if (existing.rows[0]) {
2341
- if (existing.rows[0].semantic_fingerprint !== fingerprint) {
2342
- fail(status.reporting_status_id, "IDEMPOTENCY_CONFLICT", "reporting_status_id is unavailable");
2343
- continue;
2344
- }
2345
- results.push({ inserted: false, value: clone(existing.rows[0].data) });
2346
- storedResults.push({ kind: "unchanged", id: status.reporting_status_id });
2347
- continue;
2348
- }
2349
- const prevalidation = entry.validationError;
2350
- if (prevalidation) {
2351
- fail(
2352
- status.reporting_status_id,
2353
- "VALIDATION_ERROR",
2354
- prevalidation,
2355
- entry.validationField,
2356
- "correctable",
2357
- entry.validationKeyword
2358
- );
2359
- continue;
2360
- }
2361
- if (Buffer.byteLength(JSON.stringify(status), "utf8") > REPORTING_CONSUMER_STATUS_MAX_BYTES) {
2362
- fail(
2363
- status.reporting_status_id,
2364
- "REPORTING_STATUS_TOO_LARGE",
2365
- "Reporting consumer status exceeds 64 KiB",
2366
- void 0,
2367
- "correctable"
2368
- );
2369
- continue;
2370
- }
2371
- if (batchCapacityExhausted || remainingStatements <= 0) {
2372
- fail(
2373
- status.reporting_status_id,
2374
- "REPORTING_STATUS_CAPACITY_EXHAUSTED",
2375
- "Reporting consumer status capacity is exhausted",
2376
- void 0,
2377
- "terminal"
2378
- );
2379
- continue;
2380
- }
2381
- const chainKey = chainKeys[index];
2382
- const current = await client.query(
2383
- `SELECT data FROM adcp_reporting_consumer_statuses
2384
- WHERE account_id = $1 AND consumer_id = $2 AND chain_key = $3 AND is_current
2385
- FOR UPDATE`,
2386
- [input.account_id, input.consumerId, chainKey]
2387
- );
2388
- const leaf = current.rows[0]?.data;
2389
- if ((leaf?.reporting_status_id ?? void 0) !== status.supersedes_reporting_status_id) {
2390
- fail(status.reporting_status_id, "IDEMPOTENCY_CONFLICT", "Status must supersede the exact current leaf");
2391
- continue;
2392
- }
2393
- if (leaf && compareReportingInstants(status.status_as_of, leaf.status_as_of) < 0) {
2394
- fail(status.reporting_status_id, "VALIDATION_ERROR", "Status time cannot regress");
2395
- continue;
2396
- }
2397
- if (leaf) {
2398
- await client.query(
2399
- `UPDATE adcp_reporting_consumer_statuses SET is_current = false
2400
- WHERE consumer_status_id = $1 AND account_id = $2 AND consumer_id = $3`,
2401
- [leaf.reporting_status_id, input.account_id, input.consumerId]
2402
- );
2403
- }
2404
- const recorded = await client.query(
2405
- `SELECT GREATEST(
2406
- clock_timestamp(),
2407
- COALESCE((
2408
- SELECT MAX(ledger_as_of) + INTERVAL '1 millisecond'
2409
- FROM adcp_reporting_checkpoints
2410
- WHERE account_id = $1
2411
- ), '-infinity'::timestamptz)
2412
- ) AS recorded_at`,
2413
- [input.account_id]
2414
- );
2415
- const value = {
2416
- ...status,
2417
- recorded_at: recorded.rows[0].recorded_at.toISOString()
2418
- };
2419
- await client.query(
2420
- `INSERT INTO adcp_reporting_consumer_statuses
2421
- (consumer_status_id, account_id, consumer_id, chain_key, revision_id, obligation_id,
2422
- supersedes_consumer_status_id, is_current, semantic_fingerprint, data, created_at, recorded_at)
2423
- VALUES ($1, $2, $3, $4, $5, $6, $7, true, $8, $9::jsonb, $10, $10)`,
2424
- [
2425
- status.reporting_status_id,
2426
- input.account_id,
2427
- input.consumerId,
2428
- chainKey,
2429
- status.reporting_revision_id ?? null,
2430
- status.reporting_obligation_id ?? null,
2431
- status.supersedes_reporting_status_id ?? null,
2432
- fingerprint,
2433
- JSON.stringify(value),
2434
- value.recorded_at
2435
- ]
2436
- );
2437
- results.push({ inserted: true, value: clone(value) });
2438
- storedResults.push({ kind: "recorded", id: status.reporting_status_id });
2439
- remainingStatements -= 1;
2440
- }
2441
- boundStoredConsumerStatusResults(storedResults, results);
2442
- if (!batchCapacityExhausted) {
2443
- await client.query(
2444
- `INSERT INTO adcp_reporting_consumer_status_batches
2445
- (account_id, consumer_id, idempotency_key, request_fingerprint, status_ids, results)
2446
- VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)`,
2447
- [
2448
- input.account_id,
2449
- input.consumerId,
2450
- input.idempotencyKey,
2451
- input.requestFingerprint,
2452
- JSON.stringify(
2453
- results.map((value) => "value" in value ? value.value.reporting_status_id : value.reporting_status_id)
2454
- ),
2455
- JSON.stringify(storedResults)
2456
- ]
2457
- );
2458
- }
2459
- return results;
2460
- },
2461
- { preBeginAdvisoryLock: accountLock(input.account_id) }
2462
- );
2463
- }
2464
- async listConsumerStatuses(revisionId) {
2465
- const result = await this.query(
2466
- `SELECT data FROM adcp_reporting_consumer_statuses WHERE revision_id = $1
2467
- AND consumer_id = '__legacy_unscoped_consumer__'
2468
- ORDER BY recorded_at, consumer_status_id`,
2469
- [revisionId]
2470
- );
2471
- return result.rows.map((row) => clone(row.data));
2472
- }
2473
- async putIssue(issue) {
2474
- const lock = await this.accountLockForObligation(issue.reporting_obligation_id);
2475
- await this.transaction(
2476
- async (client) => {
2477
- const result = await client.query(
2478
- `INSERT INTO adcp_reporting_issues (issue_id, obligation_id, data, observed_at, resolved_at)
2479
- VALUES ($1, $2, $3::jsonb, $4, $5)
2480
- -- opened_at is fixed at first emission; see recordIssue.
2481
- ON CONFLICT (issue_id) DO UPDATE SET
2482
- data = jsonb_set(EXCLUDED.data, '{openedAt}', COALESCE(adcp_reporting_issues.data -> 'openedAt', EXCLUDED.data -> 'openedAt')),
2483
- observed_at = EXCLUDED.observed_at,
2484
- resolved_at = EXCLUDED.resolved_at,
2485
- changed_at = clock_timestamp()
2486
- WHERE adcp_reporting_issues.obligation_id = EXCLUDED.obligation_id`,
2487
- [
2488
- issue.issueId,
2489
- issue.reporting_obligation_id,
2490
- JSON.stringify(issue),
2491
- issue.observedAt,
2492
- issue.resolvedAt ?? null
2493
- ]
2494
- );
2495
- if (result.rowCount !== 1) throw new Error("Reporting issue identity belongs to another obligation");
2496
- },
2497
- { preBeginAdvisoryLock: lock }
2498
- );
2499
- }
2500
- async resolveIssue(issueId, resolvedAt) {
2501
- const lock = await this.accountLockForIssue(issueId);
2502
- if (!lock) return;
2503
- await this.transaction(
2504
- async (client) => {
2505
- await client.query(
2506
- `UPDATE adcp_reporting_issues
2507
- SET resolved_at = $2::text::timestamptz,
2508
- data = data || jsonb_build_object('resolvedAt', $2::text),
2509
- changed_at = clock_timestamp()
2510
- WHERE issue_id = $1 AND resolved_at IS NULL`,
2511
- [issueId, resolvedAt]
2512
- );
2513
- },
2514
- { preBeginAdvisoryLock: lock }
2515
- );
2516
- }
2517
- async listIssues(obligationId) {
2518
- const result = await this.query(
2519
- `SELECT data FROM adcp_reporting_issues WHERE obligation_id = $1
2520
- ORDER BY observed_at, issue_id`,
2521
- [obligationId]
2522
- );
2523
- return result.rows.map((row) => clone(row.data));
2524
- }
2525
- /**
2526
- * Resolves — and for pre-SDK-14 rows persists — the observed finality baseline
2527
- * of the obligation's latest transition, so the lifecycle decision and this
2528
- * store's compare-and-set read one committed value.
2529
- */
2530
- async resolveTransitionFinalityBaseline(obligationId) {
2531
- const lock = await this.accountLockForObligation(obligationId);
2532
- return this.transaction((client) => resolveStoredFinalityBaseline(client, obligationId), {
2533
- preBeginAdvisoryLock: lock
2534
- });
2535
- }
2536
- async appendTransition(transition) {
2537
- const lock = await this.accountLockForObligation(transition.reporting_obligation_id);
2538
- return assertFinalityWriterFence(() => this.appendTransitionWithinLock(transition, lock));
2539
- }
2540
- async appendTransitionWithinLock(transition, lock) {
2541
- return this.transaction(
2542
- async (client) => {
2543
- if (this.notificationActivityPort) {
2544
- await assertNoLegacyPendingTransitions(client, transition.reporting_obligation_id);
2545
- }
2546
- const previousFinality = await resolveStoredFinalityBaseline(client, transition.reporting_obligation_id);
2547
- const latest = await client.query(
2548
- `SELECT data->>'health' AS health FROM adcp_reporting_transitions
2549
- WHERE obligation_id = $1 ORDER BY transition_sequence DESC LIMIT 1`,
2550
- [transition.reporting_obligation_id]
2551
- );
2552
- if ((latest.rows[0]?.health ?? "waiting") !== transition.previousHealth || transition.previousFinality !== void 0 && previousFinality !== transition.previousFinality) {
2553
- return { inserted: false };
2554
- }
2555
- const storedTransition = this.notificationActivityPort ? { ...transition, notifiedAt: transition.occurredAt } : transition;
2556
- const result = await client.query(
2557
- `INSERT INTO adcp_reporting_transitions (transition_id, obligation_id, data, occurred_at)
2558
- VALUES ($1, $2, $3::jsonb, $4)
2559
- ON CONFLICT DO NOTHING RETURNING transition_id`,
2560
- [
2561
- transition.transitionId,
2562
- transition.reporting_obligation_id,
2563
- JSON.stringify(storedTransition),
2564
- transition.occurredAt
2565
- ]
2566
- );
2567
- if (result.rowCount === 1 && this.notificationActivityPort) {
2568
- const obligation = await client.query(
2569
- "SELECT data FROM adcp_reporting_obligations WHERE obligation_id = $1",
2570
- [transition.reporting_obligation_id]
2571
- );
2572
- if (!obligation.rows[0]) throw new Error("Reporting obligation is unavailable");
2573
- await this.notificationActivityPort.recordTransition(
2574
- { transition, obligation: obligation.rows[0].data },
2575
- client
2576
- );
2577
- }
2578
- return { inserted: result.rowCount === 1 };
2579
- },
2580
- { preBeginAdvisoryLock: lock }
2581
- );
2582
- }
2583
- async applyLifecycleProjection(input) {
2584
- const lock = await this.accountLockForObligation(input.reporting_obligation_id);
2585
- return assertFinalityWriterFence(() => this.applyLifecycleProjectionWithinLock(input, lock));
2586
- }
2587
- async applyLifecycleProjectionWithinLock(input, lock) {
2588
- return this.transaction(
2589
- async (client) => {
2590
- const obligations = await client.query(
2591
- `SELECT data FROM adcp_reporting_obligations WHERE obligation_id = $1 FOR UPDATE`,
2592
- [input.reporting_obligation_id]
2593
- );
2594
- if (this.notificationActivityPort) {
2595
- await assertNoLegacyPendingTransitions(client, input.reporting_obligation_id);
2596
- }
2597
- const previousFinality = await resolveStoredFinalityBaseline(client, input.reporting_obligation_id);
2598
- const revisions = await client.query(
2599
- `SELECT revision_id FROM adcp_reporting_revisions
2600
- WHERE obligation_id = $1 ORDER BY revision_number, revision_id`,
2601
- [input.reporting_obligation_id]
2602
- );
2603
- const latest = await client.query(
2604
- `SELECT data->>'health' AS health FROM adcp_reporting_transitions
2605
- WHERE obligation_id = $1 ORDER BY transition_sequence DESC LIMIT 1`,
2606
- [input.reporting_obligation_id]
2607
- );
2608
- const revisionIds = revisions.rows.map((value) => value.revision_id);
2609
- const previousHealth = latest.rows[0]?.health ?? "waiting";
2610
- const obligation = obligations.rows[0]?.data;
2611
- if (!obligation || obligation.state !== input.expectedObligationState || obligation.attemptCount !== input.expectedAttemptCount || canonicalJsonV1(revisionIds) !== canonicalJsonV1(input.expectedRevisionIds) || previousHealth !== input.expectedPreviousHealth || input.expectedPreviousFinality !== void 0 && previousFinality !== input.expectedPreviousFinality) {
2612
- return { applied: false, transitionInserted: false };
2613
- }
2614
- if (input.expectedManagedStateVersion !== void 0 && await this.readManagedStateVersion(client, input.reporting_obligation_id) !== input.expectedManagedStateVersion) {
2615
- return { applied: false, transitionInserted: false };
2616
- }
2617
- if (input.expectedObligatedConsumerRosterVersion !== void 0) {
2618
- const state = await client.query(
2619
- `SELECT current_roster_version FROM adcp_reporting_lifecycle_state
2620
- WHERE obligation_id = $1 FOR UPDATE`,
2621
- [input.reporting_obligation_id]
2622
- );
2623
- const observed = state.rows[0]?.current_roster_version;
2624
- if (observed !== void 0 && observed !== input.expectedObligatedConsumerRosterVersion) {
2625
- return { applied: false, transitionInserted: false };
2626
- }
2627
- }
2628
- let transitionInserted = false;
2629
- if (input.transition) {
2630
- const storedTransition = this.notificationActivityPort ? { ...input.transition, notifiedAt: input.ledgerAsOf } : input.transition;
2631
- const inserted = await client.query(
2632
- `INSERT INTO adcp_reporting_transitions (transition_id, obligation_id, data, occurred_at)
2633
- VALUES ($1, $2, $3::jsonb, $4)
2634
- ON CONFLICT DO NOTHING RETURNING transition_id`,
2635
- [
2636
- input.transition.transitionId,
2637
- input.reporting_obligation_id,
2638
- JSON.stringify(storedTransition),
2639
- input.transition.occurredAt
2640
- ]
2641
- );
2642
- if (inserted.rowCount !== 1) return { applied: false, transitionInserted: false };
2643
- transitionInserted = true;
2644
- if (this.notificationActivityPort) {
2645
- await this.notificationActivityPort.recordTransition({ transition: input.transition, obligation }, client);
2646
- }
2647
- }
2648
- {
2649
- await client.query(
2650
- `INSERT INTO adcp_reporting_lifecycle_state
2651
- (obligation_id, processed_state_version, processed_roster_version,
2652
- current_roster_version, processed_at)
2653
- VALUES ($1, $2, $3, $3, LEAST($4::timestamptz, clock_timestamp()))
2654
- ON CONFLICT (obligation_id) DO UPDATE SET
2655
- processed_state_version = EXCLUDED.processed_state_version,
2656
- processed_roster_version = EXCLUDED.processed_roster_version,
2657
- -- Reconciling also observes the roster, so the current version
2658
- -- moves with the processed one. A later external change
2659
- -- publishes a different current version and re-arms; without
2660
- -- this the two never matched and the obligation stayed due.
2661
- -- Never over a version this reconcile did not observe: if the
2662
- -- row was created or advanced after the check above, that
2663
- -- newer observation is what has to survive, or its re-arm is
2664
- -- silently dropped.
2665
- current_roster_version = CASE
2666
- WHEN $5::text IS NOT NULL
2667
- AND adcp_reporting_lifecycle_state.current_roster_version IS DISTINCT FROM $5::text
2668
- THEN adcp_reporting_lifecycle_state.current_roster_version
2669
- ELSE EXCLUDED.processed_roster_version END,
2670
- processed_at = EXCLUDED.processed_at,
2671
- failure_count = 0,
2672
- next_attempt_at = NULL`,
2673
- [
2674
- input.reporting_obligation_id,
2675
- input.processedManagedStateVersion ?? null,
2676
- // Empty string, not null: null means "never reconciled", which
2677
- // is a due condition. A deployment with no roster source would
2678
- // otherwise stay due forever.
2679
- input.processedObligatedConsumerRosterVersion ?? "",
2680
- // The cutoff the projection actually read at, never the commit
2681
- // instant. Watermarking at commit time swallowed everything
2682
- // that landed between the two: excluded from the projection,
2683
- // and then behind the watermark forever, so it never became
2684
- // due again. It is clamped to this database's clock in SQL
2685
- // below, because a cutoff ahead of the ledger buries
2686
- // database-timestamped work the same way for the opposite
2687
- // reason.
2688
- input.ledgerAsOf,
2689
- input.expectedObligatedConsumerRosterVersion ?? null
2690
- ]
2691
- );
2692
- }
2693
- const projectedIds = input.projectedIssues.map((issue) => issue.issueId);
2694
- for (const issue of input.projectedIssues) {
2695
- await client.query(
2696
- `INSERT INTO adcp_reporting_issues (issue_id, obligation_id, data, observed_at, resolved_at)
2697
- VALUES ($1, $2, $3::jsonb, $4, NULL)
2698
- -- opened_at is fixed at first emission; see recordIssue. A reopened
2699
- -- issue keeps the anchor because it is the same issue_id.
2700
- ON CONFLICT (issue_id) DO UPDATE SET
2701
- data = jsonb_set(EXCLUDED.data, '{openedAt}', COALESCE(adcp_reporting_issues.data -> 'openedAt', EXCLUDED.data -> 'openedAt')),
2702
- observed_at = EXCLUDED.observed_at,
2703
- resolved_at = NULL, changed_at = clock_timestamp()
2704
- WHERE adcp_reporting_issues.obligation_id = EXCLUDED.obligation_id`,
2705
- [issue.issueId, input.reporting_obligation_id, JSON.stringify(issue), issue.observedAt]
2706
- );
2707
- }
2708
- await client.query(
2709
- `UPDATE adcp_reporting_issues
2710
- SET resolved_at = $2::timestamptz,
2711
- data = data || jsonb_build_object('resolvedAt', $2::text),
2712
- changed_at = clock_timestamp()
2713
- WHERE obligation_id = $1 AND resolved_at IS NULL
2714
- -- Every code this projection can emit, not only the Core two.
2715
- -- A managed DELIVERY_FAILED persisted by an earlier reconcile
2716
- -- was never cleared, so after a successful redelivery the
2717
- -- status kept publishing a stale delivery failure for as long
2718
- -- as Core stayed degraded for some unrelated reason.
2719
- AND data->>'code' IN (
2720
- 'REPORT_OVERDUE', 'REPORTING_COVERAGE_INCOMPLETE',
2721
- 'DELIVERY_FAILED', 'RESOURCE_EXPIRED',
2722
- 'RECEIPT_REQUIRED', 'RECEIPT_REJECTED',
2723
- 'ADJUSTMENT_RECEIPT_REQUIRED', 'ADJUSTMENT_RECEIPT_REJECTED'
2724
- )
2725
- AND NOT (issue_id = ANY($3::text[]))`,
2726
- [input.reporting_obligation_id, input.ledgerAsOf, projectedIds]
2727
- );
2728
- return { applied: true, transitionInserted };
2729
- },
2730
- { preBeginAdvisoryLock: lock }
2731
- );
2732
- }
2733
- async markTransitionNotified(transitionId, notifiedAt) {
2734
- const lock = await this.accountLockForTransition(transitionId);
2735
- if (!lock) return;
2736
- await this.transaction(
2737
- async (client) => {
2738
- await client.query(
2739
- `UPDATE adcp_reporting_transitions
2740
- SET data = data || jsonb_build_object('notifiedAt', $2::text)
2741
- || CASE WHEN data ? 'finality' THEN '{}'::jsonb ELSE jsonb_build_object('finality', 'none') END
2742
- WHERE transition_id = $1 AND NOT (data ? 'notifiedAt')`,
2743
- [transitionId, notifiedAt]
2744
- );
2745
- },
2746
- { preBeginAdvisoryLock: lock }
2747
- );
2748
- }
2749
- async listTransitions(obligationId) {
2750
- const result = await this.query(
2751
- `SELECT data FROM adcp_reporting_transitions WHERE obligation_id = $1
2752
- ORDER BY transition_sequence`,
2753
- [obligationId]
2754
- );
2755
- return result.rows.map((row) => clone(row.data));
2756
- }
2757
- async listPendingTransitions(input = {}) {
2758
- const limit = input.limit ?? 100;
2759
- positiveInteger(limit, "limit");
2760
- if (limit > 1e3) throw new RangeError("limit must not exceed 1000");
2761
- const result = await this.query(
2762
- `SELECT transition.data FROM adcp_reporting_transitions transition
2763
- JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = transition.obligation_id
2764
- WHERE NOT (transition.data ? 'notifiedAt')
2765
- AND ($1::text IS NULL OR obligation.account_id = $1)
2766
- ORDER BY transition.recorded_at, transition.transition_id
2767
- LIMIT $2`,
2768
- [input.account_id ?? null, limit]
2769
- );
2770
- return result.rows.map((row) => clone(row.data));
2771
- }
2772
- async createSnapshot(query) {
2773
- return this.transaction(
2774
- async (client) => {
2775
- const clock = await client.query(
2776
- "SELECT statement_timestamp() AS ledger_as_of"
2777
- );
2778
- const ledgerAsOf = clock.rows[0].ledger_as_of.toISOString();
2779
- await client.query(
2780
- "DELETE FROM adcp_reporting_snapshots WHERE account_id = $1 AND expires_at <= clock_timestamp()",
2781
- [query.account_id]
2782
- );
2783
- if (query.view === "periods") {
2784
- await client.query(
2785
- "DELETE FROM adcp_reporting_checkpoints WHERE account_id = $1 AND expires_at <= clock_timestamp()",
2786
- [query.account_id]
2787
- );
2788
- }
2789
- const active = await client.query(
2790
- `SELECT COUNT(*)::text AS count, COALESCE(SUM(byte_count), 0)::text AS bytes
2791
- FROM adcp_reporting_snapshots WHERE account_id = $1`,
2792
- [query.account_id]
2793
- );
2794
- if (Number(active.rows[0]?.count ?? 0) >= MAX_ACTIVE_SNAPSHOTS_PER_ACCOUNT) {
2795
- throw new Error("Reporting ledger snapshot capacity is exhausted");
2796
- }
2797
- const activeSnapshotBytes = Number(active.rows[0]?.bytes ?? 0);
2798
- const changesAfter = query.changes_after ? await this.resolveChangesCheckpoint(client, query, query.changes_after) : void 0;
2799
- const managedInstalled = this.managedDelivery && await this.managedTablesInstalled(client);
2800
- const configurations = await this.listSnapshotConfigurations(client, query, ledgerAsOf);
2801
- if (configurations.length > MAX_SNAPSHOT_ITEMS) {
2802
- throw new Error("Reporting ledger snapshot exceeds the configuration limit");
2803
- }
2804
- let obligations = await this.listSnapshotObligations(client, query, ledgerAsOf, changesAfter);
2805
- if (managedInstalled && changesAfter) {
2806
- const managedChangedIds = await this.listManagedChangedObligationIds(client, query, ledgerAsOf, changesAfter);
2807
- if (managedChangedIds.size) {
2808
- const fullScope = await this.listSnapshotObligations(
2809
- client,
2810
- { ...query, changes_after: void 0, health: void 0 },
2811
- ledgerAsOf
2812
- );
2813
- const byId = new Map(obligations.map((value) => [value.reporting_obligation_id, value]));
2814
- for (const obligation of fullScope) {
2815
- if (managedChangedIds.has(obligation.reporting_obligation_id)) {
2816
- byId.set(obligation.reporting_obligation_id, obligation);
2817
- }
2818
- }
2819
- obligations = [...byId.values()].sort(
2820
- (left, right) => left.period.start.localeCompare(right.period.start) || left.reporting_obligation_id.localeCompare(right.reporting_obligation_id)
2821
- );
2822
- }
2823
- }
2824
- if (obligations.length > MAX_SNAPSHOT_ITEMS) {
2825
- throw new Error("Reporting ledger snapshot exceeds the item limit");
2826
- }
2827
- const frozenCalendarProofRequired = configurations.some((configuration) => {
2828
- try {
2829
- reportingPeriodSchedule(configuration);
2830
- return false;
2831
- } catch (error) {
2832
- if (isFrozenCalendarRulesMismatch(error)) return true;
2833
- throw error;
2834
- }
2835
- });
2836
- const coverageObligations = changesAfter || frozenCalendarProofRequired ? await this.listSnapshotObligations(
2837
- client,
2838
- {
2839
- ...query,
2840
- changes_after: void 0,
2841
- health: void 0,
2842
- finality: void 0,
2843
- ...frozenCalendarProofRequired ? {
2844
- // The current host cannot derive the predecessor's edge
2845
- // ordinals. Fetch its full closed range so coverage can
2846
- // prove the handoff exclusively from frozen obligations.
2847
- period: {
2848
- start: configurations.map((configuration) => configuration.installedAt).sort(compareReportingInstants)[0],
2849
- end: ledgerAsOf
2850
- }
2851
- } : {}
2852
- },
2853
- ledgerAsOf
2854
- ) : obligations;
2855
- if (coverageObligations.length > MAX_SNAPSHOT_ITEMS) {
2856
- throw new Error("Reporting ledger snapshot exceeds the coverage item limit");
2857
- }
2858
- const ledgerCoverage = evaluateReportingLedgerCoverageV1(
2859
- query,
2860
- configurations,
2861
- coverageObligations.map((value) => ({
2862
- configurationId: value.configurationId,
2863
- periodOrdinal: value.periodOrdinal,
2864
- period: { start: value.period.start, end: value.period.end }
2865
- })),
2866
- ledgerAsOf
2867
- );
2868
- let consumerStatuses = query.consumer_id ? await this.listSnapshotConsumerStatuses(client, query, ledgerAsOf, changesAfter, MAX_SNAPSHOT_ITEMS + 1) : [];
2869
- if (consumerStatuses.length > MAX_SNAPSHOT_ITEMS) {
2870
- throw new Error("Reporting ledger snapshot exceeds the consumer status limit");
2871
- }
2872
- const consumerStatusProjection = query.consumer_id ? await this.listSnapshotConsumerStatuses(client, query, ledgerAsOf, void 0, MAX_SNAPSHOT_ITEMS + 1) : [];
2873
- if (consumerStatusProjection.length > MAX_SNAPSHOT_ITEMS) {
2874
- throw new Error("Reporting ledger snapshot exceeds the consumer status projection limit");
2875
- }
2876
- const supersededConsumerStatusIds = new Set(
2877
- consumerStatusProjection.map((value) => value.supersedes_reporting_status_id).filter((value) => Boolean(value))
2878
- );
2879
- const exposesMissingObligationStatus = query.view === "periods" && consumerStatusProjection.some(
2880
- (value) => value.consumer_status === "obligation_missing" && !supersededConsumerStatusIds.has(value.reporting_status_id)
2881
- );
2882
- if (query.view !== "revision" && !ledgerCoverage.complete && !exposesMissingObligationStatus) {
2883
- throw new ReportingLedgerContinuityError("Reporting ledger is missing an elapsed obligation");
2884
- }
2885
- const obligationIds = obligations.map((value) => value.reporting_obligation_id);
2886
- const remaining = MAX_SNAPSHOT_ITEMS - obligations.length;
2887
- let revisions = await this.listSnapshotRevisions(client, obligationIds, query, ledgerAsOf, remaining + 1);
2888
- if (revisions.length > remaining) throw new Error("Reporting ledger snapshot exceeds the item limit");
2889
- const adjustmentCapacity = remaining - revisions.length;
2890
- let adjustments = await this.listSnapshotAdjustments(client, obligationIds, ledgerAsOf, adjustmentCapacity + 1);
2891
- if (adjustments.length > adjustmentCapacity) {
2892
- throw new Error("Reporting ledger snapshot exceeds the item limit");
2893
- }
2894
- const consumerStatusCapacity = adjustmentCapacity - adjustments.length;
2895
- if (consumerStatuses.length > consumerStatusCapacity) {
2896
- throw new Error("Reporting ledger snapshot exceeds the item limit");
2897
- }
2898
- let issues = await this.listSnapshotIssues(client, obligationIds, MAX_SNAPSHOT_ITEMS + 1);
2899
- const managedBindings = managedInstalled ? await this.listSnapshotManagedBindings(
2900
- client,
2901
- configurations.map((value) => value.configurationId)
2902
- ) : [];
2903
- let materializations = managedInstalled ? await this.listSnapshotMaterializations(client, obligationIds, ledgerAsOf, changesAfter) : [];
2904
- let receipts = managedInstalled && query.consumer_id ? await this.listSnapshotReceipts(client, query, obligationIds, ledgerAsOf, changesAfter, "revision") : [];
2905
- let adjustmentReceipts = managedInstalled && query.consumer_id ? await this.listSnapshotReceipts(client, query, obligationIds, ledgerAsOf, changesAfter, "adjustment") : [];
2906
- const tombstonedAcceptedSubjects = managedInstalled ? (await this.listTombstonedAcceptedSubjects(client, query, obligationIds, false, ledgerAsOf)).subjects : [];
2907
- const tombstonedDeliveredRevisionIds = managedInstalled ? (await client.query(
2908
- `SELECT revision_id FROM adcp_reporting_materialization_tombstones
2909
- WHERE obligation_id = ANY($1::text[]) AND reached_success
2910
- AND COALESCE(reached_success_at, pruned_at) <= $3::timestamptz LIMIT $2`,
2911
- [obligationIds, MAX_SNAPSHOT_ITEMS + 1, ledgerAsOf]
2912
- )).rows.map((row) => row.revision_id) : [];
2913
- const materializationProjection = managedInstalled ? await this.listSnapshotMaterializationProjection(client, obligationIds, ledgerAsOf) : [];
2914
- const materializationHistoryProjection = managedInstalled && changesAfter ? await this.listSnapshotMaterializations(client, obligationIds, ledgerAsOf) : materializations;
2915
- const receiptProjection = managedInstalled && query.consumer_id && changesAfter ? await this.listSnapshotReceipts(client, query, obligationIds, ledgerAsOf, void 0, "revision") : receipts;
2916
- const adjustmentReceiptProjection = managedInstalled && query.consumer_id && changesAfter ? await this.listSnapshotReceipts(client, query, obligationIds, ledgerAsOf, void 0, "adjustment") : adjustmentReceipts;
2917
- if (materializationProjection.length > MAX_SNAPSHOT_ITEMS || materializationHistoryProjection.length > MAX_SNAPSHOT_ITEMS || receiptProjection.length > MAX_SNAPSHOT_ITEMS || adjustmentReceiptProjection.length > MAX_SNAPSHOT_ITEMS) {
2918
- throw new Error("Reporting ledger snapshot exceeds the managed projection limit");
2919
- }
2920
- if (obligations.length + revisions.length + adjustments.length + consumerStatuses.length + materializations.length + receipts.length + adjustmentReceipts.length > MAX_SNAPSHOT_ITEMS) {
2921
- throw new Error("Reporting ledger snapshot exceeds the item limit");
2922
- }
2923
- if (issues.length > MAX_SNAPSHOT_ITEMS) throw new Error("Reporting ledger snapshot exceeds the issue limit");
2924
- if (query.view === "periods" && query.health) {
2925
- const scopedObligations = obligations;
2926
- const accepted = new Set(
2927
- scopedObligations.filter((value) => {
2928
- const obligationRevisions = revisions.filter(
2929
- (item) => item.reporting_obligation_id === value.reporting_obligation_id
2930
- );
2931
- const coreProjection = projectReportingObligationHealthV1(
2932
- value,
2933
- obligationRevisions,
2934
- ledgerAsOf,
2935
- reportingLedgerScopeClosed(query, ledgerAsOf, ledgerCoverage.complete)
2936
- );
2937
- const sellerHealth = projectReportingUnreadableRevisionHealthV1(
2938
- value,
2939
- obligationRevisions,
2940
- issues,
2941
- coreProjection.health
2942
- );
2943
- const statuses = consumerStatusProjection.filter((status) => statusMatchesObligation(status, value));
2944
- const leaf = currentConsumerStatus(statuses);
2945
- const mismatch = projectReportingConsumerStatusMismatchV1(
2946
- value,
2947
- leaf,
2948
- obligationRevisions,
2949
- sellerHealth,
2950
- ledgerAsOf,
2951
- this.consumerMismatchEscalation
2952
- );
2953
- const baseProjection = { ...coreProjection, health: sellerHealth };
2954
- const managed = projectManagedDelivery({
2955
- obligation: value,
2956
- binding: managedBindings.find((binding) => binding.configurationId === value.configurationId),
2957
- revisions: obligationRevisions,
2958
- adjustments: adjustments.filter(
2959
- (item) => item.reporting_obligation_id === value.reporting_obligation_id
2960
- ),
2961
- materializations: materializationProjection.filter(
2962
- (item) => item.reporting_obligation_id === value.reporting_obligation_id
2963
- ),
2964
- materializationHistory: materializationHistoryProjection.filter(
2965
- (item) => item.reporting_obligation_id === value.reporting_obligation_id
2966
- ),
2967
- receipts: receiptProjection,
2968
- adjustmentReceipts: adjustmentReceiptProjection,
2969
- base: baseProjection,
2970
- ledgerAsOf,
2971
- tombstonedAcceptedSubjects,
2972
- tombstonedDeliveredRevisionIds
2973
- });
2974
- const health = mismatch ? moreSevereReportingHealthV1(managed?.projection.health ?? sellerHealth, mismatch.health) : managed?.projection.health ?? sellerHealth;
2975
- return query.health.includes(health);
2976
- }).map((value) => value.reporting_obligation_id)
2977
- );
2978
- const obligationsByConsumerStatusChain = new Map(
2979
- scopedObligations.map((value) => [consumerStatusChainKeyForObligation(value), value])
2980
- );
2981
- const projectedStatusesByChain = /* @__PURE__ */ new Map();
2982
- for (const status of consumerStatusProjection) {
2983
- const key = reportingConsumerStatusChainKeyV1(status);
2984
- const statuses = projectedStatusesByChain.get(key) ?? [];
2985
- statuses.push(status);
2986
- projectedStatusesByChain.set(key, statuses);
2987
- }
2988
- obligations = obligations.filter((value) => accepted.has(value.reporting_obligation_id));
2989
- revisions = revisions.filter((value) => accepted.has(value.reporting_obligation_id));
2990
- adjustments = adjustments.filter((value) => accepted.has(value.reporting_obligation_id));
2991
- materializations = materializations.filter((value) => accepted.has(value.reporting_obligation_id));
2992
- const acceptedRevisionIds = new Set(revisions.map((value) => value.reporting_revision_id));
2993
- const acceptedAdjustmentIds = new Set(adjustments.map((value) => value.reporting_adjustment_id));
2994
- receipts = receipts.filter((value) => acceptedRevisionIds.has(value.reporting_revision_id));
2995
- adjustmentReceipts = adjustmentReceipts.filter(
2996
- (value) => acceptedAdjustmentIds.has(value.reporting_adjustment_id)
2997
- );
2998
- consumerStatuses = consumerStatuses.filter((value) => {
2999
- const key = reportingConsumerStatusChainKeyV1(value);
3000
- const matchingObligation = obligationsByConsumerStatusChain.get(key);
3001
- if (matchingObligation) return accepted.has(matchingObligation.reporting_obligation_id);
3002
- const leaf = currentConsumerStatus(projectedStatusesByChain.get(key) ?? []);
3003
- return query.health.includes("action_required") && leaf?.consumer_status === "obligation_missing";
3004
- });
3005
- issues = issues.filter((value) => accepted.has(value.reporting_obligation_id));
3006
- }
3007
- const changesCheckpoint = randomUUID();
3008
- const snapshot = {
3009
- snapshotId: randomUUID(),
3010
- ledgerAsOf,
3011
- changesCheckpoint,
3012
- queryFingerprint: digest(query),
3013
- query: clone(query),
3014
- configurations,
3015
- coverageOrdinals: coverageObligations.map((value) => ({
3016
- configurationId: value.configurationId,
3017
- periodOrdinal: value.periodOrdinal,
3018
- period: { start: value.period.start, end: value.period.end }
3019
- })),
3020
- obligations,
3021
- revisions,
3022
- adjustments,
3023
- ...managedInstalled ? {
3024
- tombstonedAcceptedSubjects,
3025
- tombstonedDeliveredRevisionIds,
3026
- managedBindings,
3027
- materializations,
3028
- materializationHistoryProjection,
3029
- materializationProjection,
3030
- receipts,
3031
- receiptProjection,
3032
- adjustmentReceipts,
3033
- adjustmentReceiptProjection
3034
- } : {},
3035
- consumerStatuses,
3036
- consumerStatusProjection,
3037
- issues
3038
- };
3039
- const snapshotJson = JSON.stringify(snapshot);
3040
- const snapshotBytes = Buffer.byteLength(snapshotJson, "utf8");
3041
- if (snapshotBytes > MAX_SNAPSHOT_BYTES) {
3042
- throw new Error("Reporting ledger snapshot exceeds the byte limit");
3043
- }
3044
- if (activeSnapshotBytes + snapshotBytes > MAX_ACTIVE_SNAPSHOT_BYTES_PER_ACCOUNT) {
3045
- throw new Error("Reporting ledger snapshot byte capacity is exhausted");
3046
- }
3047
- await client.query(
3048
- `INSERT INTO adcp_reporting_snapshots
3049
- (snapshot_id, account_id, query_fingerprint, data, byte_count, created_at, expires_at)
3050
- VALUES ($1, $2, $3, $4::jsonb, $5, $6,
3051
- $6::timestamptz + ($7::bigint * INTERVAL '1 millisecond'))`,
3052
- [
3053
- snapshot.snapshotId,
3054
- query.account_id,
3055
- snapshot.queryFingerprint,
3056
- snapshotJson,
3057
- snapshotBytes,
3058
- ledgerAsOf,
3059
- SNAPSHOT_RETENTION_MS
3060
- ]
3061
- );
3062
- if (query.view === "periods") {
3063
- await client.query(
3064
- `INSERT INTO adcp_reporting_checkpoints
3065
- (checkpoint_id, account_id, scope_fingerprint, ledger_as_of, expires_at)
3066
- VALUES ($1, $2, $3, $4,
3067
- $4::timestamptz + ($5::bigint * INTERVAL '1 millisecond'))`,
3068
- [
3069
- changesCheckpoint,
3070
- query.account_id,
3071
- checkpointScopeFingerprint(query),
3072
- ledgerAsOf,
3073
- CHECKPOINT_RETENTION_MS
3074
- ]
3075
- );
3076
- }
3077
- return clone(snapshot);
3078
- },
3079
- {
3080
- isolation: "REPEATABLE READ",
3081
- preBeginAdvisoryLock: `adcp-reporting-account:${query.account_id}`
3082
- }
3083
- );
3084
- }
3085
- async readSnapshotPage(snapshotId, account_id, cursor, limit) {
3086
- positiveInteger(limit, "limit");
3087
- if (limit > 500) throw new RangeError("limit must not exceed 500");
3088
- if (!UUID_PATTERN.test(snapshotId)) throw new ReportingLedgerSnapshotUnavailableError();
3089
- const snapshot = await this.one(
3090
- `SELECT data FROM adcp_reporting_snapshots
3091
- WHERE snapshot_id = $1 AND account_id = $2 AND expires_at > clock_timestamp()`,
3092
- [snapshotId, account_id]
3093
- );
3094
- if (!snapshot) throw new ReportingLedgerSnapshotUnavailableError();
3095
- const offset = cursor ? decodeCursor(cursor, snapshot) : 0;
3096
- const items = snapshotItems(snapshot);
3097
- const selected = items.slice(offset, offset + limit);
3098
- const obligations = selected.filter((value) => value.kind === "obligation").map((value) => value.value);
3099
- const revisions = selected.filter((value) => value.kind === "revision").map((value) => value.value);
3100
- const adjustments = selected.filter((value) => value.kind === "adjustment").map((value) => value.value);
3101
- const consumerStatuses = selected.filter(
3102
- (value) => value.kind === "consumer_status"
3103
- ).map((value) => value.value);
3104
- const materializations = selected.filter(
3105
- (value) => value.kind === "materialization"
3106
- ).map((value) => value.value);
3107
- const receipts = selected.filter((value) => value.kind === "receipt").map((value) => value.value);
3108
- const adjustmentReceipts = selected.filter(
3109
- (value) => value.kind === "adjustment_receipt"
3110
- ).map((value) => value.value);
3111
- const nextOffset = offset + selected.length;
3112
- const hasMore = nextOffset < items.length;
3113
- return {
3114
- snapshot,
3115
- obligations,
3116
- revisions,
3117
- adjustments,
3118
- materializations,
3119
- receipts,
3120
- adjustmentReceipts,
3121
- consumerStatuses,
3122
- totalCount: items.length,
3123
- offset,
3124
- limit,
3125
- hasMore,
3126
- ...hasMore ? { nextCursor: encodeCursor(snapshot, nextOffset) } : {}
3127
- };
3128
- }
3129
- async listSnapshotConsumerStatuses(client, query, ledgerAsOf, changesAfter, limit, currentOnly = false) {
3130
- const period = reportingLedgerEffectivePeriod(query, ledgerAsOf);
3131
- const result = await client.query(
3132
- `SELECT status.data FROM adcp_reporting_consumer_statuses status
3133
- JOIN adcp_reporting_configurations configuration
3134
- ON configuration.account_id = status.account_id
3135
- AND configuration.delivery_config_id = status.data->>'delivery_config_id'
3136
- AND configuration.delivery_config_version = (status.data->>'delivery_config_version')::integer
3137
- WHERE status.account_id = $1 AND status.consumer_id = $2
3138
- AND status.recorded_at <= $3
3139
- AND ($4::timestamptz IS NULL OR status.recorded_at > $4)
3140
- AND ($5::text[] IS NULL OR status.data->>'delivery_config_id' = ANY($5))
3141
- AND ($10::text IS NOT NULL OR (status.data->'period'->>'end')::timestamptz > $6)
3142
- AND ($10::text IS NOT NULL OR (status.data->'period'->>'start')::timestamptz < $7)
3143
- AND ($8::text[] IS NULL OR configuration.data->>'feedPurpose' = ANY($8))
3144
- AND ($9::text[] IS NULL OR configuration.data->'mediaBuyIds' ?| $9::text[])
3145
- AND ($10::text IS NULL OR status.data->>'reporting_revision_id' = $10)
3146
- AND (NOT $11::boolean OR status.is_current)
3147
- ORDER BY status.recorded_at, status.consumer_status_id
3148
- LIMIT $12`,
3149
- [
3150
- query.account_id,
3151
- query.consumer_id,
3152
- ledgerAsOf,
3153
- changesAfter ?? null,
3154
- query.delivery_config_ids ?? null,
3155
- period.start,
3156
- period.end,
3157
- query.feed_purposes ?? null,
3158
- query.media_buy_ids ?? null,
3159
- query.reporting_revision_id ?? null,
3160
- currentOnly,
3161
- limit
3162
- ]
3163
- );
3164
- return result.rows.map((row) => clone(row.data));
3165
- }
3166
- async listSnapshotObligations(client, query, ledgerAsOf, changesAfter) {
3167
- const defaultPeriod = query.view === "revision" ? { start: null, end: null } : {
3168
- start: new Date(Date.parse(ledgerAsOf) - 24 * 60 * 60 * 1e3).toISOString(),
3169
- end: ledgerAsOf
3170
- };
3171
- const result = await client.query(
3172
- `SELECT data FROM adcp_reporting_obligations
3173
- WHERE account_id = $1 AND created_at <= $2
3174
- AND ($3::timestamptz IS NULL OR changed_at > $3 OR EXISTS (
3175
- SELECT 1 FROM adcp_reporting_revisions revision
3176
- WHERE revision.obligation_id = adcp_reporting_obligations.obligation_id
3177
- AND revision.recorded_at > $3 AND revision.recorded_at <= $2
3178
- ) OR EXISTS (
3179
- SELECT 1 FROM adcp_reporting_adjustments adjustment
3180
- WHERE adjustment.obligation_id = adcp_reporting_obligations.obligation_id
3181
- AND adjustment.recorded_at > $3 AND adjustment.recorded_at <= $2
3182
- ) OR EXISTS (
3183
- SELECT 1 FROM adcp_reporting_consumer_statuses status
3184
- WHERE status.account_id = $1
3185
- AND status.consumer_id = $10
3186
- AND (
3187
- status.obligation_id = adcp_reporting_obligations.obligation_id
3188
- OR (
3189
- status.data->>'delivery_config_id' = adcp_reporting_obligations.data->>'delivery_config_id'
3190
- AND (status.data->>'delivery_config_version')::bigint =
3191
- (adcp_reporting_obligations.data->>'delivery_config_version')::bigint
3192
- AND status.data->>'report_definition_id' = adcp_reporting_obligations.data->>'report_definition_id'
3193
- AND (status.data->'period'->>'start')::timestamptz = period_start
3194
- AND (status.data->'period'->>'end')::timestamptz = period_end
3195
- AND status.data->'period'->>'source_timezone' =
3196
- adcp_reporting_obligations.data->'period'->>'sourceTimezone'
3197
- )
3198
- )
3199
- AND status.recorded_at > $3 AND status.recorded_at <= $2
3200
- ) OR EXISTS (
3201
- SELECT 1 FROM adcp_reporting_issues issue
3202
- WHERE issue.obligation_id = adcp_reporting_obligations.obligation_id
3203
- AND issue.changed_at > $3 AND issue.changed_at <= $2
3204
- ) OR EXISTS (
3205
- SELECT 1 FROM adcp_reporting_transitions transition
3206
- WHERE transition.obligation_id = adcp_reporting_obligations.obligation_id
3207
- AND transition.recorded_at > $3 AND transition.recorded_at <= $2
3208
- ) OR ((data->>'expectedAt')::timestamptz > $3 AND (data->>'expectedAt')::timestamptz <= $2)
3209
- OR ((data->>'recoveryDeadlineAt')::timestamptz > $3
3210
- AND (data->>'recoveryDeadlineAt')::timestamptz <= $2)
3211
- )
3212
- AND ($4::text[] IS NULL OR data->>'delivery_config_id' = ANY($4))
3213
- AND ($5::text[] IS NULL OR data->>'feedPurpose' = ANY($5))
3214
- AND ($6::text[] IS NULL OR EXISTS (
3215
- SELECT 1 FROM jsonb_array_elements_text(data->'mediaBuyIds') value WHERE value = ANY($6)
3216
- ))
3217
- AND ($7::timestamptz IS NULL OR period_end > $7)
3218
- AND ($8::timestamptz IS NULL OR period_start < $8)
3219
- AND ($9::text IS NOT NULL OR period_end <= $2)
3220
- AND ($9::text IS NULL OR EXISTS (
3221
- SELECT 1 FROM adcp_reporting_revisions exact_revision
3222
- WHERE exact_revision.obligation_id = adcp_reporting_obligations.obligation_id
3223
- AND exact_revision.revision_id = $9 AND exact_revision.recorded_at <= $2
3224
- ))
3225
- ORDER BY period_start, obligation_id
3226
- LIMIT $11`,
3227
- [
3228
- query.account_id,
3229
- ledgerAsOf,
3230
- changesAfter ?? null,
3231
- query.delivery_config_ids ?? null,
3232
- query.feed_purposes ?? null,
3233
- query.media_buy_ids ?? null,
3234
- query.period?.start ?? defaultPeriod.start,
3235
- query.period?.end ?? defaultPeriod.end,
3236
- query.reporting_revision_id ?? null,
3237
- query.consumer_id ?? null,
3238
- MAX_SNAPSHOT_ITEMS + 1
3239
- ]
3240
- );
3241
- return result.rows.map((row) => row.data);
3242
- }
3243
- async listSnapshotConfigurations(client, query, ledgerAsOf) {
3244
- const result = await client.query(
3245
- `SELECT data FROM adcp_reporting_configurations
3246
- WHERE account_id = $1 AND created_at <= $2
3247
- AND ($3::text[] IS NULL OR delivery_config_id = ANY($3))
3248
- ORDER BY delivery_config_id, delivery_config_version
3249
- LIMIT $4`,
3250
- [query.account_id, ledgerAsOf, query.delivery_config_ids ?? null, MAX_SNAPSHOT_ITEMS + 1]
3251
- );
3252
- return result.rows.map((row) => row.data);
3253
- }
3254
- async resolveChangesCheckpoint(client, query, checkpointId) {
3255
- if (!UUID_PATTERN.test(checkpointId)) throw new ReportingLedgerSnapshotUnavailableError();
3256
- const result = await client.query(
3257
- `SELECT ledger_as_of FROM adcp_reporting_checkpoints
3258
- WHERE checkpoint_id = $1 AND account_id = $2 AND scope_fingerprint = $3
3259
- AND expires_at > clock_timestamp()`,
3260
- [checkpointId, query.account_id, checkpointScopeFingerprint(query)]
3261
- );
3262
- const value = result.rows[0]?.ledger_as_of;
3263
- if (!value) throw new ReportingLedgerSnapshotUnavailableError();
3264
- return value.toISOString();
3265
- }
3266
- async listSnapshotRevisions(client, obligationIds, query, ledgerAsOf, limit) {
3267
- if (!obligationIds.length) return [];
3268
- const result = await client.query(
3269
- `SELECT data - 'rows' AS data FROM adcp_reporting_revisions
3270
- WHERE obligation_id = ANY($1::text[]) AND recorded_at <= $2
3271
- AND ($3::text IS NULL OR revision_id = $3)
3272
- ORDER BY obligation_id, revision_number
3273
- LIMIT $4`,
3274
- [obligationIds, ledgerAsOf, query.reporting_revision_id ?? null, limit]
3275
- );
3276
- return result.rows.map((row) => row.data);
3277
- }
3278
- async listSnapshotIssues(client, obligationIds, limit) {
3279
- if (!obligationIds.length) return [];
3280
- const result = await client.query(
3281
- `SELECT data FROM adcp_reporting_issues
3282
- WHERE obligation_id = ANY($1::text[])
3283
- ORDER BY obligation_id, observed_at, issue_id
3284
- LIMIT $2`,
3285
- [obligationIds, limit]
3286
- );
3287
- return result.rows.map((row) => row.data);
3288
- }
3289
- async listSnapshotAdjustments(client, obligationIds, ledgerAsOf, limit) {
3290
- if (!obligationIds.length) return [];
3291
- const result = await client.query(
3292
- `SELECT data - 'rows' AS data FROM adcp_reporting_adjustments
3293
- WHERE obligation_id = ANY($1::text[]) AND recorded_at <= $2
3294
- ORDER BY obligation_id, adjustment_number
3295
- LIMIT $3`,
3296
- [obligationIds, ledgerAsOf, limit]
3297
- );
3298
- return result.rows.map((row) => row.data);
3299
- }
3300
- /**
3301
- * Managed Delivery projection inputs for one obligation, grouped per consumer.
3302
- *
3303
- * The lifecycle reconciler folds these through the same
3304
- * `projectManagedDelivery` the read path uses, so a persisted transition and
3305
- * its webhook report the health a read of the same obligation would return.
3306
- * Returns `null` when Managed Delivery is not installed or the obligation's
3307
- * configuration has no binding, which keeps a Core-only ledger unchanged.
3308
- */
3309
- async getManagedLifecycleProjection(input) {
3310
- if (!this.managedDelivery) return null;
3311
- const base = await this.transaction(
3312
- async (client) => {
3313
- if (!await this.managedTablesInstalled(client)) return null;
3314
- const obligation = await client.query(
3315
- "SELECT configuration_id FROM adcp_reporting_obligations WHERE obligation_id = $1",
3316
- [input.reporting_obligation_id]
3317
- );
3318
- const configurationId = obligation.rows[0]?.configuration_id;
3319
- if (!configurationId) return null;
3320
- const [binding] = await this.listSnapshotManagedBindings(client, [configurationId]);
3321
- if (!binding) return null;
3322
- const resolvedLedgerAsOf = (await client.query(
3323
- // RFC 3339 with microseconds. PostgreSQL's own ::text rendering
3324
- // uses a space separator and a two-digit offset, which the SDK's
3325
- // instant parser rejects, and round-tripping through a JS Date
3326
- // would truncate the microseconds this exists to preserve.
3327
- `SELECT ${rfc3339Microseconds("COALESCE($1::timestamptz, clock_timestamp())")} AS instant`,
3328
- [input.ledgerAsOf ?? null]
3329
- )).rows[0].instant;
3330
- const rows = await client.query(
3331
- `SELECT materialization.data,
3332
- materialization.changed_at > $2::timestamptz AS settled_after_cutoff,
3333
- CASE WHEN authz.revoked_at <= $2::timestamptz THEN authz.revoked_at END AS revoked_at
3334
- FROM adcp_reporting_materializations materialization
3335
- JOIN adcp_reporting_destination_authorizations authz
3336
- ON authz.account_id = materialization.account_id
3337
- AND authz.destination_ref = materialization.destination_ref
3338
- AND authz.generation = materialization.authorization_generation
3339
- WHERE materialization.obligation_id = $1
3340
- AND materialization.recorded_at <= $2::timestamptz
3341
- ORDER BY materialization.attempt, materialization.materialization_id
3342
- LIMIT $3`,
3343
- [input.reporting_obligation_id, resolvedLedgerAsOf, MAX_SNAPSHOT_ITEMS + 1]
3344
- );
3345
- if (rows.rows.length > MAX_SNAPSHOT_ITEMS) {
3346
- throw new Error("Reporting lifecycle projection exceeds the managed materialization limit");
3347
- }
3348
- const placed = rows.rows.map((row) => ({
3349
- atAsOf: row.settled_after_cutoff ? pendingAtCutoff(row.data) : clone(row.data),
3350
- revokedAt: row.revoked_at
3351
- }));
3352
- const materializationHistory = placed.map((value) => value.atAsOf);
3353
- const materializations = placed.map(
3354
- ({ atAsOf, revokedAt }) => revokedAt && (atAsOf.status === "available" || atAsOf.status === "delivered") ? {
3355
- ...atAsOf,
3356
- status: "failed",
3357
- failed_at: revokedAt.toISOString(),
3358
- failure_code: "AUTHORIZATION_REVOKED"
3359
- } : atAsOf
3360
- );
3361
- const receipts = await client.query(
3362
- `WITH subject AS (
3363
- -- Drive from this obligation's own subjects. Asking the global
3364
- -- receipt table "which of your rows belong to this obligation"
3365
- -- matched no index \u2014 they lead with account and consumer, and
3366
- -- this question supplies neither \u2014 so every due obligation
3367
- -- re-scanned the whole receipt history.
3368
- SELECT revision_id AS subject_id, 'revision' AS receipt_kind
3369
- FROM adcp_reporting_revisions WHERE obligation_id = $1
3370
- UNION ALL
3371
- SELECT adjustment_id, 'adjustment'
3372
- FROM adcp_reporting_adjustments WHERE obligation_id = $1
3373
- )
3374
- SELECT receipt.consumer_id, receipt.receipt_kind, receipt.data
3375
- FROM subject
3376
- JOIN adcp_reporting_receipts receipt
3377
- ON receipt.subject_id = subject.subject_id
3378
- AND receipt.receipt_kind = subject.receipt_kind
3379
- -- Ordered and cut off by the database clock, exactly as every other
3380
- -- receipt read path is, so a skewed host cannot make the lifecycle
3381
- -- projection see a different receipt set than get_reporting_status.
3382
- --
3383
- -- One leaf per chain, resolved AS OF the cutoff. The fold reads
3384
- -- exactly one thing from each chain, and walking the whole history
3385
- -- to find it pushed this read past its bound for a subject repaired
3386
- -- often enough. But is_current is today's answer: at a cutoff
3387
- -- before an acceptance superseded a rejection, the rejection was no
3388
- -- longer current and the acceptance was not yet in scope, so the
3389
- -- projection saw neither and persisted RECEIPT_REQUIRED over a
3390
- -- rejection the buyer had already filed. The leaf at the cutoff is
3391
- -- the receipt that nothing recorded by then supersedes. Wire
3392
- -- counters are computed on the read path, which still sees every
3393
- -- row.
3394
- WHERE receipt.recorded_at <= $2
3395
- AND NOT EXISTS (
3396
- SELECT 1 FROM adcp_reporting_receipts successor
3397
- WHERE successor.account_id = receipt.account_id
3398
- AND successor.consumer_id = receipt.consumer_id
3399
- AND successor.supersedes_receipt_id = receipt.reporting_receipt_id
3400
- AND successor.recorded_at <= $2
3401
- )
3402
- -- A successor whose body has been pruned still superseded this
3403
- -- row. Counting only live successors let retention resurrect the
3404
- -- rejected predecessor of a pruned acceptance as the live leaf,
3405
- -- which overrode that acceptance's own tombstone and reopened a
3406
- -- settled subject the write path then refused to repair.
3407
- AND NOT EXISTS (
3408
- SELECT 1 FROM adcp_reporting_receipt_tombstones gone
3409
- WHERE gone.account_id = receipt.account_id
3410
- AND gone.consumer_id = receipt.consumer_id
3411
- AND gone.supersedes_receipt_id = receipt.reporting_receipt_id
3412
- AND COALESCE(gone.subject_recorded_at, gone.pruned_at) <= $2
3413
- )
3414
- ORDER BY receipt.consumer_id, receipt.subject_id, receipt.recorded_at, receipt.reporting_receipt_id
3415
- LIMIT $3`,
3416
- [input.reporting_obligation_id, resolvedLedgerAsOf, MAX_LIFECYCLE_RECEIPT_LEAVES + 1]
3417
- );
3418
- let receiptEvidenceComplete = true;
3419
- let leaves = receipts.rows;
3420
- if (leaves.length > MAX_LIFECYCLE_RECEIPT_LEAVES) {
3421
- receiptEvidenceComplete = false;
3422
- const partial = leaves[MAX_LIFECYCLE_RECEIPT_LEAVES].consumer_id;
3423
- leaves = leaves.slice(0, MAX_LIFECYCLE_RECEIPT_LEAVES).filter((row) => row.consumer_id !== partial);
3424
- }
3425
- const byConsumer = /* @__PURE__ */ new Map();
3426
- for (const row of leaves) {
3427
- const consumer = byConsumer.get(row.consumer_id) ?? {
3428
- consumer_id: row.consumer_id,
3429
- receipts: [],
3430
- adjustmentReceipts: []
3431
- };
3432
- if (row.receipt_kind === "revision") consumer.receipts.push(clone(row.data));
3433
- else consumer.adjustmentReceipts.push(clone(row.data));
3434
- byConsumer.set(row.consumer_id, consumer);
3435
- }
3436
- const engaged = await client.query(
3437
- `SELECT DISTINCT consumer_id FROM adcp_reporting_consumer_statuses
3438
- WHERE obligation_id = $1 AND consumer_id <> '__legacy_unscoped_consumer__'
3439
- ORDER BY consumer_id LIMIT $2`,
3440
- [input.reporting_obligation_id, MAX_SNAPSHOT_ITEMS + 1]
3441
- );
3442
- if (engaged.rows.length > MAX_SNAPSHOT_ITEMS) {
3443
- receiptEvidenceComplete = false;
3444
- engaged.rows = engaged.rows.slice(0, MAX_SNAPSHOT_ITEMS);
3445
- }
3446
- const observed = [.../* @__PURE__ */ new Set([...byConsumer.keys(), ...engaged.rows.map((row) => row.consumer_id)])].sort();
3447
- const tombstoned = await this.listTombstonedAcceptedSubjects(
3448
- client,
3449
- { account_id: binding.account_id, consumer_id: void 0, view: "periods" },
3450
- [input.reporting_obligation_id],
3451
- true,
3452
- resolvedLedgerAsOf
3453
- );
3454
- if (!tombstoned.complete) receiptEvidenceComplete = false;
3455
- const visible = await client.query(
3456
- `SELECT adjustment_id FROM adcp_reporting_adjustments
3457
- WHERE obligation_id = $1 AND recorded_at <= $2::timestamptz
3458
- ORDER BY adjustment_number, adjustment_id LIMIT $3`,
3459
- [input.reporting_obligation_id, resolvedLedgerAsOf, MAX_SNAPSHOT_ITEMS + 1]
3460
- );
3461
- if (visible.rows.length > MAX_SNAPSHOT_ITEMS) receiptEvidenceComplete = false;
3462
- const visibleAdjustmentIds = visible.rows.slice(0, MAX_SNAPSHOT_ITEMS).map((row) => row.adjustment_id);
3463
- const visibleRevisions = await client.query(
3464
- `SELECT revision_id FROM adcp_reporting_revisions
3465
- WHERE obligation_id = $1 AND recorded_at <= $2::timestamptz
3466
- ORDER BY revision_number, revision_id LIMIT $3`,
3467
- [input.reporting_obligation_id, resolvedLedgerAsOf, MAX_SNAPSHOT_ITEMS + 1]
3468
- );
3469
- if (visibleRevisions.rows.length > MAX_SNAPSHOT_ITEMS) receiptEvidenceComplete = false;
3470
- const visibleRevisionIds = visibleRevisions.rows.slice(0, MAX_SNAPSHOT_ITEMS).map((row) => row.revision_id);
3471
- const tombstonedAcceptedSubjects = tombstoned.subjects;
3472
- const delivered = await client.query(
3473
- `SELECT revision_id FROM adcp_reporting_materialization_tombstones
3474
- WHERE obligation_id = $1 AND reached_success
3475
- AND COALESCE(reached_success_at, pruned_at) <= $3::timestamptz LIMIT $2`,
3476
- [input.reporting_obligation_id, MAX_SNAPSHOT_ITEMS + 1, resolvedLedgerAsOf]
3477
- );
3478
- if (delivered.rows.length > MAX_SNAPSHOT_ITEMS) receiptEvidenceComplete = false;
3479
- const tombstonedDeliveredRevisionIds = delivered.rows.slice(0, MAX_SNAPSHOT_ITEMS).map((row) => row.revision_id);
3480
- const managedStateVersion = await this.readManagedStateVersion(client, input.reporting_obligation_id);
3481
- return {
3482
- binding,
3483
- materializations,
3484
- materializationHistory,
3485
- consumers: [...byConsumer.values()],
3486
- obligatedConsumerIds: observed,
3487
- obligatedConsumerRosterComplete: false,
3488
- receiptEvidenceComplete,
3489
- visibleAdjustmentIds,
3490
- visibleRevisionIds,
3491
- managedStateVersion,
3492
- resolvedLedgerAsOf,
3493
- tombstonedAcceptedSubjects,
3494
- tombstonedDeliveredRevisionIds
3495
- };
3496
- },
3497
- { isolation: "REPEATABLE READ" }
3498
- );
3499
- if (!base || !this.obligatedConsumers) return base;
3500
- const supplied = await withReportingCallbackDeadline(
3501
- this.obligatedConsumers({
3502
- reporting_obligation_id: input.reporting_obligation_id,
3503
- account_id: base.binding.account_id
3504
- }),
3505
- OBLIGATED_CONSUMERS_DEADLINE_MS,
3506
- "Reporting obligated-consumer lookup deadline elapsed"
3507
- );
3508
- const all = obligatedConsumerIdsFor(supplied, base.obligatedConsumerIds ?? []);
3509
- const identityIds = supplied.version === void 0 && supplied.complete !== true ? obligatedConsumerIdsFor(supplied, await this.observedConsumerIds(input.reporting_obligation_id)) : all;
3510
- const ids = all.slice(0, MAX_SNAPSHOT_ITEMS);
3511
- const rosterComplete = supplied.complete === true && all.length === ids.length;
3512
- return {
3513
- ...base,
3514
- obligatedConsumerIds: ids,
3515
- obligatedConsumerRosterComplete: rosterComplete,
3516
- ...all.length === ids.length ? {} : { receiptEvidenceComplete: false },
3517
- obligatedConsumerRosterVersion: obligatedConsumerRosterVersionFor(supplied, identityIds)
3518
- };
3519
- }
3520
- /**
3521
- * Digest over every managed row that can change this obligation's projected
3522
- * health: materialization status, receipt existence and current-leaf flag,
3523
- * adjustments, and the destination authorization's revocation/cleanup state.
3524
- *
3525
- * `is_current` is included because superseding a receipt flips it with no
3526
- * timestamp of its own, and the authorization's `cleanup_completed_at` is
3527
- * included because `completeRevocation` does not touch `changed_at`.
3528
- */
3529
- async readManagedStateVersion(client, obligationId) {
3530
- const result = await client.query(
3531
- `SELECT md5(COALESCE(string_agg(marker, '|' ORDER BY marker), '')) AS version FROM (
3532
- SELECT 'm:' || materialization_id || ':' || status || ':'
3533
- || EXTRACT(EPOCH FROM changed_at)::text AS marker
3534
- FROM adcp_reporting_materializations WHERE obligation_id = $1
3535
- UNION ALL
3536
- -- Driven from this obligation's subjects into the receipt subject
3537
- -- index. This digest is read while the apply holds the account
3538
- -- lock, so asking the global receipt table which of its rows belong
3539
- -- to an obligation -- a question supplying neither account nor
3540
- -- consumer -- turned every apply into a full scan with that lock
3541
- -- held, and concurrent Core writes failed with 55P03.
3542
- SELECT 'r:' || receipt.reporting_receipt_id || ':' || receipt.is_current::text || ':'
3543
- || EXTRACT(EPOCH FROM receipt.recorded_at)::text
3544
- FROM adcp_reporting_revisions revision
3545
- JOIN adcp_reporting_receipts receipt
3546
- ON receipt.receipt_kind = 'revision' AND receipt.subject_id = revision.revision_id
3547
- WHERE revision.obligation_id = $1
3548
- UNION ALL
3549
- SELECT 'r:' || receipt.reporting_receipt_id || ':' || receipt.is_current::text || ':'
3550
- || EXTRACT(EPOCH FROM receipt.recorded_at)::text
3551
- FROM adcp_reporting_adjustments adjustment
3552
- JOIN adcp_reporting_receipts receipt
3553
- ON receipt.receipt_kind = 'adjustment' AND receipt.subject_id = adjustment.adjustment_id
3554
- WHERE adjustment.obligation_id = $1
3555
- UNION ALL
3556
- SELECT 'a:' || adjustment_id || ':' || EXTRACT(EPOCH FROM recorded_at)::text
3557
- FROM adcp_reporting_adjustments WHERE obligation_id = $1
3558
- UNION ALL
3559
- -- Consumer statuses are a health input through the mismatch
3560
- -- projection and they widen the observed roster, so a status landing
3561
- -- between projection and apply has to move the token too.
3562
- SELECT 'c:' || status.consumer_id || ':' || status.consumer_status_id || ':'
3563
- || status.is_current::text || ':' || EXTRACT(EPOCH FROM status.created_at)::text
3564
- FROM adcp_reporting_consumer_statuses status
3565
- WHERE status.obligation_id = $1
3566
- UNION ALL
3567
- SELECT 'z:' || authz.generation::text
3568
- || ':' || COALESCE(EXTRACT(EPOCH FROM authz.revoked_at)::text, '')
3569
- || ':' || COALESCE(EXTRACT(EPOCH FROM authz.cleanup_completed_at)::text, '')
3570
- || ':' || EXTRACT(EPOCH FROM authz.changed_at)::text
3571
- FROM adcp_reporting_destination_authorizations authz
3572
- JOIN adcp_reporting_managed_bindings binding
3573
- ON binding.account_id = authz.account_id AND binding.destination_ref = authz.destination_ref
3574
- JOIN adcp_reporting_obligations obligation
3575
- ON obligation.configuration_id = binding.configuration_id
3576
- WHERE obligation.obligation_id = $1
3577
- ) markers`,
3578
- [obligationId]
3579
- );
3580
- return result.rows[0]?.version ?? "";
3581
- }
3582
- /**
3583
- * Current external roster version, read outside any transaction so the
3584
- * reconciler can re-check it immediately before an apply.
3585
- */
3586
- /**
3587
- * Publishes the roster version currently observed for an obligation.
3588
- *
3589
- * The roster is external, so nothing in this database changes when it does.
3590
- * Recording what a reader saw is what lets the due query notice drift from
3591
- * the version last reconciled. Never lowers a watermark; it only reports.
3592
- */
3593
- async recordObligatedConsumerRosterVersion(input) {
3594
- await this.query(
3595
- `INSERT INTO adcp_reporting_lifecycle_state
3596
- (obligation_id, current_roster_version, roster_refreshed_at, processed_at)
3597
- VALUES ($1, $2, clock_timestamp(), clock_timestamp())
3598
- ON CONFLICT (obligation_id) DO UPDATE SET
3599
- current_roster_version = EXCLUDED.current_roster_version,
3600
- -- Advances the refresh cursor, never the reconciliation watermark:
3601
- -- observing a roster is not reconciling an obligation.
3602
- roster_refreshed_at = clock_timestamp()`,
3603
- [input.reporting_obligation_id, input.version]
3604
- );
3605
- }
3606
- /**
3607
- * Re-reads the external roster for managed obligations and publishes what
3608
- * it saw, so a change made while nothing was scheduled can re-arm.
3609
- *
3610
- * Ordered by least-recently-refreshed and bounded, so a large account is
3611
- * swept over successive passes rather than in one burst of callbacks.
3612
- */
3613
- async refreshObligatedConsumerRosterVersions(input) {
3614
- if (!this.managedDelivery || !this.obligatedConsumers) return 0;
3615
- positiveInteger(input.limit, "limit");
3616
- if (!await this.managedDueTablesReady()) return 0;
3617
- const budget = Math.min(input.limit, MAX_ROSTER_REFRESH_PER_SWEEP);
3618
- const candidates = await this.query(
3619
- `SELECT obligation.obligation_id
3620
- FROM adcp_reporting_obligations obligation
3621
- JOIN adcp_reporting_managed_bindings binding
3622
- ON binding.configuration_id = obligation.configuration_id
3623
- LEFT JOIN adcp_reporting_lifecycle_state state
3624
- ON state.obligation_id = obligation.obligation_id
3625
- WHERE ($1::text IS NULL OR obligation.account_id = $1)
3626
- -- Its own cursor, advanced by the refresh itself. Ordering by the
3627
- -- reconciliation watermark, which a refresh never moves, meant every
3628
- -- sweep re-read the same first page and nothing beyond one page of quiet
3629
- -- obligations was ever refreshed, so their roster changes could never
3630
- -- re-arm.
3631
- ORDER BY state.roster_refreshed_at NULLS FIRST, obligation.obligation_id
3632
- LIMIT $2`,
3633
- [input.account_id ?? null, budget]
3634
- );
3635
- let refreshed = 0;
3636
- for (const row of candidates.rows) {
3637
- try {
3638
- await this.readObligatedConsumerRosterVersion({ reporting_obligation_id: row.obligation_id });
3639
- refreshed += 1;
3640
- } catch {
3641
- await this.query(
3642
- `INSERT INTO adcp_reporting_lifecycle_state (obligation_id, roster_refreshed_at, processed_at)
3643
- VALUES ($1, clock_timestamp(), clock_timestamp())
3644
- ON CONFLICT (obligation_id) DO UPDATE SET roster_refreshed_at = clock_timestamp()`,
3645
- [row.obligation_id]
3646
- ).catch(() => void 0);
3647
- }
3648
- }
3649
- return refreshed;
3650
- }
3651
- /**
3652
- * Records a failed reconcile so the obligation backs off instead of
3653
- * re-occupying the head of every sweep, without ever hiding it: the
3654
- * watermark is untouched, so it stays unresolved work.
3655
- */
3656
- async recordLifecycleFailure(input) {
3657
- await this.query(
3658
- `INSERT INTO adcp_reporting_lifecycle_state (obligation_id, failure_count, next_attempt_at, processed_at)
3659
- VALUES ($1, 1, clock_timestamp() + INTERVAL '30 seconds', clock_timestamp())
3660
- ON CONFLICT (obligation_id) DO UPDATE SET
3661
- failure_count = adcp_reporting_lifecycle_state.failure_count + 1,
3662
- next_attempt_at = clock_timestamp() + (LEAST(
3663
- ${MAX_LIFECYCLE_BACKOFF_SECONDS}, 30 * POWER(2, LEAST(6, adcp_reporting_lifecycle_state.failure_count))
3664
- ) * INTERVAL '1 second')`,
3665
- [input.reporting_obligation_id]
3666
- );
3667
- }
3668
- /** Authoritative instant from the database clock, at microsecond precision. */
3669
- async readLedgerInstant() {
3670
- const result = await this.query(
3671
- `SELECT ${rfc3339Microseconds("clock_timestamp()")} AS instant`
3672
- );
3673
- return result.rows[0].instant;
3674
- }
3675
- async readObligatedConsumerRosterVersion(input) {
3676
- if (!this.managedDelivery || !this.obligatedConsumers) return void 0;
3677
- const accountId = await this.query(
3678
- "SELECT account_id FROM adcp_reporting_obligations WHERE obligation_id = $1",
3679
- [input.reporting_obligation_id]
3680
- );
3681
- const account_id = accountId.rows[0]?.account_id;
3682
- if (!account_id) return void 0;
3683
- const supplied = await withReportingCallbackDeadline(
3684
- this.obligatedConsumers({ reporting_obligation_id: input.reporting_obligation_id, account_id }),
3685
- OBLIGATED_CONSUMERS_DEADLINE_MS,
3686
- "Reporting obligated-consumer lookup deadline elapsed"
3687
- );
3688
- const observed = await this.observedConsumerIds(input.reporting_obligation_id);
3689
- const version = obligatedConsumerRosterVersionFor(supplied, obligatedConsumerIdsFor(supplied, observed));
3690
- await this.recordObligatedConsumerRosterVersion({
3691
- reporting_obligation_id: input.reporting_obligation_id,
3692
- version
3693
- });
3694
- return version;
3695
- }
3696
- /** Principals that have engaged with this obligation, as the projection sees them. */
3697
- async observedConsumerIds(obligationId) {
3698
- const result = await this.query(
3699
- `SELECT DISTINCT consumer_id FROM (
3700
- -- Driven from this obligation's subjects into the receipt subject
3701
- -- index, like every other obligation-scoped receipt read. Asking the
3702
- -- global receipt table which of its rows belong to an obligation
3703
- -- supplies neither account nor consumer, so the roster read scanned
3704
- -- the whole receipt history once per reconcile and once per
3705
- -- pre-apply re-check.
3706
- SELECT receipt.consumer_id
3707
- FROM adcp_reporting_revisions revision
3708
- JOIN adcp_reporting_receipts receipt
3709
- ON receipt.receipt_kind = 'revision' AND receipt.subject_id = revision.revision_id
3710
- WHERE revision.obligation_id = $1
3711
- UNION
3712
- SELECT receipt.consumer_id
3713
- FROM adcp_reporting_adjustments adjustment
3714
- JOIN adcp_reporting_receipts receipt
3715
- ON receipt.receipt_kind = 'adjustment' AND receipt.subject_id = adjustment.adjustment_id
3716
- WHERE adjustment.obligation_id = $1
3717
- UNION
3718
- SELECT status.consumer_id FROM adcp_reporting_consumer_statuses status
3719
- WHERE status.obligation_id = $1 AND status.consumer_id <> '__legacy_unscoped_consumer__'
3720
- ) engaged ORDER BY consumer_id`,
3721
- [obligationId]
3722
- );
3723
- return result.rows.map((row) => row.consumer_id);
3724
- }
3725
- managedDueTablesReadyCache;
3726
- /** Whether the managed schema exists, resolved once per store instance. */
3727
- managedDueTablesReady() {
3728
- if (!this.managedDelivery) return Promise.resolve(false);
3729
- this.managedDueTablesReadyCache ??= this.query(
3730
- `SELECT to_regclass('adcp_reporting_managed_bindings') IS NOT NULL
3731
- AND to_regclass('adcp_reporting_materializations') IS NOT NULL
3732
- AND to_regclass('adcp_reporting_receipts') IS NOT NULL AS ready`
3733
- ).then((result) => result.rows[0]?.ready === true).catch((cause) => {
3734
- this.managedDueTablesReadyCache = void 0;
3735
- throw cause;
3736
- });
3737
- return this.managedDueTablesReadyCache;
3738
- }
3739
- /**
3740
- * Terminal receipt subjects whose bodies have been pruned.
3741
- *
3742
- * The projection decides reconciliation from the receipt rows it can see,
3743
- * so once an accepted leaf ages out the subject looks unreconciled again
3744
- * and a settled billing period reopens. The tombstone is the only remaining
3745
- * proof, so the projection has to consult it.
3746
- */
3747
- async listTombstonedAcceptedSubjects(client, query, obligationIds, allConsumers = false, ledgerAsOf) {
3748
- if (!obligationIds.length || !query.consumer_id && !allConsumers) return { subjects: [], complete: true };
3749
- const result = await client.query(
3750
- `SELECT tombstone.receipt_kind, tombstone.subject_id, tombstone.consumer_id
3751
- FROM adcp_reporting_receipt_tombstones tombstone
3752
- WHERE tombstone.account_id = $1 AND ($2::text IS NULL OR tombstone.consumer_id = $2)
3753
- AND tombstone.status = 'accepted' AND tombstone.was_current
3754
- -- A conclusion still has a place in time. Applying every accepted
3755
- -- tombstone regardless of when its receipt was filed let a
3756
- -- historical projection settle a subject on an acceptance that had
3757
- -- not happened yet at its cutoff. Rows written before the column
3758
- -- existed fall back to pruned_at, which is never earlier, so the
3759
- -- fallback can only withhold a conclusion, never invent one.
3760
- AND ($5::timestamptz IS NULL
3761
- OR COALESCE(tombstone.subject_recorded_at, tombstone.pruned_at) <= $5::timestamptz)
3762
- AND ((tombstone.receipt_kind = 'revision' AND EXISTS (
3763
- SELECT 1 FROM adcp_reporting_revisions revision
3764
- WHERE revision.revision_id = tombstone.subject_id
3765
- AND revision.obligation_id = ANY($3::text[])))
3766
- OR (tombstone.receipt_kind = 'adjustment' AND EXISTS (
3767
- SELECT 1 FROM adcp_reporting_adjustments adjustment
3768
- WHERE adjustment.adjustment_id = tombstone.subject_id
3769
- AND adjustment.obligation_id = ANY($3::text[]))))
3770
- LIMIT $4`,
3771
- [
3772
- query.account_id,
3773
- allConsumers ? null : query.consumer_id,
3774
- obligationIds,
3775
- MAX_SNAPSHOT_ITEMS + 1,
3776
- ledgerAsOf ?? null
3777
- ]
3778
- );
3779
- const complete = result.rows.length <= MAX_SNAPSHOT_ITEMS;
3780
- return {
3781
- complete,
3782
- subjects: result.rows.slice(0, MAX_SNAPSHOT_ITEMS).map((row) => ({
3783
- kind: row.receipt_kind === "adjustment" ? "adjustment" : "revision",
3784
- subjectId: row.subject_id,
3785
- // Carried through deliberately. An acceptance belongs to the consumer
3786
- // that gave it, so a lifecycle fold that runs per consumer must not
3787
- // let A's pruned acceptance settle the obligation on B's behalf.
3788
- consumerId: row.consumer_id
3789
- }))
3790
- };
3791
- }
3792
- async managedTablesInstalled(client) {
3793
- const result = await client.query(
3794
- `SELECT to_regclass('adcp_reporting_managed_bindings') IS NOT NULL
3795
- AND to_regclass('adcp_reporting_materializations') IS NOT NULL
3796
- AND to_regclass('adcp_reporting_receipts') IS NOT NULL AS ready`
3797
- );
3798
- return result.rows[0]?.ready === true;
3799
- }
3800
- async listManagedChangedObligationIds(client, query, ledgerAsOf, changesAfter) {
3801
- const result = await client.query(
3802
- `SELECT DISTINCT changed.obligation_id FROM (
3803
- SELECT materialization.obligation_id
3804
- FROM adcp_reporting_materializations materialization
3805
- WHERE materialization.account_id = $1
3806
- AND GREATEST(materialization.recorded_at, materialization.changed_at) > $3
3807
- AND GREATEST(materialization.recorded_at, materialization.changed_at) <= $2
3808
- UNION ALL
3809
- SELECT COALESCE(revision.obligation_id, adjustment.obligation_id) AS obligation_id
3810
- FROM adcp_reporting_receipts receipt
3811
- LEFT JOIN adcp_reporting_revisions revision
3812
- ON receipt.receipt_kind = 'revision' AND revision.revision_id = receipt.subject_id
3813
- LEFT JOIN adcp_reporting_adjustments adjustment
3814
- ON receipt.receipt_kind = 'adjustment' AND adjustment.adjustment_id = receipt.subject_id
3815
- WHERE receipt.account_id = $1 AND receipt.consumer_id = $4
3816
- AND receipt.recorded_at > $3 AND receipt.recorded_at <= $2
3817
- UNION ALL
3818
- SELECT obligation.obligation_id
3819
- FROM adcp_reporting_destination_authorizations authz
3820
- JOIN adcp_reporting_managed_bindings binding
3821
- ON binding.account_id = authz.account_id
3822
- AND binding.destination_ref = authz.destination_ref
3823
- AND binding.authorization_generation = authz.generation
3824
- JOIN adcp_reporting_obligations obligation ON obligation.configuration_id = binding.configuration_id
3825
- WHERE authz.account_id = $1 AND authz.revoked_at IS NOT NULL
3826
- AND authz.changed_at > $3 AND authz.changed_at <= $2
3827
- ) changed WHERE changed.obligation_id IS NOT NULL`,
3828
- [query.account_id, ledgerAsOf, changesAfter, query.consumer_id ?? null]
3829
- );
3830
- return new Set(result.rows.map((row) => row.obligation_id));
3831
- }
3832
- async listSnapshotManagedBindings(client, configurationIds) {
3833
- if (!configurationIds.length) return [];
3834
- const result = await client.query(
3835
- `SELECT data FROM adcp_reporting_managed_bindings
3836
- WHERE configuration_id = ANY($1::text[]) ORDER BY configuration_id`,
3837
- [configurationIds]
3838
- );
3839
- return result.rows.map((row) => row.data);
3840
- }
3841
- async listSnapshotMaterializations(client, obligationIds, ledgerAsOf, changesAfter) {
3842
- if (!obligationIds.length) return [];
3843
- const result = await client.query(
3844
- `SELECT data FROM adcp_reporting_materializations
3845
- WHERE obligation_id = ANY($1::text[])
3846
- AND recorded_at <= $2
3847
- AND ($3::timestamptz IS NULL OR GREATEST(recorded_at, changed_at) > $3)
3848
- ORDER BY obligation_id, attempt, materialization_id
3849
- LIMIT $4`,
3850
- [obligationIds, ledgerAsOf, changesAfter ?? null, MAX_SNAPSHOT_ITEMS + 1]
3851
- );
3852
- return result.rows.map((row) => row.data);
3853
- }
3854
- async listSnapshotMaterializationProjection(client, obligationIds, ledgerAsOf) {
3855
- if (!obligationIds.length) return [];
3856
- const result = await client.query(
3857
- `SELECT materialization.data, authz.revoked_at
3858
- FROM adcp_reporting_materializations materialization
3859
- JOIN adcp_reporting_destination_authorizations authz
3860
- ON authz.account_id = materialization.account_id
3861
- AND authz.destination_ref = materialization.destination_ref
3862
- AND authz.generation = materialization.authorization_generation
3863
- WHERE materialization.obligation_id = ANY($1::text[])
3864
- AND materialization.recorded_at <= $2
3865
- ORDER BY materialization.obligation_id, materialization.attempt, materialization.materialization_id
3866
- LIMIT $3`,
3867
- [obligationIds, ledgerAsOf, MAX_SNAPSHOT_ITEMS + 1]
3868
- );
3869
- return result.rows.map((row) => {
3870
- if (!row.revoked_at || row.data.status !== "available" && row.data.status !== "delivered") return row.data;
3871
- return {
3872
- ...row.data,
3873
- status: "failed",
3874
- failed_at: row.revoked_at.toISOString(),
3875
- failure_code: "AUTHORIZATION_REVOKED"
3876
- };
3877
- });
3878
- }
3879
- async listSnapshotReceipts(client, query, obligationIds, ledgerAsOf, changesAfter, kind) {
3880
- if (!obligationIds.length || !query.consumer_id) return [];
3881
- const join = kind === "revision" ? "JOIN adcp_reporting_revisions subject ON subject.revision_id = receipt.subject_id" : "JOIN adcp_reporting_adjustments subject ON subject.adjustment_id = receipt.subject_id";
3882
- const result = await client.query(
3883
- `SELECT receipt.data FROM adcp_reporting_receipts receipt
3884
- ${join}
3885
- WHERE receipt.account_id = $1 AND receipt.consumer_id = $2
3886
- AND receipt.receipt_kind = $3 AND subject.obligation_id = ANY($4::text[])
3887
- AND receipt.recorded_at <= $5
3888
- AND ($6::timestamptz IS NULL OR receipt.recorded_at > $6)
3889
- ORDER BY subject.obligation_id, receipt.recorded_at, receipt.reporting_receipt_id
3890
- LIMIT $7`,
3891
- [
3892
- query.account_id,
3893
- query.consumer_id,
3894
- kind,
3895
- obligationIds,
3896
- ledgerAsOf,
3897
- changesAfter ?? null,
3898
- MAX_SNAPSHOT_ITEMS + 1
3899
- ]
3900
- );
3901
- return result.rows.map((row) => row.data);
3902
- }
3903
- async one(sql, params) {
3904
- const result = await this.query(sql, params);
3905
- return result.rows[0] ? clone(result.rows[0].data) : null;
3906
- }
3907
- async putImmutable(insertSql, insertParams, readSql, readParams, proposed, fingerprint, advisoryLock, leaseFence, legacyReplayEquivalent, onInserted) {
3908
- return this.transaction(
3909
- async (client) => {
3910
- const inserted = await client.query(insertSql, insertParams);
3911
- const value = inserted.rows[0]?.data ?? (await client.query(readSql, readParams)).rows[0]?.data;
3912
- if (!value) {
3913
- if (leaseFence) {
3914
- const lease = await client.query(
3915
- `SELECT 1 FROM adcp_reporting_obligations
3916
- WHERE obligation_id = $1 AND lease_owner = $2 AND lease_generation = $3
3917
- AND lease_expires_at > clock_timestamp()`,
3918
- [leaseFence.obligationId, leaseFence.owner, leaseFence.generation]
3919
- );
3920
- if (lease.rowCount === 1) throw new ReportingLedgerContinuityError();
3921
- throw new ReportingLedgerLeaseLostError();
3922
- }
3923
- throw new Error("Immutable reporting ledger replay could not be resolved");
3924
- }
3925
- if (fingerprint(value) !== fingerprint(proposed) && !legacyReplayEquivalent?.(value, proposed)) {
3926
- throw new Error("Immutable reporting ledger identity names different content");
3927
- }
3928
- if (inserted.rowCount === 1) await onInserted?.(client, clone(value));
3929
- return { inserted: inserted.rowCount === 1, value: clone(value) };
3930
- },
3931
- { preBeginAdvisoryLock: advisoryLock }
3932
- );
3933
- }
3934
- async accountLockForObligation(obligationId) {
3935
- const result = await this.query(
3936
- "SELECT account_id FROM adcp_reporting_obligations WHERE obligation_id = $1",
3937
- [obligationId]
3938
- );
3939
- const accountId = result.rows[0]?.account_id;
3940
- if (!accountId) throw new Error("Reporting obligation is unavailable");
3941
- return accountLock(accountId);
3942
- }
3943
- async accountLockForIssue(issueId) {
3944
- const result = await this.query(
3945
- "SELECT obligation_id FROM adcp_reporting_issues WHERE issue_id = $1",
3946
- [issueId]
3947
- );
3948
- return result.rows[0]?.obligation_id ? this.accountLockForObligation(result.rows[0].obligation_id) : null;
3949
- }
3950
- async accountLockForTransition(transitionId) {
3951
- const result = await this.query(
3952
- "SELECT obligation_id FROM adcp_reporting_transitions WHERE transition_id = $1",
3953
- [transitionId]
3954
- );
3955
- return result.rows[0]?.obligation_id ? this.accountLockForObligation(result.rows[0].obligation_id) : null;
3956
- }
3957
- async transaction(work, options = {}) {
3958
- let client;
3959
- try {
3960
- client = await this.pool.connect();
3961
- } catch (cause) {
3962
- throw new Error("PostgresReportingLedgerStore database connection failed", { cause });
3963
- }
3964
- let releaseError;
3965
- let transactionStarted = false;
3966
- let advisoryLockAcquired = false;
3967
- let lockTimeoutSet = false;
3968
- try {
3969
- if (options.preBeginAdvisoryLock) {
3970
- await client.query("SET lock_timeout = '5s'");
3971
- lockTimeoutSet = true;
3972
- await client.query("SELECT pg_advisory_lock(hashtextextended($1, 0))", [options.preBeginAdvisoryLock]);
3973
- advisoryLockAcquired = true;
3974
- await client.query("RESET lock_timeout");
3975
- lockTimeoutSet = false;
3976
- }
3977
- await client.query(`BEGIN ISOLATION LEVEL ${options.isolation ?? "READ COMMITTED"}`);
3978
- transactionStarted = true;
3979
- const result = await work(client);
3980
- await client.query("COMMIT");
3981
- transactionStarted = false;
3982
- return result;
3983
- } catch (error) {
3984
- if (transactionStarted) {
3985
- try {
3986
- await client.query("ROLLBACK");
3987
- transactionStarted = false;
3988
- } catch (rollbackCause) {
3989
- releaseError = rollbackCause instanceof Error ? rollbackCause : new Error("Reporting ledger rollback failed");
3990
- }
3991
- }
3992
- if (error instanceof ReportingLedgerLeaseLostError || error instanceof ReportingLedgerContinuityError || error instanceof ReportingLedgerIssueIntegrityError || error instanceof ReportingLedgerPeriodRetiredError || error instanceof ReportingLedgerSnapshotUnavailableError || error instanceof ReportingConsumerStatusConflictError || isReportingRowStoreError(error)) {
3993
- throw error;
3994
- }
3995
- throw new Error("PostgresReportingLedgerStore transaction failed", { cause: error });
3996
- } finally {
3997
- if (lockTimeoutSet) {
3998
- try {
3999
- await client.query("RESET lock_timeout");
4000
- } catch (resetCause) {
4001
- releaseError = resetCause instanceof Error ? resetCause : new Error("Reporting ledger lock timeout reset failed");
4002
- }
4003
- }
4004
- if (options.preBeginAdvisoryLock && advisoryLockAcquired) {
4005
- try {
4006
- await client.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [options.preBeginAdvisoryLock]);
4007
- } catch (unlockCause) {
4008
- releaseError = unlockCause instanceof Error ? unlockCause : new Error("Reporting ledger unlock failed");
4009
- }
4010
- }
4011
- client.release(releaseError);
4012
- }
4013
- }
4014
- async query(sql, params) {
4015
- try {
4016
- return await this.pool.query(sql, params);
4017
- } catch (cause) {
4018
- throw new Error("PostgresReportingLedgerStore database operation failed", { cause });
4019
- }
4020
- }
4021
- }
4022
- function snapshotItems(snapshot) {
4023
- const visibleRevisions = snapshot.query.finality ? snapshot.revisions.filter((value) => snapshot.query.finality.includes(value.finality)) : snapshot.revisions;
4024
- const visibleRevisionIds = new Set(visibleRevisions.map((value) => value.reporting_revision_id));
4025
- const revisions = /* @__PURE__ */ new Map();
4026
- for (const revision of visibleRevisions) {
4027
- const values = revisions.get(revision.reporting_obligation_id) ?? [];
4028
- values.push(revision);
4029
- revisions.set(revision.reporting_obligation_id, values);
4030
- }
4031
- const adjustments = /* @__PURE__ */ new Map();
4032
- for (const adjustment of snapshot.adjustments) {
4033
- if (snapshot.query.finality && !visibleRevisionIds.has(adjustment.adjusts_reporting_revision_id)) continue;
4034
- const values = adjustments.get(adjustment.reporting_obligation_id) ?? [];
4035
- values.push(adjustment);
4036
- adjustments.set(adjustment.reporting_obligation_id, values);
4037
- }
4038
- const obligationByRevision = new Map(
4039
- snapshot.revisions.map((value) => [value.reporting_revision_id, value.reporting_obligation_id])
4040
- );
4041
- const obligationByAdjustment = new Map(
4042
- snapshot.adjustments.map((value) => [value.reporting_adjustment_id, value.reporting_obligation_id])
4043
- );
4044
- const materializations = /* @__PURE__ */ new Map();
4045
- for (const materialization of snapshot.materializations ?? []) {
4046
- if (snapshot.query.finality && !visibleRevisionIds.has(materialization.reporting_revision_id)) continue;
4047
- const values = materializations.get(materialization.reporting_obligation_id) ?? [];
4048
- values.push(materialization);
4049
- materializations.set(materialization.reporting_obligation_id, values);
4050
- }
4051
- const receipts = /* @__PURE__ */ new Map();
4052
- for (const receipt of snapshot.receipts ?? []) {
4053
- if (snapshot.query.finality && !visibleRevisionIds.has(receipt.reporting_revision_id)) continue;
4054
- const obligationId = obligationByRevision.get(receipt.reporting_revision_id);
4055
- if (!obligationId) continue;
4056
- const values = receipts.get(obligationId) ?? [];
4057
- values.push(receipt);
4058
- receipts.set(obligationId, values);
4059
- }
4060
- const adjustmentReceipts = /* @__PURE__ */ new Map();
4061
- const visibleAdjustmentIds = new Set([...adjustments.values()].flat().map((value) => value.reporting_adjustment_id));
4062
- for (const receipt of snapshot.adjustmentReceipts ?? []) {
4063
- if (snapshot.query.finality && !visibleAdjustmentIds.has(receipt.reporting_adjustment_id)) continue;
4064
- const obligationId = obligationByAdjustment.get(receipt.reporting_adjustment_id);
4065
- if (!obligationId) continue;
4066
- const values = adjustmentReceipts.get(obligationId) ?? [];
4067
- values.push(receipt);
4068
- adjustmentReceipts.set(obligationId, values);
4069
- }
4070
- return [
4071
- ...snapshot.obligations.flatMap((obligation) => [
4072
- ...snapshot.query.view === "revision" ? [] : [{ kind: "obligation", value: obligation }],
4073
- ...(revisions.get(obligation.reporting_obligation_id) ?? []).map((value) => ({
4074
- kind: "revision",
4075
- value
4076
- })),
4077
- ...(adjustments.get(obligation.reporting_obligation_id) ?? []).map((value) => ({
4078
- kind: "adjustment",
4079
- value
4080
- })),
4081
- ...(materializations.get(obligation.reporting_obligation_id) ?? []).map((value) => ({
4082
- kind: "materialization",
4083
- value
4084
- })),
4085
- ...(receipts.get(obligation.reporting_obligation_id) ?? []).map((value) => ({
4086
- kind: "receipt",
4087
- value
4088
- })),
4089
- ...(adjustmentReceipts.get(obligation.reporting_obligation_id) ?? []).map((value) => ({
4090
- kind: "adjustment_receipt",
4091
- value
4092
- }))
4093
- ]),
4094
- ...snapshot.query.view === "periods" || snapshot.query.view === "revision" ? (snapshot.consumerStatuses ?? []).map((value) => ({ kind: "consumer_status", value })) : []
4095
- ];
4096
- }
4097
- function validateRevisionBinding(revision) {
4098
- const bytes = Buffer.from(
4099
- canonicalize({
4100
- reporting_revision_id: revision.reporting_revision_id,
4101
- row_count: revision.rows.length,
4102
- control_totals: revision.wireRevision.control_totals,
4103
- reporting_rows: revision.rows
4104
- }),
4105
- "utf8"
4106
- );
4107
- const sha256 = createHash("sha256").update(bytes).digest("hex");
4108
- if (revision.binding.algorithm !== "rfc8785_jcs_v1" || revision.binding.sha256 !== sha256 || revision.binding.byteCount !== bytes.byteLength || revision.binding.rowCount !== revision.rows.length || revision.wireRevision.revision_content_sha256 !== sha256) {
4109
- throw new Error("Reporting revision rows do not match their canonical binding");
4110
- }
4111
- }
4112
- function statusMatchesObligation(status, obligation) {
4113
- return status.delivery_config_id === obligation.delivery_config_id && status.delivery_config_version === obligation.delivery_config_version && status.report_definition_id === obligation.report_definition_id && compareReportingInstants(status.period.start, obligation.period.start) === 0 && compareReportingInstants(status.period.end, obligation.period.end) === 0 && status.period.source_timezone === obligation.period.sourceTimezone;
4114
- }
4115
- function currentConsumerStatus(statuses) {
4116
- const superseded = new Set(
4117
- statuses.map((value) => value.supersedes_reporting_status_id).filter((value) => Boolean(value))
4118
- );
4119
- return statuses.find((value) => !superseded.has(value.reporting_status_id));
4120
- }
4121
- function validateBoundRows(value) {
4122
- const bytes = Buffer.from(canonicalize(value.rows), "utf8");
4123
- const sha256 = createHash("sha256").update(bytes).digest("hex");
4124
- if (value.binding.algorithm !== "rfc8785_jcs_v1" || value.binding.sha256 !== sha256 || value.binding.byteCount !== bytes.byteLength || value.binding.rowCount !== value.rows.length) {
4125
- throw new Error("Reporting revision rows do not match their canonical binding");
4126
- }
4127
- }
4128
- function assertLeaseTarget(reportingObligationId, lease) {
4129
- if (lease.obligation.reporting_obligation_id !== reportingObligationId) {
4130
- throw new ReportingLedgerLeaseLostError();
4131
- }
4132
- }
4133
- function encodeCursor(snapshot, offset) {
4134
- const payload = { snapshotId: snapshot.snapshotId, offset, queryFingerprint: snapshot.queryFingerprint };
4135
- return Buffer.from(canonicalJsonV1({ ...payload, digest: digest(payload) }), "utf8").toString("base64url");
4136
- }
4137
- function decodeCursor(cursor, snapshot) {
4138
- let parsed;
4139
- try {
4140
- const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
4141
- if (!isRecord(decoded)) throw new ReportingLedgerSnapshotUnavailableError();
4142
- parsed = decoded;
4143
- } catch {
4144
- throw new ReportingLedgerSnapshotUnavailableError();
4145
- }
4146
- const payload = {
4147
- snapshotId: parsed.snapshotId,
4148
- offset: parsed.offset,
4149
- queryFingerprint: parsed.queryFingerprint
4150
- };
4151
- if (parsed.snapshotId !== snapshot.snapshotId || parsed.queryFingerprint !== snapshot.queryFingerprint || !Number.isSafeInteger(parsed.offset) || parsed.offset < 0 || parsed.digest !== digest(payload)) {
4152
- throw new ReportingLedgerSnapshotUnavailableError();
4153
- }
4154
- return parsed.offset;
4155
- }
4156
- function digest(value) {
4157
- return createHash("sha256").update(canonicalJsonV1(value)).digest("hex");
4158
- }
4159
- function isRecord(value) {
4160
- return typeof value === "object" && value !== null && !Array.isArray(value);
4161
- }
4162
- function revisionIdentityFingerprint(value) {
4163
- const { rows: _rows, createdAt: _createdAt, wireRevision, ...identity } = value;
4164
- const { created_at: _wireCreatedAt, ...stableWireRevision } = wireRevision;
4165
- return digest(JSON.parse(JSON.stringify({ ...identity, wireRevision: stableWireRevision })));
4166
- }
4167
- function adjustmentIdentityFingerprint(value) {
4168
- const { rows: _rows, createdAt: _createdAt, wireAdjustment, ...identity } = value;
4169
- const { created_at: _wireCreatedAt, ...stableWireAdjustment } = wireAdjustment;
4170
- return digest(JSON.parse(JSON.stringify({ ...identity, wireAdjustment: stableWireAdjustment })));
4171
- }
4172
- function revisionLegacyCanonicalDigestReplay(stored, proposed) {
4173
- if (stored.wireRevision.canonical_content_digest !== void 0) return false;
4174
- if (proposed.wireRevision.canonical_content_digest === void 0) return false;
4175
- const { canonical_content_digest: _digest, ...withoutDigest } = proposed.wireRevision;
4176
- return revisionIdentityFingerprint({
4177
- ...proposed,
4178
- wireRevision: withoutDigest
4179
- }) === revisionIdentityFingerprint(stored);
4180
- }
4181
- function adjustmentLegacyCanonicalDigestReplay(stored, proposed) {
4182
- if (stored.wireAdjustment.canonical_adjustment_sha256 !== void 0) return false;
4183
- const proposedDigest = proposed.wireAdjustment.canonical_adjustment_sha256;
4184
- if (proposedDigest === void 0) return false;
4185
- const { canonical_adjustment_sha256: _digest, ...withoutDigest } = proposed.wireAdjustment;
4186
- if (reportingCanonicalAdjustmentSha256V1(withoutDigest) !== proposedDigest) return false;
4187
- return adjustmentIdentityFingerprint({
4188
- ...proposed,
4189
- wireAdjustment: withoutDigest
4190
- }) === adjustmentIdentityFingerprint(stored);
4191
- }
4192
- const OBLIGATED_CONSUMERS_DEADLINE_MS = 1e4;
4193
- async function withReportingCallbackDeadline(value, milliseconds, message) {
4194
- let timer;
4195
- try {
4196
- return await Promise.race([
4197
- value,
4198
- new Promise((_resolve, reject) => {
4199
- timer = setTimeout(() => reject(new Error(message)), milliseconds);
4200
- })
4201
- ]);
4202
- } finally {
4203
- if (timer) clearTimeout(timer);
4204
- }
4205
- }
4206
- function pendingAtCutoff(value) {
4207
- const {
4208
- ready_at: _readyAt,
4209
- resource: _resource,
4210
- verification: _verification,
4211
- failed_at: _failedAt,
4212
- failure_code: _failureCode,
4213
- ...pending
4214
- } = clone(value);
4215
- return { ...pending, status: "pending" };
4216
- }
4217
- const MANAGED_DUE_ARM = ` OR (EXISTS (
4218
- SELECT 1 FROM adcp_reporting_managed_bindings binding
4219
- WHERE binding.configuration_id = obligation.configuration_id
4220
- AND (
4221
- -- A managed change after the last transition. Health is
4222
- -- composed from managed state, but only Core evidence
4223
- -- and the clock used to schedule a reconcile \u2014 so a
4224
- -- settlement, revocation, receipt, consumer status or
4225
- -- roster-visible change arriving after a complete left
4226
- -- that complete persisted and webhooked forever while
4227
- -- a live read of the same obligation degraded.
4228
- EXISTS (
4229
- SELECT 1 FROM adcp_reporting_materializations m
4230
- WHERE m.obligation_id = obligation.obligation_id
4231
- AND m.changed_at > watermark.since
4232
- AND m.changed_at <= $2)
4233
- OR EXISTS (
4234
- -- Adjustments are managed health inputs too: after an
4235
- -- accepted billing receipt, a correction makes the live
4236
- -- read action_required while the persisted health and
4237
- -- its webhook stayed complete.
4238
- SELECT 1 FROM adcp_reporting_adjustments adj
4239
- WHERE adj.obligation_id = obligation.obligation_id
4240
- AND adj.recorded_at > watermark.since
4241
- AND adj.recorded_at <= $2)
4242
- OR EXISTS (
4243
- SELECT 1 FROM adcp_reporting_destination_authorizations authz
4244
- WHERE authz.account_id = binding.account_id
4245
- AND authz.destination_ref = binding.destination_ref
4246
- AND GREATEST(authz.changed_at, COALESCE(authz.cleanup_completed_at, authz.changed_at))
4247
- > watermark.since
4248
- AND authz.changed_at <= $2)
4249
- -- Driven from this obligation's subjects into the
4250
- -- receipt table's subject index. Asking the global
4251
- -- receipt table which of its rows belong to this
4252
- -- obligation supplied neither account nor consumer, so
4253
- -- every candidate obligation in every sweep re-scanned
4254
- -- the whole receipt history.
4255
- OR EXISTS (
4256
- SELECT 1 FROM adcp_reporting_revisions rv
4257
- JOIN adcp_reporting_receipts receipt
4258
- ON receipt.receipt_kind = 'revision'
4259
- AND receipt.subject_id = rv.revision_id
4260
- WHERE rv.obligation_id = obligation.obligation_id
4261
- AND receipt.recorded_at > watermark.since
4262
- AND receipt.recorded_at <= $2)
4263
- OR EXISTS (
4264
- SELECT 1 FROM adcp_reporting_adjustments aj
4265
- JOIN adcp_reporting_receipts receipt
4266
- ON receipt.receipt_kind = 'adjustment'
4267
- AND receipt.subject_id = aj.adjustment_id
4268
- WHERE aj.obligation_id = obligation.obligation_id
4269
- AND receipt.recorded_at > watermark.since
4270
- AND receipt.recorded_at <= $2)
4271
- OR EXISTS (
4272
- SELECT 1 FROM adcp_reporting_consumer_statuses status
4273
- WHERE status.obligation_id = obligation.obligation_id
4274
- AND status.created_at > watermark.since
4275
- AND status.created_at <= $2)
4276
- -- An external roster change writes nothing here at all,
4277
- -- so the only durable trace is the version recorded at
4278
- -- the last reconcile. A null one means never reconciled.
4279
- OR state.processed_roster_version IS NULL
4280
- -- A roster lives outside this database, so the only way
4281
- -- a change can re-arm anything is if someone records
4282
- -- it. Every read of the roster publishes the version it
4283
- -- saw; a difference from what was last reconciled is a
4284
- -- due condition. Without this the first reconcile made
4285
- -- processed_roster_version non-null and no later roster
4286
- -- change could ever schedule work again.
4287
- OR state.current_roster_version IS DISTINCT FROM state.processed_roster_version
4288
- -- A retained resource expiring is a health change with
4289
- -- no row written anywhere, so nothing else can notice it.
4290
- OR EXISTS (
4291
- SELECT 1 FROM adcp_reporting_materializations m
4292
- WHERE m.obligation_id = obligation.obligation_id
4293
- AND m.status IN ('available', 'delivered')
4294
- AND (m.data -> 'resource' ->> 'expires_at')::timestamptz <= $2
4295
- AND (m.data -> 'resource' ->> 'expires_at')::timestamptz
4296
- > watermark.since)
4297
- )
4298
- ))
4299
- `;
4300
- function obligatedConsumerIdsFor(supplied, observed) {
4301
- return supplied.complete === true ? [...new Set(supplied.ids)].sort() : [.../* @__PURE__ */ new Set([...supplied.ids, ...observed])].sort();
4302
- }
4303
- function obligatedConsumerRosterVersionFor(supplied, ids) {
4304
- return supplied.version ?? digest({ ids, complete: supplied.complete === true });
4305
- }
4306
- const MAX_ROSTER_REFRESH_PER_SWEEP = 25;
4307
- const MAX_LIFECYCLE_BACKOFF_SECONDS = 900;
4308
- function rfc3339Microseconds(expression) {
4309
- return `to_char(${expression} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
4310
- }
4311
- function accountLock(accountId) {
4312
- return `adcp-reporting-account:${accountId}`;
4313
- }
4314
- function objectLock(bindingId, objectRef) {
4315
- return `adcp-reporting-object:${bindingId}:${objectRef}`;
4316
- }
4317
- async function resolveStoredFinalityBaseline(transaction, obligationId) {
4318
- const latest = await transaction.query(
4319
- `SELECT transition_id, data->>'finality' AS finality
4320
- FROM adcp_reporting_transitions
4321
- WHERE obligation_id = $1 ORDER BY transition_sequence DESC LIMIT 1
4322
- FOR UPDATE`,
4323
- [obligationId]
4324
- );
4325
- const previous = latest.rows[0];
4326
- if (!previous) return "none";
4327
- if (previous.finality) return previous.finality;
4328
- await transaction.query(
4329
- `UPDATE adcp_reporting_transitions
4330
- SET data = data || jsonb_build_object('finality', 'none')
4331
- WHERE transition_id = $1 AND NOT (data ? 'finality')`,
4332
- [previous.transition_id]
4333
- );
4334
- return "none";
4335
- }
4336
- async function assertNoLegacyPendingTransitions(transaction, obligationId) {
4337
- const pending = await transaction.query(
4338
- `SELECT transition_id FROM adcp_reporting_transitions
4339
- WHERE obligation_id = $1 AND data->>'notifiedAt' IS NULL
4340
- LIMIT 1`,
4341
- [obligationId]
4342
- );
4343
- if (pending.rowCount !== 0) {
4344
- throw new Error(
4345
- "Drain or explicitly resolve legacy pending reporting transitions before enabling transactional notification activity"
4346
- );
4347
- }
4348
- }
4349
- async function assertFinalityWriterFence(work) {
4350
- try {
4351
- return await work();
4352
- } catch (error) {
4353
- if (isFinalityWriterFenceViolation(error)) {
4354
- throw new Error(
4355
- "Reporting transition rejected by the finality writer fence: every transition written after REPORTING_LEDGER_FINALITY_WRITER_FENCE_MIGRATION must record an observed finality. Drain pre-SDK-14 writers before installing the fence.",
4356
- { cause: error }
4357
- );
4358
- }
4359
- throw error;
4360
- }
4361
- }
4362
- function isFinalityWriterFenceViolation(error) {
4363
- for (let current = error; current instanceof Error; current = current.cause) {
4364
- const candidate = current;
4365
- if (candidate.code === "23514" && candidate.constraint === REPORTING_LEDGER_FINALITY_FENCE_CONSTRAINT) return true;
4366
- }
4367
- return false;
4368
- }
4369
- function consumerStatusChainKeyForObligation(obligation) {
4370
- return reportingConsumerStatusChainKeyFromIdentityV1({
4371
- delivery_config_id: obligation.delivery_config_id,
4372
- delivery_config_version: obligation.delivery_config_version,
4373
- report_definition_id: obligation.report_definition_id,
4374
- periodStart: obligation.period.start,
4375
- periodEnd: obligation.period.end,
4376
- sourceTimezone: obligation.period.sourceTimezone
4377
- });
4378
- }
4379
- function boundedConsumerStatusErrorField(errorField) {
4380
- return errorField && !errorField.includes("\0") && isWellFormedUnicodeString(errorField) && Buffer.byteLength(errorField, "utf8") <= REPORTING_CONSUMER_STATUS_ERROR_FIELD_MAX_BYTES ? { errorField } : {};
4381
- }
4382
- function boundedConsumerStatusErrorKeyword(errorKeyword) {
4383
- return errorKeyword && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(errorKeyword) ? { errorKeyword } : {};
4384
- }
4385
- function boundedConsumerStatusSafeMessage(safeMessage) {
4386
- const wellFormed = Buffer.from(safeMessage, "utf8").toString("utf8").replace(/\u0000/g, "\uFFFD");
4387
- if (Buffer.byteLength(wellFormed, "utf8") <= REPORTING_CONSUMER_STATUS_ERROR_MESSAGE_MAX_BYTES) {
4388
- return wellFormed || "Reporting consumer status was rejected";
4389
- }
4390
- let bounded = "";
4391
- let bytes = 0;
4392
- for (const character of wellFormed) {
4393
- const characterBytes = Buffer.byteLength(character, "utf8");
4394
- if (bytes + characterBytes > REPORTING_CONSUMER_STATUS_ERROR_MESSAGE_MAX_BYTES) break;
4395
- bounded += character;
4396
- bytes += characterBytes;
4397
- }
4398
- return bounded || "Reporting consumer status was rejected";
4399
- }
4400
- function storedResultsJsonBytes(results) {
4401
- return Buffer.byteLength(JSON.stringify(results), "utf8");
4402
- }
4403
- function boundStoredConsumerStatusResults(storedResults, results) {
4404
- if (storedResultsJsonBytes(storedResults) <= REPORTING_CONSUMER_STATUS_BATCH_RESULT_MAX_BYTES) return;
4405
- for (let index = storedResults.length - 1; index >= 0; index -= 1) {
4406
- const stored = storedResults[index];
4407
- const result = results[index];
4408
- if (stored?.errorField !== void 0) {
4409
- delete stored.errorField;
4410
- if (result && !("value" in result)) delete result.errorField;
4411
- if (storedResultsJsonBytes(storedResults) <= REPORTING_CONSUMER_STATUS_BATCH_RESULT_MAX_BYTES) return;
4412
- }
4413
- }
4414
- const fallback = "Reporting consumer status was rejected";
4415
- for (let index = storedResults.length - 1; index >= 0; index -= 1) {
4416
- const stored = storedResults[index];
4417
- const result = results[index];
4418
- if (stored?.kind === "failed" && stored.safeMessage !== fallback) {
4419
- stored.safeMessage = fallback;
4420
- if (result && !("value" in result)) result.safeMessage = fallback;
4421
- if (storedResultsJsonBytes(storedResults) <= REPORTING_CONSUMER_STATUS_BATCH_RESULT_MAX_BYTES) return;
4422
- }
4423
- }
4424
- throw new RangeError("Reporting consumer status replay metadata exceeds 64 KiB");
4425
- }
4426
- function checkpointScopeFingerprint(query) {
4427
- const sorted = (value) => [...value].sort();
4428
- return digest({
4429
- account_id: query.account_id,
4430
- ...query.consumer_id ? { consumer_id: query.consumer_id } : {},
4431
- ...query.media_buy_ids ? { media_buy_ids: sorted(query.media_buy_ids) } : {},
4432
- ...query.delivery_config_ids ? { delivery_config_ids: sorted(query.delivery_config_ids) } : {},
4433
- ...query.feed_purposes ? { feed_purposes: sorted(query.feed_purposes) } : {},
4434
- ...query.health ? { health: sorted(query.health) } : {},
4435
- ...query.finality ? { finality: sorted(query.finality) } : {},
4436
- ...query.reporting_revision_id ? { reporting_revision_id: query.reporting_revision_id } : {},
4437
- ...query.period ? { period: query.period } : {}
4438
- });
4439
- }
4440
- function clone(value) {
4441
- return structuredClone(value);
4442
- }
4443
- function positiveInteger(value, name) {
4444
- if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
4445
- }
4446
- function withoutRows(value) {
4447
- const { rows: _rows, ...rest } = value;
4448
- return rest;
4449
- }
4450
- function withRows(value, rows) {
4451
- return Array.isArray(value.rows) ? value : { ...value, rows: clone(rows) };
4452
- }
4453
- export {
4454
- PostgresReportingLedgerStore,
4455
- REPORTING_LEDGER_FINALITY_FENCE_CONSTRAINT,
4456
- REPORTING_LEDGER_FINALITY_WRITER_FENCE_MIGRATION,
4457
- REPORTING_LEDGER_MIGRATION,
4458
- ReportingLedgerContinuityError,
4459
- ReportingLedgerLeaseLostError,
4460
- ReportingLedgerPeriodRetiredError,
4461
- sweepExpiredReportingLedgerState
4462
- };