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

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