@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.
- package/README.md +15 -0
- package/dist/lib/auth/oauth/index.d.mts +1 -0
- package/dist/lib/auth/oauth/index.d.ts +1 -0
- package/dist/lib/auth/oauth/index.js +5 -0
- package/dist/lib/auth/oauth/index.mjs +3 -0
- package/dist/lib/auth/oauth/pending-web-flow.d.mts +16 -0
- package/dist/lib/auth/oauth/pending-web-flow.d.ts +16 -0
- package/dist/lib/auth/oauth/pending-web-flow.js +66 -0
- package/dist/lib/auth/oauth/pending-web-flow.mjs +41 -0
- package/dist/lib/auth/oauth/web-flow.js +3 -3
- package/dist/lib/auth/oauth/web-flow.mjs +3 -3
- package/dist/lib/registry/types.generated.d.mts +63 -1
- package/dist/lib/registry/types.generated.d.ts +63 -1
- package/dist/lib/reporting/azure/index.d.mts +26 -0
- package/dist/lib/reporting/azure/index.d.ts +26 -0
- package/dist/lib/reporting/azure/index.js +247 -0
- package/dist/lib/reporting/azure/index.mjs +230 -0
- package/dist/lib/reporting/bigquery/index.d.mts +127 -0
- package/dist/lib/reporting/bigquery/index.d.ts +127 -0
- package/dist/lib/reporting/bigquery/index.js +379 -0
- package/dist/lib/reporting/bigquery/index.mjs +341 -0
- package/dist/lib/reporting/ledger/postgres.d.mts +10 -0
- package/dist/lib/reporting/ledger/postgres.d.ts +10 -0
- package/dist/lib/reporting/ledger/postgres.js +23 -2
- package/dist/lib/reporting/ledger/postgres.mjs +23 -2
- package/dist/lib/reporting/ledger/row-storage-object.js +31 -8
- package/dist/lib/reporting/ledger/row-storage-object.mjs +32 -9
- package/dist/lib/reporting/ledger/row-storage-provider-io.d.mts +0 -7
- package/dist/lib/reporting/ledger/row-storage-provider-io.d.ts +0 -7
- package/dist/lib/reporting/ledger/row-storage-provider-io.js +0 -10
- package/dist/lib/reporting/ledger/row-storage-provider-io.mjs +0 -8
- package/dist/lib/reporting/s3/index.d.mts +29 -0
- package/dist/lib/reporting/s3/index.d.ts +29 -0
- package/dist/lib/reporting/s3/index.js +324 -0
- package/dist/lib/reporting/s3/index.mjs +317 -0
- package/dist/lib/reporting/service/index.d.mts +3 -1
- package/dist/lib/reporting/service/index.d.ts +3 -1
- package/dist/lib/reporting/service/index.js +1 -0
- package/dist/lib/reporting/service/index.mjs +1 -0
- package/dist/lib/schemas/browser.d.mts +14 -0
- package/dist/lib/schemas/browser.d.ts +14 -0
- package/dist/lib/schemas/browser.js +48 -0
- package/dist/lib/schemas/browser.mjs +20 -0
- package/dist/lib/schemas/index.d.mts +2 -120
- package/dist/lib/schemas/index.d.ts +2 -120
- package/dist/lib/schemas/index.js +3 -11
- package/dist/lib/schemas/index.mjs +1 -9
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/testing/index.d.mts +1 -0
- package/dist/lib/testing/index.d.ts +1 -0
- package/dist/lib/testing/index.js +3 -0
- package/dist/lib/testing/index.mjs +2 -0
- package/dist/lib/testing/pending-web-flow-store.d.mts +20 -0
- package/dist/lib/testing/pending-web-flow-store.d.ts +20 -0
- package/dist/lib/testing/pending-web-flow-store.js +132 -0
- package/dist/lib/testing/pending-web-flow-store.mjs +98 -0
- package/dist/lib/testing/storyboard/validations.d.mts +1 -1
- package/dist/lib/testing/storyboard/validations.d.ts +1 -1
- package/dist/lib/validation/get-products-request.d.mts +122 -0
- package/dist/lib/validation/get-products-request.d.ts +122 -0
- package/dist/lib/validation/get-products-request.js +39 -0
- package/dist/lib/validation/get-products-request.mjs +14 -0
- package/dist/lib/version.d.mts +3 -3
- package/dist/lib/version.d.ts +3 -3
- package/dist/lib/version.js +3 -3
- package/dist/lib/version.mjs +3 -3
- package/docs/TYPE-SUMMARY.md +1 -1
- package/docs/ZOD-SCHEMAS.md +13 -1
- package/docs/guides/OAUTH-ISSUER-BINDING.md +6 -0
- package/docs/guides/REPORTING-LEDGER.md +165 -0
- package/docs/guides/REPORTING-OPERATIONS.md +76 -1
- package/docs/guides/WEB-OAUTH.md +48 -1
- package/docs/llms.txt +1 -1
- package/docs/migration-14.x-rc-worksheet.md +6 -4
- 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
|
|
131
|
-
|
|
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 (
|
|
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 ??
|
|
187
|
-
if (offset < 0 || length < 0 || offset + length >
|
|
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
|
|
90
|
-
|
|
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 (
|
|
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 ??
|
|
146
|
-
if (offset < 0 || length < 0 || offset + length >
|
|
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;
|