@adcp/sdk 15.5.0 → 15.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +15 -0
  2. package/dist/lib/auth/oauth/index.d.mts +1 -0
  3. package/dist/lib/auth/oauth/index.d.ts +1 -0
  4. package/dist/lib/auth/oauth/index.js +5 -0
  5. package/dist/lib/auth/oauth/index.mjs +3 -0
  6. package/dist/lib/auth/oauth/pending-web-flow.d.mts +16 -0
  7. package/dist/lib/auth/oauth/pending-web-flow.d.ts +16 -0
  8. package/dist/lib/auth/oauth/pending-web-flow.js +66 -0
  9. package/dist/lib/auth/oauth/pending-web-flow.mjs +41 -0
  10. package/dist/lib/auth/oauth/web-flow.js +3 -3
  11. package/dist/lib/auth/oauth/web-flow.mjs +3 -3
  12. package/dist/lib/registry/types.generated.d.mts +63 -1
  13. package/dist/lib/registry/types.generated.d.ts +63 -1
  14. package/dist/lib/reporting/azure/index.d.mts +26 -0
  15. package/dist/lib/reporting/azure/index.d.ts +26 -0
  16. package/dist/lib/reporting/azure/index.js +247 -0
  17. package/dist/lib/reporting/azure/index.mjs +230 -0
  18. package/dist/lib/reporting/bigquery/index.d.mts +127 -0
  19. package/dist/lib/reporting/bigquery/index.d.ts +127 -0
  20. package/dist/lib/reporting/bigquery/index.js +379 -0
  21. package/dist/lib/reporting/bigquery/index.mjs +341 -0
  22. package/dist/lib/reporting/ledger/handler.js +20 -8
  23. package/dist/lib/reporting/ledger/handler.mjs +20 -8
  24. package/dist/lib/reporting/ledger/postgres.d.mts +21 -0
  25. package/dist/lib/reporting/ledger/postgres.d.ts +21 -0
  26. package/dist/lib/reporting/ledger/postgres.js +35 -2
  27. package/dist/lib/reporting/ledger/postgres.mjs +35 -2
  28. package/dist/lib/reporting/ledger/row-storage-object.js +31 -8
  29. package/dist/lib/reporting/ledger/row-storage-object.mjs +32 -9
  30. package/dist/lib/reporting/ledger/row-storage-provider-io.d.mts +0 -7
  31. package/dist/lib/reporting/ledger/row-storage-provider-io.d.ts +0 -7
  32. package/dist/lib/reporting/ledger/row-storage-provider-io.js +0 -10
  33. package/dist/lib/reporting/ledger/row-storage-provider-io.mjs +0 -8
  34. package/dist/lib/reporting/s3/index.d.mts +29 -0
  35. package/dist/lib/reporting/s3/index.d.ts +29 -0
  36. package/dist/lib/reporting/s3/index.js +324 -0
  37. package/dist/lib/reporting/s3/index.mjs +317 -0
  38. package/dist/lib/reporting/service/index.d.mts +39 -22
  39. package/dist/lib/reporting/service/index.d.ts +39 -22
  40. package/dist/lib/reporting/service/index.js +75 -64
  41. package/dist/lib/reporting/service/index.mjs +79 -63
  42. package/dist/lib/reporting/service/maintenance.d.mts +91 -0
  43. package/dist/lib/reporting/service/maintenance.d.ts +91 -0
  44. package/dist/lib/reporting/service/maintenance.js +140 -0
  45. package/dist/lib/reporting/service/maintenance.mjs +114 -0
  46. package/dist/lib/schemas/browser.d.mts +14 -0
  47. package/dist/lib/schemas/browser.d.ts +14 -0
  48. package/dist/lib/schemas/browser.js +48 -0
  49. package/dist/lib/schemas/browser.mjs +20 -0
  50. package/dist/lib/schemas/index.d.mts +2 -120
  51. package/dist/lib/schemas/index.d.ts +2 -120
  52. package/dist/lib/schemas/index.js +3 -11
  53. package/dist/lib/schemas/index.mjs +1 -9
  54. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  55. package/dist/lib/testing/index.d.mts +1 -0
  56. package/dist/lib/testing/index.d.ts +1 -0
  57. package/dist/lib/testing/index.js +3 -0
  58. package/dist/lib/testing/index.mjs +2 -0
  59. package/dist/lib/testing/pending-web-flow-store.d.mts +20 -0
  60. package/dist/lib/testing/pending-web-flow-store.d.ts +20 -0
  61. package/dist/lib/testing/pending-web-flow-store.js +132 -0
  62. package/dist/lib/testing/pending-web-flow-store.mjs +98 -0
  63. package/dist/lib/testing/storyboard/validations.d.mts +1 -1
  64. package/dist/lib/testing/storyboard/validations.d.ts +1 -1
  65. package/dist/lib/validation/get-products-request.d.mts +122 -0
  66. package/dist/lib/validation/get-products-request.d.ts +122 -0
  67. package/dist/lib/validation/get-products-request.js +39 -0
  68. package/dist/lib/validation/get-products-request.mjs +14 -0
  69. package/dist/lib/version.d.mts +3 -3
  70. package/dist/lib/version.d.ts +3 -3
  71. package/dist/lib/version.js +3 -3
  72. package/dist/lib/version.mjs +3 -3
  73. package/docs/TYPE-SUMMARY.md +1 -1
  74. package/docs/ZOD-SCHEMAS.md +13 -1
  75. package/docs/guides/OAUTH-ISSUER-BINDING.md +6 -0
  76. package/docs/guides/REPORTING-LEDGER.md +254 -0
  77. package/docs/guides/REPORTING-OPERATIONS.md +82 -1
  78. package/docs/guides/WEB-OAUTH.md +48 -1
  79. package/docs/llms.txt +1 -1
  80. package/docs/migration-14.x-rc-worksheet.md +6 -4
  81. package/package.json +64 -1
@@ -0,0 +1,341 @@
1
+ import { createHash } from "crypto";
2
+ import { promises as fs } from "fs";
3
+ import { tmpdir } from "os";
4
+ import * as path from "path";
5
+ const REPORTING_WAREHOUSE_SINK_MIGRATION = `
6
+ CREATE TABLE IF NOT EXISTS adcp_reporting_warehouse_sinks (
7
+ sink_name TEXT PRIMARY KEY CHECK (sink_name ~ '^[A-Za-z0-9_.:-]{1,128}$'),
8
+ committed_cursor TEXT,
9
+ planned_from_cursor TEXT,
10
+ planned_to_cursor TEXT,
11
+ planned_revisions JSONB,
12
+ planned_rows_attempt INTEGER NOT NULL DEFAULT 0 CHECK (planned_rows_attempt >= 0),
13
+ planned_revisions_attempt INTEGER NOT NULL DEFAULT 0 CHECK (planned_revisions_attempt >= 0),
14
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
15
+ CHECK ((planned_to_cursor IS NULL) = (planned_revisions IS NULL))
16
+ );
17
+ `.trim();
18
+ const PROJECT = /^[a-z][a-z0-9-]{4,61}[a-z0-9]$/;
19
+ const DATASET_OR_TABLE = /^[A-Za-z_][A-Za-z0-9_]{0,1023}$/;
20
+ const NAME = /^[A-Za-z0-9_.:-]{1,128}$/;
21
+ function createBigQueryReportingWarehouseSinkV1(options) {
22
+ if (!NAME.test(options.name)) throw new TypeError("Warehouse sink name must be a bounded non-secret identifier");
23
+ if (options.projectId !== void 0 && !PROJECT.test(options.projectId)) {
24
+ throw new TypeError("BigQuery projectId is invalid");
25
+ }
26
+ for (const [label, value] of [
27
+ ["datasetId", options.datasetId],
28
+ ["rowsTableId", options.rowsTableId],
29
+ ["revisionsTableId", options.revisionsTableId]
30
+ ]) {
31
+ if (!DATASET_OR_TABLE.test(value)) throw new TypeError(`BigQuery ${label} is invalid`);
32
+ }
33
+ const loadTimeout = options.loadTimeoutMilliseconds ?? 6e5;
34
+ if (!Number.isSafeInteger(loadTimeout) || loadTimeout < 1e3 || loadTimeout > 6 * 36e5) {
35
+ throw new RangeError("loadTimeoutMilliseconds must be between 1 second and 6 hours");
36
+ }
37
+ const maxRevisions = options.maxRevisionsPerBatch ?? 200;
38
+ if (!Number.isSafeInteger(maxRevisions) || maxRevisions < 1 || maxRevisions > 1e4) {
39
+ throw new RangeError("maxRevisionsPerBatch must be between 1 and 10000");
40
+ }
41
+ const tables = {
42
+ projectId: options.projectId,
43
+ datasetId: options.datasetId,
44
+ rowsTableId: options.rowsTableId,
45
+ revisionsTableId: options.revisionsTableId
46
+ };
47
+ const dataset = options.bigquery.dataset(options.datasetId, {
48
+ ...options.projectId ? { projectId: options.projectId } : {},
49
+ ...options.location ? { location: options.location } : {}
50
+ });
51
+ async function loadOnce(tableId, jobId, lines, signal) {
52
+ signal?.throwIfAborted();
53
+ const directory = await fs.mkdtemp(path.join(tmpdir(), "adcp-warehouse-"));
54
+ const file = path.join(directory, `${jobId}.ndjson`);
55
+ try {
56
+ await fs.writeFile(file, lines.length ? lines.join("\n") + "\n" : "");
57
+ try {
58
+ await withinDeadline(
59
+ () => dataset.table(tableId).load(file, {
60
+ jobId,
61
+ sourceFormat: "NEWLINE_DELIMITED_JSON",
62
+ writeDisposition: "WRITE_APPEND",
63
+ jobTimeoutMs: String(loadTimeout),
64
+ ...options.location ? { location: options.location } : {},
65
+ ...options.labels ? { labels: { ...options.labels } } : {}
66
+ }),
67
+ loadTimeout,
68
+ signal
69
+ );
70
+ } catch (error) {
71
+ if (!isAlreadyExists(error)) throw error;
72
+ const metadata = await withinDeadline(
73
+ () => options.bigquery.job(jobId, options.location ? { location: options.location } : void 0).getMetadata(),
74
+ loadTimeout,
75
+ signal
76
+ );
77
+ assertJobSucceeded(metadata);
78
+ }
79
+ } finally {
80
+ await fs.rm(directory, { recursive: true, force: true });
81
+ }
82
+ }
83
+ return {
84
+ defaultTablesSql: reportingWarehouseDefaultTablesSqlV1(tables),
85
+ currentRowsViewSql: reportingWarehouseCurrentRowsViewSqlV1(tables),
86
+ async runOnce(runOptions = {}) {
87
+ const signal = runOptions.signal;
88
+ await options.db.query(
89
+ `INSERT INTO adcp_reporting_warehouse_sinks (sink_name) VALUES ($1) ON CONFLICT DO NOTHING`,
90
+ [options.name]
91
+ );
92
+ const state = (await options.db.query(
93
+ `SELECT committed_cursor, planned_from_cursor, planned_to_cursor, planned_revisions,
94
+ planned_rows_attempt, planned_revisions_attempt
95
+ FROM adcp_reporting_warehouse_sinks WHERE sink_name = $1`,
96
+ [options.name]
97
+ )).rows[0];
98
+ let plan = state.planned_to_cursor && state.planned_revisions ? {
99
+ from: state.planned_from_cursor,
100
+ to: state.planned_to_cursor,
101
+ revisions: state.planned_revisions,
102
+ rowsAttempt: state.planned_rows_attempt,
103
+ revisionsAttempt: state.planned_revisions_attempt
104
+ } : void 0;
105
+ if (!plan) {
106
+ const page = await options.store.changesAfter({
107
+ ...state.committed_cursor ? { cursor: state.committed_cursor } : {},
108
+ ...options.account_id ? { account_id: options.account_id } : {},
109
+ kinds: ["revision"],
110
+ limit: maxRevisions
111
+ });
112
+ if (page.records.length === 0) {
113
+ if (page.cursor !== state.committed_cursor) await commit(page.cursor);
114
+ return { idle: true, revisions: 0, rows: 0, missing: [] };
115
+ }
116
+ plan = {
117
+ from: state.committed_cursor,
118
+ to: page.cursor,
119
+ revisions: page.records.map((record) => ({ id: record.record_id, account_id: record.account_id })),
120
+ rowsAttempt: 0,
121
+ revisionsAttempt: 0
122
+ };
123
+ await options.db.query(
124
+ `UPDATE adcp_reporting_warehouse_sinks
125
+ SET planned_from_cursor = $2, planned_to_cursor = $3, planned_revisions = $4::jsonb,
126
+ planned_rows_attempt = 0, planned_revisions_attempt = 0, updated_at = clock_timestamp()
127
+ WHERE sink_name = $1`,
128
+ [options.name, plan.from, plan.to, JSON.stringify(plan.revisions)]
129
+ );
130
+ }
131
+ const rowLines = [];
132
+ const revisionLines = [];
133
+ const missing = [];
134
+ for (const planned of plan.revisions) {
135
+ signal?.throwIfAborted();
136
+ const revision = await options.store.getRevisionMetadata(planned.id, planned.account_id);
137
+ if (!revision) {
138
+ missing.push(planned.id);
139
+ continue;
140
+ }
141
+ const periodDate = sourceLocalDate(
142
+ revision.wireRevision.period?.start,
143
+ revision.wireRevision.period?.source_timezone
144
+ );
145
+ const base = {
146
+ reporting_revision_id: revision.reporting_revision_id,
147
+ reporting_obligation_id: revision.reporting_obligation_id,
148
+ account_id: planned.account_id,
149
+ revision_number: revision.revisionNumber,
150
+ finality: revision.finality,
151
+ period_date: periodDate
152
+ };
153
+ for (let offset = 0; offset < revision.binding.rowCount; ) {
154
+ const page = await options.store.readRevisionRows({
155
+ reporting_revision_id: revision.reporting_revision_id,
156
+ account_id: planned.account_id,
157
+ offset,
158
+ limit: 5e3,
159
+ ...signal ? { signal } : {}
160
+ });
161
+ if (!page || page.rows.length === 0) throw new Error("Reporting revision rows ended early");
162
+ page.rows.forEach((row, index) => {
163
+ const ordinal = offset + index;
164
+ const mapped = options.mapRow ? options.mapRow(row, { revision, ordinal }) : { row };
165
+ rowLines.push(JSON.stringify({ ...mapped, ...base, ordinal }));
166
+ });
167
+ offset += page.rows.length;
168
+ }
169
+ revisionLines.push(
170
+ JSON.stringify({
171
+ ...base,
172
+ supersedes_reporting_revision_id: revision.supersedes_reporting_revision_id ?? null,
173
+ period_start: revision.wireRevision.period?.start ?? null,
174
+ period_end: revision.wireRevision.period?.end ?? null,
175
+ source_timezone: revision.wireRevision.period?.source_timezone ?? null,
176
+ observed_at: revision.observedAt,
177
+ data_through: revision.dataThrough,
178
+ revision_content_sha256: revision.binding.sha256,
179
+ row_count: revision.binding.rowCount,
180
+ control_totals: revision.wireRevision.control_totals ?? []
181
+ })
182
+ );
183
+ }
184
+ const baseJobId = reportingWarehouseJobIdV1(options.name, plan.from, plan.to);
185
+ const suffix = (attempt) => attempt ? `_a${attempt}` : "";
186
+ const steps = [
187
+ { table: options.rowsTableId, kind: "rows", lines: rowLines, attempt: plan.rowsAttempt },
188
+ { table: options.revisionsTableId, kind: "revs", lines: revisionLines, attempt: plan.revisionsAttempt }
189
+ ];
190
+ for (const step of steps) {
191
+ if (!step.lines.length) continue;
192
+ try {
193
+ await loadOnce(step.table, `${baseJobId}_${step.kind}${suffix(step.attempt)}`, [...step.lines], signal);
194
+ } catch (error) {
195
+ if (isFinishedWithError(error)) {
196
+ const column = step.kind === "rows" ? "planned_rows_attempt" : "planned_revisions_attempt";
197
+ await options.db.query(
198
+ `UPDATE adcp_reporting_warehouse_sinks SET ${column} = ${column} + 1,
199
+ updated_at = clock_timestamp() WHERE sink_name = $1`,
200
+ [options.name]
201
+ );
202
+ }
203
+ throw error;
204
+ }
205
+ }
206
+ await commit(plan.to);
207
+ return { idle: false, revisions: revisionLines.length, rows: rowLines.length, missing };
208
+ }
209
+ };
210
+ async function commit(cursor) {
211
+ await options.db.query(
212
+ `UPDATE adcp_reporting_warehouse_sinks
213
+ SET committed_cursor = $2, planned_from_cursor = NULL, planned_to_cursor = NULL,
214
+ planned_revisions = NULL, planned_rows_attempt = 0, planned_revisions_attempt = 0,
215
+ updated_at = clock_timestamp()
216
+ WHERE sink_name = $1`,
217
+ [options.name, cursor]
218
+ );
219
+ await options.store.saveFeedConsumerCursor(options.name, cursor);
220
+ }
221
+ }
222
+ function reportingWarehouseJobIdV1(name, from, to) {
223
+ return `adcp_rr_${createHash("sha256").update(JSON.stringify([name, from, to])).digest("hex").slice(0, 40)}`;
224
+ }
225
+ function qualified(tables, table) {
226
+ return `\`${tables.projectId ? `${tables.projectId}.` : ""}${tables.datasetId}.${table}\``;
227
+ }
228
+ function reportingWarehouseDefaultTablesSqlV1(tables) {
229
+ return `
230
+ CREATE TABLE IF NOT EXISTS ${qualified(tables, tables.rowsTableId)} (
231
+ reporting_revision_id STRING NOT NULL,
232
+ reporting_obligation_id STRING NOT NULL,
233
+ account_id STRING NOT NULL,
234
+ revision_number INT64 NOT NULL,
235
+ finality STRING NOT NULL,
236
+ period_date DATE,
237
+ ordinal INT64 NOT NULL,
238
+ row JSON
239
+ )
240
+ PARTITION BY period_date
241
+ CLUSTER BY account_id, reporting_revision_id;
242
+
243
+ CREATE TABLE IF NOT EXISTS ${qualified(tables, tables.revisionsTableId)} (
244
+ reporting_revision_id STRING NOT NULL,
245
+ reporting_obligation_id STRING NOT NULL,
246
+ account_id STRING NOT NULL,
247
+ revision_number INT64 NOT NULL,
248
+ finality STRING NOT NULL,
249
+ period_date DATE,
250
+ supersedes_reporting_revision_id STRING,
251
+ period_start TIMESTAMP,
252
+ period_end TIMESTAMP,
253
+ source_timezone STRING,
254
+ observed_at TIMESTAMP,
255
+ data_through TIMESTAMP,
256
+ revision_content_sha256 STRING NOT NULL,
257
+ row_count INT64 NOT NULL,
258
+ control_totals JSON
259
+ )
260
+ PARTITION BY period_date
261
+ CLUSTER BY account_id, reporting_obligation_id;`.trim();
262
+ }
263
+ function reportingWarehouseCurrentRowsViewSqlV1(tables) {
264
+ const view = tables.viewId ?? "current_rows";
265
+ if (!DATASET_OR_TABLE.test(view)) throw new TypeError("BigQuery viewId is invalid");
266
+ return `
267
+ CREATE OR REPLACE VIEW ${qualified(tables, view)} AS
268
+ SELECT row_data.*
269
+ FROM ${qualified(tables, tables.rowsTableId)} AS row_data
270
+ JOIN (
271
+ SELECT reporting_obligation_id,
272
+ ARRAY_AGG(reporting_revision_id ORDER BY revision_number DESC LIMIT 1)[OFFSET(0)] AS reporting_revision_id
273
+ FROM ${qualified(tables, tables.revisionsTableId)}
274
+ GROUP BY reporting_obligation_id
275
+ ) AS current_revision
276
+ USING (reporting_obligation_id, reporting_revision_id);`.trim();
277
+ }
278
+ function sourceLocalDate(start, timeZone) {
279
+ if (!start) return null;
280
+ const instant = new Date(start);
281
+ if (Number.isNaN(instant.getTime())) return null;
282
+ try {
283
+ const parts = new Intl.DateTimeFormat("en-CA", {
284
+ timeZone: timeZone ?? "UTC",
285
+ year: "numeric",
286
+ month: "2-digit",
287
+ day: "2-digit"
288
+ }).format(instant);
289
+ return parts;
290
+ } catch {
291
+ return instant.toISOString().slice(0, 10);
292
+ }
293
+ }
294
+ async function withinDeadline(operation, milliseconds, signal) {
295
+ signal?.throwIfAborted();
296
+ let timer;
297
+ let onAbort;
298
+ try {
299
+ return await Promise.race([
300
+ operation(),
301
+ new Promise((_, reject) => {
302
+ timer = setTimeout(
303
+ () => reject(new Error("BigQuery call exceeded its deadline; the batch will be retried")),
304
+ milliseconds
305
+ );
306
+ if (signal) {
307
+ onAbort = () => reject(signal.reason ?? new Error("aborted"));
308
+ signal.addEventListener("abort", onAbort, { once: true });
309
+ }
310
+ })
311
+ ]);
312
+ } finally {
313
+ if (timer) clearTimeout(timer);
314
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
315
+ }
316
+ }
317
+ function isAlreadyExists(error) {
318
+ const record = error;
319
+ return record?.code === 409 || !!record?.errors?.some((entry) => entry.reason === "duplicate");
320
+ }
321
+ function isFinishedWithError(error) {
322
+ return error?.adcpJobFailed === true;
323
+ }
324
+ function assertJobSucceeded(metadata) {
325
+ const record = Array.isArray(metadata) ? metadata[0] : metadata;
326
+ if (record?.status?.state !== "DONE") {
327
+ throw new Error("A BigQuery load job for this batch is still running; retry later");
328
+ }
329
+ if (record.status.errorResult) {
330
+ const failure = new Error("A BigQuery load job for this batch failed; it will be retried as a new attempt");
331
+ Object.defineProperty(failure, "adcpJobFailed", { value: true });
332
+ throw failure;
333
+ }
334
+ }
335
+ export {
336
+ REPORTING_WAREHOUSE_SINK_MIGRATION,
337
+ createBigQueryReportingWarehouseSinkV1,
338
+ reportingWarehouseCurrentRowsViewSqlV1,
339
+ reportingWarehouseDefaultTablesSqlV1,
340
+ reportingWarehouseJobIdV1
341
+ };
@@ -410,6 +410,12 @@ function createReportingStatusHandler(store, options) {
410
410
  };
411
411
  }
412
412
  const REPORTING_STATUS_REVISION_VIEW_MAX_ROWS = 1e4;
413
+ function reportingRevisionNotFound() {
414
+ return new import_async_outcome.AdcpError("REFERENCE_NOT_FOUND", {
415
+ message: "Reporting revision is unavailable",
416
+ field: "reporting_revision_id"
417
+ });
418
+ }
413
419
  function createReportingDeliveryHandler(store) {
414
420
  const activeReadsByAccount = /* @__PURE__ */ new Map();
415
421
  return async (request, context) => {
@@ -428,14 +434,15 @@ function createReportingDeliveryHandler(store) {
428
434
  try {
429
435
  const raw = request;
430
436
  if (typeof raw.reporting_revision_id !== "string" || !raw.reporting_revision_id) {
431
- throw new Error("Ledger delivery reads require reporting_revision_id");
437
+ throw new import_async_outcome.AdcpError("VALIDATION_ERROR", {
438
+ message: "Ledger delivery reads require reporting_revision_id",
439
+ field: "reporting_revision_id"
440
+ });
432
441
  }
433
442
  const revision = await (0, import_revision_metadata.getRevisionMetadataFromStore)(store, raw.reporting_revision_id, accountId);
434
- if (!revision) throw new Error("Reporting revision is unavailable");
443
+ if (!revision) throw reportingRevisionNotFound();
435
444
  const obligation = await store.getObligation(revision.reporting_obligation_id);
436
- if (!obligation || obligation.account.account_id !== accountId) {
437
- throw new Error("Reporting revision is unavailable");
438
- }
445
+ if (!obligation || obligation.account.account_id !== accountId) throw reportingRevisionNotFound();
439
446
  const pagination = isRecord(raw.pagination) ? raw.pagination : {};
440
447
  const maxResults = typeof pagination.max_results === "number" && Number.isFinite(pagination.max_results) ? Math.max(1, Math.min(500, Math.trunc(pagination.max_results))) : 100;
441
448
  const totalCount = revision.binding.rowCount;
@@ -448,7 +455,7 @@ function createReportingDeliveryHandler(store) {
448
455
  }
449
456
  offset = cursor.offset;
450
457
  } catch {
451
- throw new Error("Reporting delivery cursor is invalid");
458
+ throw reportingRevisionNotFound();
452
459
  }
453
460
  }
454
461
  let reportingRows = [];
@@ -464,14 +471,19 @@ function createReportingDeliveryHandler(store) {
464
471
  });
465
472
  } catch (error) {
466
473
  if ((0, import_row_storage.isReportingRowStoreError)(error)) {
467
- if (error.code === "ROWS_EXPIRED") throw new Error("Reporting revision is unavailable");
474
+ if (error.code === "ROWS_EXPIRED") throw reportingRevisionNotFound();
468
475
  throw new import_async_outcome.AdcpError("SERVICE_UNAVAILABLE", {
469
476
  message: "Reporting revision rows are temporarily unavailable"
470
477
  });
471
478
  }
472
479
  throw error;
473
480
  }
474
- if (!page || page.total !== totalCount) throw new Error("Reporting revision is unavailable");
481
+ if (!page) throw reportingRevisionNotFound();
482
+ if (page.total !== totalCount) {
483
+ throw new import_async_outcome.AdcpError("SERVICE_UNAVAILABLE", {
484
+ message: "Reporting revision rows are temporarily unavailable"
485
+ });
486
+ }
475
487
  reportingRows = page.rows;
476
488
  }
477
489
  const nextOffset = offset + reportingRows.length;
@@ -393,6 +393,12 @@ function createReportingStatusHandler(store, options) {
393
393
  };
394
394
  }
395
395
  const REPORTING_STATUS_REVISION_VIEW_MAX_ROWS = 1e4;
396
+ function reportingRevisionNotFound() {
397
+ return new AdcpError("REFERENCE_NOT_FOUND", {
398
+ message: "Reporting revision is unavailable",
399
+ field: "reporting_revision_id"
400
+ });
401
+ }
396
402
  function createReportingDeliveryHandler(store) {
397
403
  const activeReadsByAccount = /* @__PURE__ */ new Map();
398
404
  return async (request, context) => {
@@ -411,14 +417,15 @@ function createReportingDeliveryHandler(store) {
411
417
  try {
412
418
  const raw = request;
413
419
  if (typeof raw.reporting_revision_id !== "string" || !raw.reporting_revision_id) {
414
- throw new Error("Ledger delivery reads require reporting_revision_id");
420
+ throw new AdcpError("VALIDATION_ERROR", {
421
+ message: "Ledger delivery reads require reporting_revision_id",
422
+ field: "reporting_revision_id"
423
+ });
415
424
  }
416
425
  const revision = await getRevisionMetadataFromStore(store, raw.reporting_revision_id, accountId);
417
- if (!revision) throw new Error("Reporting revision is unavailable");
426
+ if (!revision) throw reportingRevisionNotFound();
418
427
  const obligation = await store.getObligation(revision.reporting_obligation_id);
419
- if (!obligation || obligation.account.account_id !== accountId) {
420
- throw new Error("Reporting revision is unavailable");
421
- }
428
+ if (!obligation || obligation.account.account_id !== accountId) throw reportingRevisionNotFound();
422
429
  const pagination = isRecord(raw.pagination) ? raw.pagination : {};
423
430
  const maxResults = typeof pagination.max_results === "number" && Number.isFinite(pagination.max_results) ? Math.max(1, Math.min(500, Math.trunc(pagination.max_results))) : 100;
424
431
  const totalCount = revision.binding.rowCount;
@@ -431,7 +438,7 @@ function createReportingDeliveryHandler(store) {
431
438
  }
432
439
  offset = cursor.offset;
433
440
  } catch {
434
- throw new Error("Reporting delivery cursor is invalid");
441
+ throw reportingRevisionNotFound();
435
442
  }
436
443
  }
437
444
  let reportingRows = [];
@@ -447,14 +454,19 @@ function createReportingDeliveryHandler(store) {
447
454
  });
448
455
  } catch (error) {
449
456
  if (isReportingRowStoreError(error)) {
450
- if (error.code === "ROWS_EXPIRED") throw new Error("Reporting revision is unavailable");
457
+ if (error.code === "ROWS_EXPIRED") throw reportingRevisionNotFound();
451
458
  throw new AdcpError("SERVICE_UNAVAILABLE", {
452
459
  message: "Reporting revision rows are temporarily unavailable"
453
460
  });
454
461
  }
455
462
  throw error;
456
463
  }
457
- if (!page || page.total !== totalCount) throw new Error("Reporting revision is unavailable");
464
+ if (!page) throw reportingRevisionNotFound();
465
+ if (page.total !== totalCount) {
466
+ throw new AdcpError("SERVICE_UNAVAILABLE", {
467
+ message: "Reporting revision rows are temporarily unavailable"
468
+ });
469
+ }
458
470
  reportingRows = page.rows;
459
471
  }
460
472
  const nextOffset = offset + reportingRows.length;
@@ -137,6 +137,17 @@ export declare class PostgresReportingLedgerStore implements ReportingLedgerStor
137
137
  private readonly rowStorageWrites;
138
138
  private readonly changeFeed;
139
139
  constructor(pool: ReportingPgPool, options?: PostgresReportingLedgerStoreOptions);
140
+ /** Whether this store writes the host change feed (`changeFeed: true`). */
141
+ get changeFeedEnabled(): boolean;
142
+ /**
143
+ * Delete expired cursor snapshots and changes checkpoints, a bounded batch
144
+ * at a time. The store-bound form of `sweepExpiredReportingLedgerState`, for
145
+ * hosts that hold the store but not the pool.
146
+ */
147
+ sweepExpiredState(limit?: number): Promise<{
148
+ snapshotsDeleted: number;
149
+ checkpointsDeleted: number;
150
+ }>;
140
151
  /** Append one host change-feed row inside the caller's account-locked transaction. */
141
152
  private appendChange;
142
153
  /** Probe and register row storage when enabled. Safe to call repeatedly. */
@@ -280,10 +291,20 @@ export declare class PostgresReportingLedgerStore implements ReportingLedgerStor
280
291
  *
281
292
  * Planners skip retired periods, and `ledger_retained_from` advances past
282
293
  * them because their obligations no longer exist.
294
+ *
295
+ * When the change-feed tables exist, a period that is not yet retiring is
296
+ * also held while any live registered feed consumer (updated within
297
+ * `maxFeedHoldDays`, default 7) has not passed that period's change rows in
298
+ * the consumer's own feed order, the same rule `pruneChanges` applies. A
299
+ * held period is simply not a candidate this pass. A consumer that stays
300
+ * silent past `maxFeedHoldDays` stops holding, so retention is never
301
+ * blocked forever; it then falls behind retention and a sink sees the
302
+ * retired revisions as missing.
283
303
  */
284
304
  retireExpiredPeriods(input: {
285
305
  statusRetentionDays: number;
286
306
  recordRetentionDays?: number;
307
+ maxFeedHoldDays?: number;
287
308
  limit?: number;
288
309
  account_id?: string;
289
310
  signal?: AbortSignal;
@@ -137,6 +137,17 @@ export declare class PostgresReportingLedgerStore implements ReportingLedgerStor
137
137
  private readonly rowStorageWrites;
138
138
  private readonly changeFeed;
139
139
  constructor(pool: ReportingPgPool, options?: PostgresReportingLedgerStoreOptions);
140
+ /** Whether this store writes the host change feed (`changeFeed: true`). */
141
+ get changeFeedEnabled(): boolean;
142
+ /**
143
+ * Delete expired cursor snapshots and changes checkpoints, a bounded batch
144
+ * at a time. The store-bound form of `sweepExpiredReportingLedgerState`, for
145
+ * hosts that hold the store but not the pool.
146
+ */
147
+ sweepExpiredState(limit?: number): Promise<{
148
+ snapshotsDeleted: number;
149
+ checkpointsDeleted: number;
150
+ }>;
140
151
  /** Append one host change-feed row inside the caller's account-locked transaction. */
141
152
  private appendChange;
142
153
  /** Probe and register row storage when enabled. Safe to call repeatedly. */
@@ -280,10 +291,20 @@ export declare class PostgresReportingLedgerStore implements ReportingLedgerStor
280
291
  *
281
292
  * Planners skip retired periods, and `ledger_retained_from` advances past
282
293
  * them because their obligations no longer exist.
294
+ *
295
+ * When the change-feed tables exist, a period that is not yet retiring is
296
+ * also held while any live registered feed consumer (updated within
297
+ * `maxFeedHoldDays`, default 7) has not passed that period's change rows in
298
+ * the consumer's own feed order, the same rule `pruneChanges` applies. A
299
+ * held period is simply not a candidate this pass. A consumer that stays
300
+ * silent past `maxFeedHoldDays` stops holding, so retention is never
301
+ * blocked forever; it then falls behind retention and a sink sees the
302
+ * retired revisions as missing.
283
303
  */
284
304
  retireExpiredPeriods(input: {
285
305
  statusRetentionDays: number;
286
306
  recordRetentionDays?: number;
307
+ maxFeedHoldDays?: number;
287
308
  limit?: number;
288
309
  account_id?: string;
289
310
  signal?: AbortSignal;
@@ -424,6 +424,18 @@ class PostgresReportingLedgerStore {
424
424
  rowStorage;
425
425
  rowStorageWrites;
426
426
  changeFeed;
427
+ /** Whether this store writes the host change feed (`changeFeed: true`). */
428
+ get changeFeedEnabled() {
429
+ return this.changeFeed;
430
+ }
431
+ /**
432
+ * Delete expired cursor snapshots and changes checkpoints, a bounded batch
433
+ * at a time. The store-bound form of `sweepExpiredReportingLedgerState`, for
434
+ * hosts that hold the store but not the pool.
435
+ */
436
+ async sweepExpiredState(limit = 1e3) {
437
+ return sweepExpiredReportingLedgerState(this.pool, limit);
438
+ }
427
439
  /** Append one host change-feed row inside the caller's account-locked transaction. */
428
440
  async appendChange(client, kind, accountId, recordId, obligationId) {
429
441
  if (!this.changeFeed) return;
@@ -1289,6 +1301,15 @@ ${managedDueArm} )
1289
1301
  *
1290
1302
  * Planners skip retired periods, and `ledger_retained_from` advances past
1291
1303
  * them because their obligations no longer exist.
1304
+ *
1305
+ * When the change-feed tables exist, a period that is not yet retiring is
1306
+ * also held while any live registered feed consumer (updated within
1307
+ * `maxFeedHoldDays`, default 7) has not passed that period's change rows in
1308
+ * the consumer's own feed order, the same rule `pruneChanges` applies. A
1309
+ * held period is simply not a candidate this pass. A consumer that stays
1310
+ * silent past `maxFeedHoldDays` stops holding, so retention is never
1311
+ * blocked forever; it then falls behind retention and a sink sees the
1312
+ * retired revisions as missing.
1292
1313
  */
1293
1314
  async retireExpiredPeriods(input) {
1294
1315
  if (!Number.isSafeInteger(input.statusRetentionDays) || input.statusRetentionDays < 1) {
@@ -1300,8 +1321,18 @@ ${managedDueArm} )
1300
1321
  }
1301
1322
  const limit = input.limit ?? 100;
1302
1323
  positiveInteger(limit, "limit");
1324
+ const holdDays = input.maxFeedHoldDays ?? 7;
1325
+ positiveInteger(holdDays, "maxFeedHoldDays");
1303
1326
  const tables = await this.optionalTables();
1304
1327
  const materializationHold = tables.has("adcp_reporting_materializations") ? `AND NOT EXISTS (SELECT 1 FROM adcp_reporting_materializations hold WHERE hold.obligation_id = obligation.obligation_id)` : "";
1328
+ const feedHold = tables.has("adcp_reporting_changes") && tables.has("adcp_reporting_feed_consumers") ? `AND NOT EXISTS (
1329
+ SELECT 1 FROM adcp_reporting_changes change
1330
+ JOIN adcp_reporting_feed_consumers consumer
1331
+ ON consumer.updated_at > clock_timestamp() - ($4::integer * INTERVAL '1 day')
1332
+ WHERE change.obligation_id = obligation.obligation_id
1333
+ AND ((consumer.account_id IS NULL
1334
+ AND (change.xid, change.seq) > (consumer.cursor_xid, consumer.cursor_seq))
1335
+ OR (consumer.account_id = change.account_id AND change.seq > consumer.cursor_seq)))` : "";
1305
1336
  const candidates = await this.query(
1306
1337
  `SELECT obligation_id, account_id FROM (
1307
1338
  SELECT tombstone.obligation_id, tombstone.account_id, 0 AS resume, tombstone.period_end
@@ -1322,10 +1353,11 @@ ${managedDueArm} )
1322
1353
  AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1323
1354
  WHERE tombstone.obligation_id = obligation.obligation_id)
1324
1355
  ${materializationHold}
1356
+ ${feedHold}
1325
1357
  ) candidate
1326
1358
  ORDER BY resume, period_end, obligation_id
1327
1359
  LIMIT $1`,
1328
- [limit, input.account_id ?? null, recordDays]
1360
+ feedHold ? [limit, input.account_id ?? null, recordDays, holdDays] : [limit, input.account_id ?? null, recordDays]
1329
1361
  );
1330
1362
  let retired = 0;
1331
1363
  const failed = [];
@@ -1497,7 +1529,8 @@ ${managedDueArm} )
1497
1529
  `SELECT table_name AS name FROM information_schema.tables
1498
1530
  WHERE table_schema = current_schema()
1499
1531
  AND table_name IN ('adcp_reporting_row_sets', 'adcp_reporting_row_write_intents',
1500
- 'adcp_reporting_materializations')`
1532
+ 'adcp_reporting_materializations', 'adcp_reporting_changes',
1533
+ 'adcp_reporting_feed_consumers')`
1501
1534
  );
1502
1535
  return new Set(result.rows.map((row) => row.name));
1503
1536
  }