@adcp/sdk 15.5.0 → 15.6.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 (75) 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/postgres.d.mts +10 -0
  23. package/dist/lib/reporting/ledger/postgres.d.ts +10 -0
  24. package/dist/lib/reporting/ledger/postgres.js +23 -2
  25. package/dist/lib/reporting/ledger/postgres.mjs +23 -2
  26. package/dist/lib/reporting/ledger/row-storage-object.js +31 -8
  27. package/dist/lib/reporting/ledger/row-storage-object.mjs +32 -9
  28. package/dist/lib/reporting/ledger/row-storage-provider-io.d.mts +0 -7
  29. package/dist/lib/reporting/ledger/row-storage-provider-io.d.ts +0 -7
  30. package/dist/lib/reporting/ledger/row-storage-provider-io.js +0 -10
  31. package/dist/lib/reporting/ledger/row-storage-provider-io.mjs +0 -8
  32. package/dist/lib/reporting/s3/index.d.mts +29 -0
  33. package/dist/lib/reporting/s3/index.d.ts +29 -0
  34. package/dist/lib/reporting/s3/index.js +324 -0
  35. package/dist/lib/reporting/s3/index.mjs +317 -0
  36. package/dist/lib/reporting/service/index.d.mts +3 -1
  37. package/dist/lib/reporting/service/index.d.ts +3 -1
  38. package/dist/lib/reporting/service/index.js +1 -0
  39. package/dist/lib/reporting/service/index.mjs +1 -0
  40. package/dist/lib/schemas/browser.d.mts +14 -0
  41. package/dist/lib/schemas/browser.d.ts +14 -0
  42. package/dist/lib/schemas/browser.js +48 -0
  43. package/dist/lib/schemas/browser.mjs +20 -0
  44. package/dist/lib/schemas/index.d.mts +2 -120
  45. package/dist/lib/schemas/index.d.ts +2 -120
  46. package/dist/lib/schemas/index.js +3 -11
  47. package/dist/lib/schemas/index.mjs +1 -9
  48. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  49. package/dist/lib/testing/index.d.mts +1 -0
  50. package/dist/lib/testing/index.d.ts +1 -0
  51. package/dist/lib/testing/index.js +3 -0
  52. package/dist/lib/testing/index.mjs +2 -0
  53. package/dist/lib/testing/pending-web-flow-store.d.mts +20 -0
  54. package/dist/lib/testing/pending-web-flow-store.d.ts +20 -0
  55. package/dist/lib/testing/pending-web-flow-store.js +132 -0
  56. package/dist/lib/testing/pending-web-flow-store.mjs +98 -0
  57. package/dist/lib/testing/storyboard/validations.d.mts +1 -1
  58. package/dist/lib/testing/storyboard/validations.d.ts +1 -1
  59. package/dist/lib/validation/get-products-request.d.mts +122 -0
  60. package/dist/lib/validation/get-products-request.d.ts +122 -0
  61. package/dist/lib/validation/get-products-request.js +39 -0
  62. package/dist/lib/validation/get-products-request.mjs +14 -0
  63. package/dist/lib/version.d.mts +3 -3
  64. package/dist/lib/version.d.ts +3 -3
  65. package/dist/lib/version.js +3 -3
  66. package/dist/lib/version.mjs +3 -3
  67. package/docs/TYPE-SUMMARY.md +1 -1
  68. package/docs/ZOD-SCHEMAS.md +13 -1
  69. package/docs/guides/OAUTH-ISSUER-BINDING.md +6 -0
  70. package/docs/guides/REPORTING-LEDGER.md +165 -0
  71. package/docs/guides/REPORTING-OPERATIONS.md +76 -1
  72. package/docs/guides/WEB-OAUTH.md +48 -1
  73. package/docs/llms.txt +1 -1
  74. package/docs/migration-14.x-rc-worksheet.md +6 -4
  75. 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
+ };
@@ -280,10 +280,20 @@ export declare class PostgresReportingLedgerStore implements ReportingLedgerStor
280
280
  *
281
281
  * Planners skip retired periods, and `ledger_retained_from` advances past
282
282
  * them because their obligations no longer exist.
283
+ *
284
+ * When the change-feed tables exist, a period that is not yet retiring is
285
+ * also held while any live registered feed consumer (updated within
286
+ * `maxFeedHoldDays`, default 7) has not passed that period's change rows in
287
+ * the consumer's own feed order, the same rule `pruneChanges` applies. A
288
+ * held period is simply not a candidate this pass. A consumer that stays
289
+ * silent past `maxFeedHoldDays` stops holding, so retention is never
290
+ * blocked forever; it then falls behind retention and a sink sees the
291
+ * retired revisions as missing.
283
292
  */
284
293
  retireExpiredPeriods(input: {
285
294
  statusRetentionDays: number;
286
295
  recordRetentionDays?: number;
296
+ maxFeedHoldDays?: number;
287
297
  limit?: number;
288
298
  account_id?: string;
289
299
  signal?: AbortSignal;
@@ -280,10 +280,20 @@ export declare class PostgresReportingLedgerStore implements ReportingLedgerStor
280
280
  *
281
281
  * Planners skip retired periods, and `ledger_retained_from` advances past
282
282
  * them because their obligations no longer exist.
283
+ *
284
+ * When the change-feed tables exist, a period that is not yet retiring is
285
+ * also held while any live registered feed consumer (updated within
286
+ * `maxFeedHoldDays`, default 7) has not passed that period's change rows in
287
+ * the consumer's own feed order, the same rule `pruneChanges` applies. A
288
+ * held period is simply not a candidate this pass. A consumer that stays
289
+ * silent past `maxFeedHoldDays` stops holding, so retention is never
290
+ * blocked forever; it then falls behind retention and a sink sees the
291
+ * retired revisions as missing.
283
292
  */
284
293
  retireExpiredPeriods(input: {
285
294
  statusRetentionDays: number;
286
295
  recordRetentionDays?: number;
296
+ maxFeedHoldDays?: number;
287
297
  limit?: number;
288
298
  account_id?: string;
289
299
  signal?: AbortSignal;
@@ -1289,6 +1289,15 @@ ${managedDueArm} )
1289
1289
  *
1290
1290
  * Planners skip retired periods, and `ledger_retained_from` advances past
1291
1291
  * them because their obligations no longer exist.
1292
+ *
1293
+ * When the change-feed tables exist, a period that is not yet retiring is
1294
+ * also held while any live registered feed consumer (updated within
1295
+ * `maxFeedHoldDays`, default 7) has not passed that period's change rows in
1296
+ * the consumer's own feed order, the same rule `pruneChanges` applies. A
1297
+ * held period is simply not a candidate this pass. A consumer that stays
1298
+ * silent past `maxFeedHoldDays` stops holding, so retention is never
1299
+ * blocked forever; it then falls behind retention and a sink sees the
1300
+ * retired revisions as missing.
1292
1301
  */
1293
1302
  async retireExpiredPeriods(input) {
1294
1303
  if (!Number.isSafeInteger(input.statusRetentionDays) || input.statusRetentionDays < 1) {
@@ -1300,8 +1309,18 @@ ${managedDueArm} )
1300
1309
  }
1301
1310
  const limit = input.limit ?? 100;
1302
1311
  positiveInteger(limit, "limit");
1312
+ const holdDays = input.maxFeedHoldDays ?? 7;
1313
+ positiveInteger(holdDays, "maxFeedHoldDays");
1303
1314
  const tables = await this.optionalTables();
1304
1315
  const materializationHold = tables.has("adcp_reporting_materializations") ? `AND NOT EXISTS (SELECT 1 FROM adcp_reporting_materializations hold WHERE hold.obligation_id = obligation.obligation_id)` : "";
1316
+ const feedHold = tables.has("adcp_reporting_changes") && tables.has("adcp_reporting_feed_consumers") ? `AND NOT EXISTS (
1317
+ SELECT 1 FROM adcp_reporting_changes change
1318
+ JOIN adcp_reporting_feed_consumers consumer
1319
+ ON consumer.updated_at > clock_timestamp() - ($4::integer * INTERVAL '1 day')
1320
+ WHERE change.obligation_id = obligation.obligation_id
1321
+ AND ((consumer.account_id IS NULL
1322
+ AND (change.xid, change.seq) > (consumer.cursor_xid, consumer.cursor_seq))
1323
+ OR (consumer.account_id = change.account_id AND change.seq > consumer.cursor_seq)))` : "";
1305
1324
  const candidates = await this.query(
1306
1325
  `SELECT obligation_id, account_id FROM (
1307
1326
  SELECT tombstone.obligation_id, tombstone.account_id, 0 AS resume, tombstone.period_end
@@ -1322,10 +1341,11 @@ ${managedDueArm} )
1322
1341
  AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1323
1342
  WHERE tombstone.obligation_id = obligation.obligation_id)
1324
1343
  ${materializationHold}
1344
+ ${feedHold}
1325
1345
  ) candidate
1326
1346
  ORDER BY resume, period_end, obligation_id
1327
1347
  LIMIT $1`,
1328
- [limit, input.account_id ?? null, recordDays]
1348
+ feedHold ? [limit, input.account_id ?? null, recordDays, holdDays] : [limit, input.account_id ?? null, recordDays]
1329
1349
  );
1330
1350
  let retired = 0;
1331
1351
  const failed = [];
@@ -1497,7 +1517,8 @@ ${managedDueArm} )
1497
1517
  `SELECT table_name AS name FROM information_schema.tables
1498
1518
  WHERE table_schema = current_schema()
1499
1519
  AND table_name IN ('adcp_reporting_row_sets', 'adcp_reporting_row_write_intents',
1500
- 'adcp_reporting_materializations')`
1520
+ 'adcp_reporting_materializations', 'adcp_reporting_changes',
1521
+ 'adcp_reporting_feed_consumers')`
1501
1522
  );
1502
1523
  return new Set(result.rows.map((row) => row.name));
1503
1524
  }
@@ -1288,6 +1288,15 @@ ${managedDueArm} )
1288
1288
  *
1289
1289
  * Planners skip retired periods, and `ledger_retained_from` advances past
1290
1290
  * them because their obligations no longer exist.
1291
+ *
1292
+ * When the change-feed tables exist, a period that is not yet retiring is
1293
+ * also held while any live registered feed consumer (updated within
1294
+ * `maxFeedHoldDays`, default 7) has not passed that period's change rows in
1295
+ * the consumer's own feed order, the same rule `pruneChanges` applies. A
1296
+ * held period is simply not a candidate this pass. A consumer that stays
1297
+ * silent past `maxFeedHoldDays` stops holding, so retention is never
1298
+ * blocked forever; it then falls behind retention and a sink sees the
1299
+ * retired revisions as missing.
1291
1300
  */
1292
1301
  async retireExpiredPeriods(input) {
1293
1302
  if (!Number.isSafeInteger(input.statusRetentionDays) || input.statusRetentionDays < 1) {
@@ -1299,8 +1308,18 @@ ${managedDueArm} )
1299
1308
  }
1300
1309
  const limit = input.limit ?? 100;
1301
1310
  positiveInteger(limit, "limit");
1311
+ const holdDays = input.maxFeedHoldDays ?? 7;
1312
+ positiveInteger(holdDays, "maxFeedHoldDays");
1302
1313
  const tables = await this.optionalTables();
1303
1314
  const materializationHold = tables.has("adcp_reporting_materializations") ? `AND NOT EXISTS (SELECT 1 FROM adcp_reporting_materializations hold WHERE hold.obligation_id = obligation.obligation_id)` : "";
1315
+ const feedHold = tables.has("adcp_reporting_changes") && tables.has("adcp_reporting_feed_consumers") ? `AND NOT EXISTS (
1316
+ SELECT 1 FROM adcp_reporting_changes change
1317
+ JOIN adcp_reporting_feed_consumers consumer
1318
+ ON consumer.updated_at > clock_timestamp() - ($4::integer * INTERVAL '1 day')
1319
+ WHERE change.obligation_id = obligation.obligation_id
1320
+ AND ((consumer.account_id IS NULL
1321
+ AND (change.xid, change.seq) > (consumer.cursor_xid, consumer.cursor_seq))
1322
+ OR (consumer.account_id = change.account_id AND change.seq > consumer.cursor_seq)))` : "";
1304
1323
  const candidates = await this.query(
1305
1324
  `SELECT obligation_id, account_id FROM (
1306
1325
  SELECT tombstone.obligation_id, tombstone.account_id, 0 AS resume, tombstone.period_end
@@ -1321,10 +1340,11 @@ ${managedDueArm} )
1321
1340
  AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
1322
1341
  WHERE tombstone.obligation_id = obligation.obligation_id)
1323
1342
  ${materializationHold}
1343
+ ${feedHold}
1324
1344
  ) candidate
1325
1345
  ORDER BY resume, period_end, obligation_id
1326
1346
  LIMIT $1`,
1327
- [limit, input.account_id ?? null, recordDays]
1347
+ feedHold ? [limit, input.account_id ?? null, recordDays, holdDays] : [limit, input.account_id ?? null, recordDays]
1328
1348
  );
1329
1349
  let retired = 0;
1330
1350
  const failed = [];
@@ -1496,7 +1516,8 @@ ${managedDueArm} )
1496
1516
  `SELECT table_name AS name FROM information_schema.tables
1497
1517
  WHERE table_schema = current_schema()
1498
1518
  AND table_name IN ('adcp_reporting_row_sets', 'adcp_reporting_row_write_intents',
1499
- 'adcp_reporting_materializations')`
1519
+ 'adcp_reporting_materializations', 'adcp_reporting_changes',
1520
+ 'adcp_reporting_feed_consumers')`
1500
1521
  );
1501
1522
  return new Set(result.rows.map((row) => row.name));
1502
1523
  }
@@ -127,10 +127,8 @@ function createFilesystemReportingRowObjectProviderV1(options) {
127
127
  }
128
128
  return target;
129
129
  };
130
- const version = async (target) => {
131
- const stat = await import_fs.promises.stat(target);
132
- return `${stat.ino}:${stat.mtimeMs}:${stat.size}`;
133
- };
130
+ const versionOf = (stat) => `${stat.ino}:${stat.birthtimeNs}:${stat.mtimeNs}:${stat.size}`;
131
+ const version = async (target) => versionOf(await import_fs.promises.stat(target, { bigint: true }));
134
132
  return {
135
133
  name: "filesystem",
136
134
  rangedReads: true,
@@ -153,6 +151,8 @@ function createFilesystemReportingRowObjectProviderV1(options) {
153
151
  const handle = await import_fs.promises.open(temporary, "wx");
154
152
  try {
155
153
  await handle.writeFile(input.bytes);
154
+ const mtime = Date.now() / 1e3 + (0, import_crypto.randomInt)(0, 1e3) / 1e6;
155
+ await handle.utimes(mtime, mtime);
156
156
  await handle.sync();
157
157
  } finally {
158
158
  await handle.close();
@@ -180,11 +180,12 @@ function createFilesystemReportingRowObjectProviderV1(options) {
180
180
  throw new import_row_storage_errors.ReportingRowStoreError("PROVIDER_UNAVAILABLE", "filesystem read failed");
181
181
  }
182
182
  try {
183
- const stat = await handle.stat();
184
- if (`${stat.ino}:${stat.mtimeMs}:${stat.size}` !== input.nativeVersion) return null;
183
+ const stat = await handle.stat({ bigint: true });
184
+ if (versionOf(stat) !== input.nativeVersion) return null;
185
+ const size = Number(stat.size);
185
186
  const offset = input.range?.offset ?? 0;
186
- const length = input.range?.length ?? stat.size - offset;
187
- if (offset < 0 || length < 0 || offset + length > stat.size) {
187
+ const length = input.range?.length ?? size - offset;
188
+ if (offset < 0 || length < 0 || offset + length > size) {
188
189
  throw new import_row_storage_errors.ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "requested range exceeds the stored object");
189
190
  }
190
191
  if (length > input.maxBytes) {
@@ -262,6 +263,28 @@ async function runReportingRowObjectProviderConformanceV1(provider, input) {
262
263
  throw new Error("repeated delete was not idempotent");
263
264
  }
264
265
  passed.push("exact-version-delete");
266
+ const recreated = await provider.putIfAbsent(
267
+ { ...base, key, bytes: first, contentType: "application/x-ndjson", metadata },
268
+ context
269
+ );
270
+ if (!recreated.created || !recreated.nativeVersion) throw new Error("putIfAbsent did not re-create a deleted object");
271
+ if (recreated.nativeVersion === created.nativeVersion) {
272
+ throw new Error("re-creating identical bytes produced the same native version; versions must be write-unique");
273
+ }
274
+ if (await provider.delete({ ...base, key, nativeVersion: created.nativeVersion }, context) !== "absent") {
275
+ throw new Error("delete of a stale version removed or reported a later write");
276
+ }
277
+ const survivor = await provider.get(
278
+ { ...base, key, nativeVersion: recreated.nativeVersion, maxBytes: 1024 },
279
+ context
280
+ );
281
+ if (!survivor || !Buffer.from(survivor).equals(first)) {
282
+ throw new Error("delete of a stale version removed a later write with identical bytes");
283
+ }
284
+ if (await provider.delete({ ...base, key, nativeVersion: recreated.nativeVersion }, context) !== "deleted") {
285
+ throw new Error("delete did not remove the re-created version");
286
+ }
287
+ passed.push("write-unique-version");
265
288
  return passed;
266
289
  }
267
290
  function sha256Hex(value) {
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID } from "crypto";
1
+ import { createHash, randomInt, randomUUID } from "crypto";
2
2
  import { promises as fs } from "fs";
3
3
  import * as path from "path";
4
4
  import { canonicalize } from "../../utils/jcs.mjs";
@@ -86,10 +86,8 @@ function createFilesystemReportingRowObjectProviderV1(options) {
86
86
  }
87
87
  return target;
88
88
  };
89
- const version = async (target) => {
90
- const stat = await fs.stat(target);
91
- return `${stat.ino}:${stat.mtimeMs}:${stat.size}`;
92
- };
89
+ const versionOf = (stat) => `${stat.ino}:${stat.birthtimeNs}:${stat.mtimeNs}:${stat.size}`;
90
+ const version = async (target) => versionOf(await fs.stat(target, { bigint: true }));
93
91
  return {
94
92
  name: "filesystem",
95
93
  rangedReads: true,
@@ -112,6 +110,8 @@ function createFilesystemReportingRowObjectProviderV1(options) {
112
110
  const handle = await fs.open(temporary, "wx");
113
111
  try {
114
112
  await handle.writeFile(input.bytes);
113
+ const mtime = Date.now() / 1e3 + randomInt(0, 1e3) / 1e6;
114
+ await handle.utimes(mtime, mtime);
115
115
  await handle.sync();
116
116
  } finally {
117
117
  await handle.close();
@@ -139,11 +139,12 @@ function createFilesystemReportingRowObjectProviderV1(options) {
139
139
  throw new ReportingRowStoreError("PROVIDER_UNAVAILABLE", "filesystem read failed");
140
140
  }
141
141
  try {
142
- const stat = await handle.stat();
143
- if (`${stat.ino}:${stat.mtimeMs}:${stat.size}` !== input.nativeVersion) return null;
142
+ const stat = await handle.stat({ bigint: true });
143
+ if (versionOf(stat) !== input.nativeVersion) return null;
144
+ const size = Number(stat.size);
144
145
  const offset = input.range?.offset ?? 0;
145
- const length = input.range?.length ?? stat.size - offset;
146
- if (offset < 0 || length < 0 || offset + length > stat.size) {
146
+ const length = input.range?.length ?? size - offset;
147
+ if (offset < 0 || length < 0 || offset + length > size) {
147
148
  throw new ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "requested range exceeds the stored object");
148
149
  }
149
150
  if (length > input.maxBytes) {
@@ -221,6 +222,28 @@ async function runReportingRowObjectProviderConformanceV1(provider, input) {
221
222
  throw new Error("repeated delete was not idempotent");
222
223
  }
223
224
  passed.push("exact-version-delete");
225
+ const recreated = await provider.putIfAbsent(
226
+ { ...base, key, bytes: first, contentType: "application/x-ndjson", metadata },
227
+ context
228
+ );
229
+ if (!recreated.created || !recreated.nativeVersion) throw new Error("putIfAbsent did not re-create a deleted object");
230
+ if (recreated.nativeVersion === created.nativeVersion) {
231
+ throw new Error("re-creating identical bytes produced the same native version; versions must be write-unique");
232
+ }
233
+ if (await provider.delete({ ...base, key, nativeVersion: created.nativeVersion }, context) !== "absent") {
234
+ throw new Error("delete of a stale version removed or reported a later write");
235
+ }
236
+ const survivor = await provider.get(
237
+ { ...base, key, nativeVersion: recreated.nativeVersion, maxBytes: 1024 },
238
+ context
239
+ );
240
+ if (!survivor || !Buffer.from(survivor).equals(first)) {
241
+ throw new Error("delete of a stale version removed a later write with identical bytes");
242
+ }
243
+ if (await provider.delete({ ...base, key, nativeVersion: recreated.nativeVersion }, context) !== "deleted") {
244
+ throw new Error("delete did not remove the re-created version");
245
+ }
246
+ passed.push("write-unique-version");
224
247
  return passed;
225
248
  }
226
249
  function sha256Hex(value) {
@@ -42,10 +42,3 @@ export declare function assertReportingRowLocationKeys(location: Readonly<Record
42
42
  * owned prefix. An absent or empty filter matches everything.
43
43
  */
44
44
  export declare function reportingRowPrefixFilterOverlaps(filter: string | undefined, bindingPrefix: string): boolean;
45
- /**
46
- * S3 and Azure record a version ID when the store is versioned, else the
47
- * ETag. ETags are always kept quoted, which version IDs never are, so the two
48
- * cannot be confused when the version is read back.
49
- */
50
- export declare function reportingRowEtagVersion(etag: string): string;
51
- export declare function isReportingRowEtagVersion(nativeVersion: string): boolean;
@@ -42,10 +42,3 @@ export declare function assertReportingRowLocationKeys(location: Readonly<Record
42
42
  * owned prefix. An absent or empty filter matches everything.
43
43
  */
44
44
  export declare function reportingRowPrefixFilterOverlaps(filter: string | undefined, bindingPrefix: string): boolean;
45
- /**
46
- * S3 and Azure record a version ID when the store is versioned, else the
47
- * ETag. ETags are always kept quoted, which version IDs never are, so the two
48
- * cannot be confused when the version is read back.
49
- */
50
- export declare function reportingRowEtagVersion(etag: string): string;
51
- export declare function isReportingRowEtagVersion(nativeVersion: string): boolean;