@adcp/sdk 16.0.0-beta.3 → 16.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/auth/oauth/diagnose.js +3 -11
- package/dist/lib/auth/oauth/diagnose.mjs +4 -12
- package/dist/lib/auth/oauth/diagnostics.d.mts +2 -1
- package/dist/lib/auth/oauth/diagnostics.d.ts +2 -1
- package/dist/lib/auth/oauth/diagnostics.js +8 -10
- package/dist/lib/auth/oauth/diagnostics.mjs +7 -10
- package/dist/lib/index.d.mts +1 -1
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/index.js +2 -0
- package/dist/lib/index.mjs +7 -1
- package/dist/lib/reporting/consumer-runtime.js +2 -1
- package/dist/lib/reporting/consumer-runtime.mjs +2 -1
- package/dist/lib/reporting/ledger/handler.js +50 -11
- package/dist/lib/reporting/ledger/handler.mjs +50 -12
- package/dist/lib/reporting/ledger/index.d.mts +17 -14
- package/dist/lib/reporting/ledger/index.d.ts +17 -14
- package/dist/lib/reporting/ledger/index.js +32 -20
- package/dist/lib/reporting/ledger/index.mjs +21 -10
- package/dist/lib/reporting/ledger/offering-validation.d.mts +9 -0
- package/dist/lib/reporting/ledger/offering-validation.d.ts +9 -0
- package/dist/lib/reporting/{gcs/bucket-policy.js → ledger/offering-validation.js} +9 -8
- package/dist/lib/reporting/ledger/offering-validation.mjs +8 -0
- package/dist/lib/reporting/ledger/receipts.d.mts +48 -0
- package/dist/lib/reporting/ledger/receipts.d.ts +48 -0
- package/dist/lib/reporting/ledger/receipts.js +186 -0
- package/dist/lib/reporting/ledger/receipts.mjs +161 -0
- package/dist/lib/reporting/ledger/status-ingest.js +3 -3
- package/dist/lib/reporting/ledger/status-ingest.mjs +4 -5
- package/dist/lib/reporting/ledger/types.d.mts +43 -0
- package/dist/lib/reporting/ledger/types.d.ts +43 -0
- package/dist/lib/reporting/ledger/types.js +31 -2
- package/dist/lib/reporting/ledger/types.mjs +26 -1
- package/dist/lib/reporting/pagination.d.mts +6 -0
- package/dist/lib/reporting/pagination.d.ts +6 -0
- package/dist/lib/reporting/{gcs/errors.js → pagination.js} +12 -17
- package/dist/lib/reporting/pagination.mjs +12 -0
- package/dist/lib/reporting/reconciliation.d.mts +1 -0
- package/dist/lib/reporting/reconciliation.d.ts +1 -0
- package/dist/lib/reporting/reconciliation.js +3 -2
- package/dist/lib/reporting/reconciliation.mjs +3 -2
- package/dist/lib/reporting/service/index.d.mts +79 -153
- package/dist/lib/reporting/service/index.d.ts +79 -153
- package/dist/lib/reporting/service/index.js +34 -491
- package/dist/lib/reporting/service/index.mjs +36 -510
- package/dist/lib/reporting/service/scheduler-support.d.mts +43 -0
- package/dist/lib/reporting/service/scheduler-support.d.ts +43 -0
- package/dist/lib/reporting/service/scheduler-support.js +125 -0
- package/dist/lib/reporting/service/scheduler-support.mjs +96 -0
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/signing/canonicalize.d.mts +4 -0
- package/dist/lib/signing/canonicalize.d.ts +4 -0
- package/dist/lib/signing/canonicalize.js +14 -8
- package/dist/lib/signing/canonicalize.mjs +14 -8
- package/dist/lib/signing/content-digest.d.mts +8 -0
- package/dist/lib/signing/content-digest.d.ts +8 -0
- package/dist/lib/signing/content-digest.js +11 -5
- package/dist/lib/signing/content-digest.mjs +10 -5
- package/dist/lib/signing/signer.js +9 -6
- package/dist/lib/signing/signer.mjs +10 -7
- package/dist/lib/signing/web-bot-auth.js +103 -37
- package/dist/lib/signing/web-bot-auth.mjs +108 -37
- package/dist/lib/testing/storyboard/junit.js +16 -3
- package/dist/lib/testing/storyboard/junit.mjs +16 -3
- package/dist/lib/testing/storyboard/request-signing/builder.js +10 -9
- package/dist/lib/testing/storyboard/request-signing/builder.mjs +10 -9
- package/dist/lib/testing/storyboard/runner.js +94 -25
- package/dist/lib/testing/storyboard/runner.mjs +94 -25
- package/dist/lib/testing/storyboard/types.d.mts +6 -0
- package/dist/lib/testing/storyboard/types.d.ts +6 -0
- package/dist/lib/testing/storyboard/validations.d.mts +1 -1
- package/dist/lib/testing/storyboard/validations.d.ts +1 -1
- 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 +23 -5
- package/docs/guides/REPORTING-GCS-FENCE.md +3 -3
- package/docs/guides/REPORTING-GCS-MANAGED.md +4 -4
- package/docs/guides/REPORTING-LEDGER-CUTOVER.md +7 -7
- package/docs/guides/REPORTING-LEDGER.md +107 -51
- package/docs/guides/REPORTING-OPERATIONS.md +66 -5
- package/docs/guides/REPORTING-STABILITY.md +36 -32
- package/docs/llms.txt +4 -3
- package/docs/migration-14.x-rc-worksheet.md +4 -7
- package/docs/migration-reporting-storage-package.md +186 -0
- package/examples/README.md +1 -1
- package/package.json +13 -78
- package/dist/lib/reporting/azure/index.d.mts +0 -31
- package/dist/lib/reporting/azure/index.d.ts +0 -31
- package/dist/lib/reporting/azure/index.js +0 -266
- package/dist/lib/reporting/azure/index.mjs +0 -249
- package/dist/lib/reporting/bigquery/index.d.mts +0 -246
- package/dist/lib/reporting/bigquery/index.d.ts +0 -246
- package/dist/lib/reporting/bigquery/index.js +0 -633
- package/dist/lib/reporting/bigquery/index.mjs +0 -594
- package/dist/lib/reporting/gcs/adapter.d.mts +0 -38
- package/dist/lib/reporting/gcs/adapter.d.ts +0 -38
- package/dist/lib/reporting/gcs/adapter.js +0 -342
- package/dist/lib/reporting/gcs/adapter.mjs +0 -325
- package/dist/lib/reporting/gcs/bucket-policy.d.mts +0 -3
- package/dist/lib/reporting/gcs/bucket-policy.d.ts +0 -3
- package/dist/lib/reporting/gcs/bucket-policy.mjs +0 -7
- package/dist/lib/reporting/gcs/errors.d.mts +0 -11
- package/dist/lib/reporting/gcs/errors.d.ts +0 -11
- package/dist/lib/reporting/gcs/errors.mjs +0 -16
- package/dist/lib/reporting/gcs/fence.d.mts +0 -41
- package/dist/lib/reporting/gcs/fence.d.ts +0 -41
- package/dist/lib/reporting/gcs/fence.js +0 -250
- package/dist/lib/reporting/gcs/fence.mjs +0 -230
- package/dist/lib/reporting/gcs/index.d.mts +0 -18
- package/dist/lib/reporting/gcs/index.d.ts +0 -18
- package/dist/lib/reporting/gcs/index.js +0 -44
- package/dist/lib/reporting/gcs/index.mjs +0 -14
- package/dist/lib/reporting/gcs/io.d.mts +0 -7
- package/dist/lib/reporting/gcs/io.d.ts +0 -7
- package/dist/lib/reporting/gcs/io.js +0 -95
- package/dist/lib/reporting/gcs/io.mjs +0 -69
- package/dist/lib/reporting/gcs/reader.d.mts +0 -67
- package/dist/lib/reporting/gcs/reader.d.ts +0 -67
- package/dist/lib/reporting/gcs/reader.js +0 -193
- package/dist/lib/reporting/gcs/reader.mjs +0 -169
- package/dist/lib/reporting/gcs/row-provider.d.mts +0 -39
- package/dist/lib/reporting/gcs/row-provider.d.ts +0 -39
- package/dist/lib/reporting/gcs/row-provider.js +0 -225
- package/dist/lib/reporting/gcs/row-provider.mjs +0 -208
- package/dist/lib/reporting/gcs/validation.d.mts +0 -5
- package/dist/lib/reporting/gcs/validation.d.ts +0 -5
- package/dist/lib/reporting/gcs/validation.js +0 -49
- package/dist/lib/reporting/gcs/validation.mjs +0 -25
- package/dist/lib/reporting/ledger/change-feed.d.mts +0 -69
- package/dist/lib/reporting/ledger/change-feed.d.ts +0 -69
- package/dist/lib/reporting/ledger/change-feed.js +0 -107
- package/dist/lib/reporting/ledger/change-feed.mjs +0 -80
- package/dist/lib/reporting/ledger/managed-postgres.d.mts +0 -293
- package/dist/lib/reporting/ledger/managed-postgres.d.ts +0 -293
- package/dist/lib/reporting/ledger/managed-postgres.js +0 -2346
- package/dist/lib/reporting/ledger/managed-postgres.mjs +0 -2332
- package/dist/lib/reporting/ledger/managed-router.d.mts +0 -148
- package/dist/lib/reporting/ledger/managed-router.d.ts +0 -148
- package/dist/lib/reporting/ledger/managed-router.js +0 -280
- package/dist/lib/reporting/ledger/managed-router.mjs +0 -258
- package/dist/lib/reporting/ledger/managed.d.mts +0 -535
- package/dist/lib/reporting/ledger/managed.d.ts +0 -535
- package/dist/lib/reporting/ledger/managed.js +0 -761
- package/dist/lib/reporting/ledger/managed.mjs +0 -729
- package/dist/lib/reporting/ledger/notification-activity.d.mts +0 -332
- package/dist/lib/reporting/ledger/notification-activity.d.ts +0 -332
- package/dist/lib/reporting/ledger/notification-activity.js +0 -1366
- package/dist/lib/reporting/ledger/notification-activity.mjs +0 -1337
- package/dist/lib/reporting/ledger/object-writes.d.mts +0 -128
- package/dist/lib/reporting/ledger/object-writes.d.ts +0 -128
- package/dist/lib/reporting/ledger/object-writes.js +0 -147
- package/dist/lib/reporting/ledger/object-writes.mjs +0 -114
- package/dist/lib/reporting/ledger/postgres.d.mts +0 -689
- package/dist/lib/reporting/ledger/postgres.d.ts +0 -689
- package/dist/lib/reporting/ledger/postgres.js +0 -4462
- package/dist/lib/reporting/ledger/postgres.mjs +0 -4462
- package/dist/lib/reporting/ledger/row-storage-migration.d.mts +0 -59
- package/dist/lib/reporting/ledger/row-storage-migration.d.ts +0 -59
- package/dist/lib/reporting/ledger/row-storage-migration.js +0 -365
- package/dist/lib/reporting/ledger/row-storage-migration.mjs +0 -339
- package/dist/lib/reporting/ledger/row-storage-object.d.mts +0 -203
- package/dist/lib/reporting/ledger/row-storage-object.d.ts +0 -203
- package/dist/lib/reporting/ledger/row-storage-object.js +0 -379
- package/dist/lib/reporting/ledger/row-storage-object.mjs +0 -335
- package/dist/lib/reporting/ledger/row-storage-provider-io.d.mts +0 -44
- package/dist/lib/reporting/ledger/row-storage-provider-io.d.ts +0 -44
- package/dist/lib/reporting/ledger/row-storage-provider-io.js +0 -142
- package/dist/lib/reporting/ledger/row-storage-provider-io.mjs +0 -111
- package/dist/lib/reporting/ledger/row-storage.d.mts +0 -316
- package/dist/lib/reporting/ledger/row-storage.d.ts +0 -316
- package/dist/lib/reporting/ledger/row-storage.js +0 -812
- package/dist/lib/reporting/ledger/row-storage.mjs +0 -802
- package/dist/lib/reporting/s3/index.d.mts +0 -34
- package/dist/lib/reporting/s3/index.d.ts +0 -34
- package/dist/lib/reporting/s3/index.js +0 -347
- package/dist/lib/reporting/s3/index.mjs +0 -340
- package/dist/lib/reporting/service/maintenance.d.mts +0 -148
- package/dist/lib/reporting/service/maintenance.d.ts +0 -148
- package/dist/lib/reporting/service/maintenance.js +0 -185
- package/dist/lib/reporting/service/maintenance.mjs +0 -159
- package/examples/reliable-reporting-service/README.md +0 -169
|
@@ -1,2332 +0,0 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { canonicalize } from "../../utils/jcs.mjs";
|
|
3
|
-
import { isReportingAdjustmentReceiptEvidence, isReportingReceiptEvidence } from "../evidence.mjs";
|
|
4
|
-
import { REPORTING_LEDGER_AUTHORITY } from "./types.mjs";
|
|
5
|
-
import { ReportingRowStorageV1, isReportingRowStoreError } from "./row-storage.mjs";
|
|
6
|
-
import {
|
|
7
|
-
assertReportingObjectWriteScope,
|
|
8
|
-
reportingObjectWritePlanFingerprint,
|
|
9
|
-
ReportingObjectWriteConflictError,
|
|
10
|
-
ReportingObjectWriteNotRevokedError
|
|
11
|
-
} from "./object-writes.mjs";
|
|
12
|
-
import {
|
|
13
|
-
adjustmentReceiptEvidenceMatches,
|
|
14
|
-
assertMaterializationOutcome,
|
|
15
|
-
receiptEvidenceMatches,
|
|
16
|
-
REPORTING_DESTINATION_PATTERNS_V1,
|
|
17
|
-
REPORTING_DESTINATION_TRANSPORT_PATTERN_V1
|
|
18
|
-
} from "./managed.mjs";
|
|
19
|
-
const REPORTING_MANAGED_DELIVERY_MIGRATION = `
|
|
20
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_destination_authorizations (
|
|
21
|
-
account_id TEXT NOT NULL,
|
|
22
|
-
destination_ref TEXT NOT NULL,
|
|
23
|
-
generation BIGINT NOT NULL,
|
|
24
|
-
authorized_at TIMESTAMPTZ NOT NULL,
|
|
25
|
-
revoked_at TIMESTAMPTZ,
|
|
26
|
-
cleanup_completed_at TIMESTAMPTZ,
|
|
27
|
-
cleanup_lease_owner TEXT,
|
|
28
|
-
cleanup_lease_generation BIGINT NOT NULL DEFAULT 0,
|
|
29
|
-
cleanup_lease_expires_at TIMESTAMPTZ,
|
|
30
|
-
changed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
31
|
-
data JSONB NOT NULL,
|
|
32
|
-
PRIMARY KEY (account_id, destination_ref, generation),
|
|
33
|
-
CHECK (generation > 0),
|
|
34
|
-
CHECK (revoked_at IS NULL OR revoked_at >= authorized_at)
|
|
35
|
-
);
|
|
36
|
-
ALTER TABLE adcp_reporting_destination_authorizations
|
|
37
|
-
ADD COLUMN IF NOT EXISTS changed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp();
|
|
38
|
-
CREATE UNIQUE INDEX IF NOT EXISTS adcp_reporting_destination_authorizations_current
|
|
39
|
-
ON adcp_reporting_destination_authorizations (account_id, destination_ref)
|
|
40
|
-
WHERE revoked_at IS NULL;
|
|
41
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_destination_authorizations_cleanup
|
|
42
|
-
ON adcp_reporting_destination_authorizations (revoked_at, account_id, destination_ref)
|
|
43
|
-
WHERE revoked_at IS NOT NULL AND cleanup_completed_at IS NULL;
|
|
44
|
-
|
|
45
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_managed_bindings (
|
|
46
|
-
configuration_id TEXT PRIMARY KEY REFERENCES adcp_reporting_configurations(configuration_id),
|
|
47
|
-
account_id TEXT NOT NULL,
|
|
48
|
-
delivery_config_id TEXT NOT NULL,
|
|
49
|
-
delivery_config_version INTEGER NOT NULL,
|
|
50
|
-
destination_ref TEXT NOT NULL,
|
|
51
|
-
authorization_generation BIGINT NOT NULL,
|
|
52
|
-
semantic_fingerprint TEXT NOT NULL,
|
|
53
|
-
data JSONB NOT NULL,
|
|
54
|
-
created_at TIMESTAMPTZ NOT NULL,
|
|
55
|
-
FOREIGN KEY (account_id, destination_ref, authorization_generation)
|
|
56
|
-
REFERENCES adcp_reporting_destination_authorizations(account_id, destination_ref, generation),
|
|
57
|
-
UNIQUE (account_id, delivery_config_id, delivery_config_version)
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_materializations (
|
|
61
|
-
materialization_id TEXT PRIMARY KEY,
|
|
62
|
-
account_id TEXT NOT NULL,
|
|
63
|
-
configuration_id TEXT NOT NULL REFERENCES adcp_reporting_managed_bindings(configuration_id),
|
|
64
|
-
obligation_id TEXT NOT NULL REFERENCES adcp_reporting_obligations(obligation_id),
|
|
65
|
-
revision_id TEXT NOT NULL REFERENCES adcp_reporting_revisions(revision_id),
|
|
66
|
-
destination_ref TEXT NOT NULL,
|
|
67
|
-
authorization_generation BIGINT NOT NULL,
|
|
68
|
-
attempt INTEGER NOT NULL,
|
|
69
|
-
status TEXT NOT NULL,
|
|
70
|
-
data JSONB NOT NULL,
|
|
71
|
-
created_at TIMESTAMPTZ NOT NULL,
|
|
72
|
-
recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
73
|
-
changed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
74
|
-
lease_owner TEXT,
|
|
75
|
-
lease_generation BIGINT NOT NULL DEFAULT 0,
|
|
76
|
-
lease_expires_at TIMESTAMPTZ,
|
|
77
|
-
UNIQUE (configuration_id, revision_id, attempt),
|
|
78
|
-
CHECK (attempt > 0),
|
|
79
|
-
CHECK (status IN ('pending', 'available', 'delivered', 'failed'))
|
|
80
|
-
);
|
|
81
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_materializations_claim
|
|
82
|
-
ON adcp_reporting_materializations (created_at, materialization_id) WHERE status = 'pending';
|
|
83
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_materializations_obligation
|
|
84
|
-
ON adcp_reporting_materializations (obligation_id, recorded_at, materialization_id);
|
|
85
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_materializations_account_retention
|
|
86
|
-
ON adcp_reporting_materializations (account_id, recorded_at);
|
|
87
|
-
CREATE UNIQUE INDEX IF NOT EXISTS adcp_reporting_materializations_success
|
|
88
|
-
ON adcp_reporting_materializations (configuration_id, revision_id)
|
|
89
|
-
WHERE status IN ('available', 'delivered');
|
|
90
|
-
|
|
91
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_receipts (
|
|
92
|
-
account_id TEXT NOT NULL,
|
|
93
|
-
consumer_id TEXT NOT NULL,
|
|
94
|
-
reporting_receipt_id TEXT NOT NULL,
|
|
95
|
-
receipt_kind TEXT NOT NULL,
|
|
96
|
-
subject_id TEXT NOT NULL,
|
|
97
|
-
supersedes_receipt_id TEXT,
|
|
98
|
-
is_current BOOLEAN NOT NULL,
|
|
99
|
-
semantic_fingerprint TEXT NOT NULL,
|
|
100
|
-
data JSONB NOT NULL,
|
|
101
|
-
received_at TIMESTAMPTZ NOT NULL,
|
|
102
|
-
recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
103
|
-
PRIMARY KEY (account_id, consumer_id, reporting_receipt_id),
|
|
104
|
-
CHECK (receipt_kind IN ('revision', 'adjustment'))
|
|
105
|
-
);
|
|
106
|
-
CREATE UNIQUE INDEX IF NOT EXISTS adcp_reporting_receipts_current
|
|
107
|
-
ON adcp_reporting_receipts (account_id, consumer_id, receipt_kind, subject_id) WHERE is_current;
|
|
108
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_receipts_readback
|
|
109
|
-
ON adcp_reporting_receipts (account_id, consumer_id, recorded_at, reporting_receipt_id);
|
|
110
|
-
-- The as-of-cutoff leaf asks, per candidate, whether anything recorded by the
|
|
111
|
-
-- cutoff supersedes it. Without this that question is a scan of the caller's
|
|
112
|
-
-- whole receipt history.
|
|
113
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_receipts_supersedes
|
|
114
|
-
ON adcp_reporting_receipts (account_id, consumer_id, supersedes_receipt_id)
|
|
115
|
-
WHERE supersedes_receipt_id IS NOT NULL;
|
|
116
|
-
-- Every lifecycle read is "the receipts for this obligation's subjects". The
|
|
117
|
-
-- receipt table is global and its other indexes lead with account and
|
|
118
|
-
-- consumer, neither of which that question supplies, so each due obligation
|
|
119
|
-
-- re-scanned the whole receipt history.
|
|
120
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_receipts_subject
|
|
121
|
-
ON adcp_reporting_receipts (subject_id, receipt_kind, recorded_at);
|
|
122
|
-
|
|
123
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_receipt_batches (
|
|
124
|
-
account_id TEXT NOT NULL,
|
|
125
|
-
consumer_id TEXT NOT NULL,
|
|
126
|
-
idempotency_key TEXT NOT NULL,
|
|
127
|
-
request_fingerprint TEXT NOT NULL,
|
|
128
|
-
results JSONB NOT NULL,
|
|
129
|
-
recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
130
|
-
PRIMARY KEY (account_id, consumer_id, idempotency_key)
|
|
131
|
-
);
|
|
132
|
-
-- Pruning used to ask "does any live replay row contain this receipt id" per
|
|
133
|
-
-- candidate, which no index could serve for a correlated operand. It now
|
|
134
|
-
-- expands the account's replay rows once instead, so no GIN index is needed
|
|
135
|
-
-- and the batches table is not burdened with maintaining one.
|
|
136
|
-
|
|
137
|
-
-- Agent-wide promises, durable and shared by every store instance and process
|
|
138
|
-
-- that talks to this database. An in-memory bound only constrains the process
|
|
139
|
-
-- that set it, so two replicas could each believe they were authoritative and
|
|
140
|
-
-- publish different windows over the same bindings.
|
|
141
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_managed_policy (
|
|
142
|
-
policy_key TEXT PRIMARY KEY,
|
|
143
|
-
advertised_recovery_window_seconds BIGINT,
|
|
144
|
-
advertised_status_retention_days BIGINT,
|
|
145
|
-
advertised_resource_retention_days BIGINT,
|
|
146
|
-
advertised_authorization_revocation_seconds BIGINT,
|
|
147
|
-
materialization_planning_cursor TEXT,
|
|
148
|
-
recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
149
|
-
changed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
|
150
|
-
);
|
|
151
|
-
-- Upgrade registries installed by earlier Managed Delivery release candidates.
|
|
152
|
-
ALTER TABLE adcp_reporting_managed_policy
|
|
153
|
-
ADD COLUMN IF NOT EXISTS advertised_resource_retention_days BIGINT;
|
|
154
|
-
ALTER TABLE adcp_reporting_managed_policy
|
|
155
|
-
ADD COLUMN IF NOT EXISTS advertised_authorization_revocation_seconds BIGINT;
|
|
156
|
-
-- Durable round-robin position for deployment-wide materialization planning.
|
|
157
|
-
-- A process-local cursor restarts at the lexically first tenant after every
|
|
158
|
-
-- deploy; without a durable position a continuously busy first account can
|
|
159
|
-
-- consume every bounded planning page and a later account never progresses.
|
|
160
|
-
ALTER TABLE adcp_reporting_managed_policy
|
|
161
|
-
ADD COLUMN IF NOT EXISTS materialization_planning_cursor TEXT;
|
|
162
|
-
-- Every policy read or adoption locks this durable row. Creating it in the
|
|
163
|
-
-- migration avoids a missing-row predicate gap on a fresh registry.
|
|
164
|
-
INSERT INTO adcp_reporting_managed_policy (policy_key)
|
|
165
|
-
VALUES ('agent')
|
|
166
|
-
ON CONFLICT (policy_key) DO NOTHING;
|
|
167
|
-
|
|
168
|
-
-- Permanent, compact identity for a receipt whose body has aged out.
|
|
169
|
-
-- Bodies are retained only through the advertised horizon, but identity is
|
|
170
|
-
-- forever: a pruned receipt_id must never bind different content later, and a
|
|
171
|
-
-- subject that reached a terminal accepted leaf must never reopen because the
|
|
172
|
-
-- row proving it was terminal has expired.
|
|
173
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_receipt_tombstones (
|
|
174
|
-
account_id TEXT NOT NULL,
|
|
175
|
-
consumer_id TEXT NOT NULL,
|
|
176
|
-
reporting_receipt_id TEXT NOT NULL,
|
|
177
|
-
receipt_kind TEXT NOT NULL,
|
|
178
|
-
subject_id TEXT NOT NULL,
|
|
179
|
-
status TEXT NOT NULL,
|
|
180
|
-
was_current BOOLEAN NOT NULL,
|
|
181
|
-
semantic_fingerprint TEXT NOT NULL,
|
|
182
|
-
pruned_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
183
|
-
PRIMARY KEY (account_id, consumer_id, reporting_receipt_id)
|
|
184
|
-
);
|
|
185
|
-
-- When the pruned receipt itself was recorded, and what it superseded. A
|
|
186
|
-
-- tombstone without them is a conclusion with no place in time: a historical
|
|
187
|
-
-- projection applied an acceptance that had not happened yet at its cutoff,
|
|
188
|
-
-- and a chain whose successor was pruned let the predecessor it superseded
|
|
189
|
-
-- come back as the live leaf. Nullable for rows written before this column
|
|
190
|
-
-- existed; readers fall back to pruned_at, which is never earlier.
|
|
191
|
-
ALTER TABLE adcp_reporting_receipt_tombstones
|
|
192
|
-
ADD COLUMN IF NOT EXISTS subject_recorded_at TIMESTAMPTZ;
|
|
193
|
-
ALTER TABLE adcp_reporting_receipt_tombstones
|
|
194
|
-
ADD COLUMN IF NOT EXISTS supersedes_receipt_id TEXT;
|
|
195
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_receipt_tombstones_supersedes
|
|
196
|
-
ON adcp_reporting_receipt_tombstones (account_id, consumer_id, supersedes_receipt_id)
|
|
197
|
-
WHERE supersedes_receipt_id IS NOT NULL;
|
|
198
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_receipt_tombstones_subject
|
|
199
|
-
ON adcp_reporting_receipt_tombstones (account_id, consumer_id, receipt_kind, subject_id);
|
|
200
|
-
|
|
201
|
-
-- Compact terminal state for a materialization whose row has been pruned.
|
|
202
|
-
-- Attempt history is control state, not evidence: without it a revision whose
|
|
203
|
-
-- attempts were exhausted, or which already succeeded, restarts at attempt 1
|
|
204
|
-
-- the moment its rows age out.
|
|
205
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_materialization_tombstones (
|
|
206
|
-
configuration_id TEXT NOT NULL,
|
|
207
|
-
revision_id TEXT NOT NULL,
|
|
208
|
-
account_id TEXT NOT NULL,
|
|
209
|
-
obligation_id TEXT NOT NULL,
|
|
210
|
-
highest_attempt INTEGER NOT NULL,
|
|
211
|
-
reached_success BOOLEAN NOT NULL,
|
|
212
|
-
pruned_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
213
|
-
-- When the delivery this records actually succeeded, so a projection at an
|
|
214
|
-
-- earlier cutoff does not read it as already delivered.
|
|
215
|
-
reached_success_at TIMESTAMPTZ,
|
|
216
|
-
PRIMARY KEY (configuration_id, revision_id)
|
|
217
|
-
);
|
|
218
|
-
-- These rows are permanent and every lifecycle projection reads them by
|
|
219
|
-
-- obligation and success. The primary key leads with configuration, which
|
|
220
|
-
-- that question does not supply, so the scan grew without bound for the life
|
|
221
|
-
-- of the deployment.
|
|
222
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_materialization_tombstones_obligation
|
|
223
|
-
ON adcp_reporting_materialization_tombstones (obligation_id, reached_success, reached_success_at);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
ALTER TABLE adcp_reporting_destination_authorizations
|
|
227
|
-
ADD COLUMN IF NOT EXISTS cleanup_lease_issued_at TIMESTAMPTZ;
|
|
228
|
-
|
|
229
|
-
-- Delivery pattern and transport recorded on the authorization generation, so
|
|
230
|
-
-- a revocation can be routed to the transport that holds the provider grant.
|
|
231
|
-
-- Both are immutable for a generation (a new delivery contract mints a new
|
|
232
|
-
-- destination_ref generation) and NULL for a generation whose contract is not
|
|
233
|
-
-- known. The block runs once, in the same statement batch that adds the
|
|
234
|
-
-- columns: it backfills a row only when every binding on that exact
|
|
235
|
-
-- (account_id, destination_ref, generation) agrees on the method and carries
|
|
236
|
-
-- the same transport, and otherwise leaves the row NULL. A later re-run finds
|
|
237
|
-
-- the columns present and does nothing, so a generation that was left NULL
|
|
238
|
-
-- stays NULL rather than being re-decided by whichever binding arrived since.
|
|
239
|
-
DO $pattern_transport$
|
|
240
|
-
DECLARE
|
|
241
|
-
unresolved BIGINT;
|
|
242
|
-
BEGIN
|
|
243
|
-
IF NOT EXISTS (
|
|
244
|
-
SELECT 1 FROM information_schema.columns
|
|
245
|
-
WHERE table_schema = current_schema()
|
|
246
|
-
AND table_name = 'adcp_reporting_destination_authorizations'
|
|
247
|
-
AND column_name = 'transport'
|
|
248
|
-
) THEN
|
|
249
|
-
ALTER TABLE adcp_reporting_destination_authorizations ADD COLUMN IF NOT EXISTS pattern TEXT;
|
|
250
|
-
ALTER TABLE adcp_reporting_destination_authorizations ADD COLUMN IF NOT EXISTS transport TEXT;
|
|
251
|
-
WITH agreed AS (
|
|
252
|
-
SELECT authz.account_id, authz.destination_ref, authz.generation,
|
|
253
|
-
MIN(binding.data->>'method') AS pattern,
|
|
254
|
-
MIN(binding.data->>'transport') AS transport
|
|
255
|
-
FROM adcp_reporting_destination_authorizations authz
|
|
256
|
-
JOIN adcp_reporting_managed_bindings binding
|
|
257
|
-
ON binding.account_id = authz.account_id
|
|
258
|
-
AND binding.destination_ref = authz.destination_ref
|
|
259
|
-
AND binding.authorization_generation = authz.generation
|
|
260
|
-
WHERE authz.pattern IS NULL AND authz.transport IS NULL
|
|
261
|
-
GROUP BY authz.account_id, authz.destination_ref, authz.generation
|
|
262
|
-
HAVING COUNT(*) = COUNT(binding.data->>'method')
|
|
263
|
-
AND COUNT(DISTINCT binding.data->>'method') = 1
|
|
264
|
-
AND COUNT(*) = COUNT(binding.data->>'transport')
|
|
265
|
-
AND COUNT(DISTINCT binding.data->>'transport') = 1
|
|
266
|
-
)
|
|
267
|
-
UPDATE adcp_reporting_destination_authorizations authz
|
|
268
|
-
SET pattern = agreed.pattern,
|
|
269
|
-
transport = agreed.transport,
|
|
270
|
-
data = authz.data || jsonb_build_object('pattern', agreed.pattern, 'transport', agreed.transport)
|
|
271
|
-
FROM agreed
|
|
272
|
-
WHERE authz.account_id = agreed.account_id
|
|
273
|
-
AND authz.destination_ref = agreed.destination_ref
|
|
274
|
-
AND authz.generation = agreed.generation;
|
|
275
|
-
SELECT COUNT(*) INTO unresolved FROM adcp_reporting_destination_authorizations WHERE transport IS NULL;
|
|
276
|
-
IF unresolved > 0 THEN
|
|
277
|
-
RAISE NOTICE 'adcp_reporting_destination_authorizations: % generation(s) left without pattern/transport; list them with PostgresReportingManagedDeliveryStore.listDestinationAuthorizationsWithoutContract()', unresolved;
|
|
278
|
-
END IF;
|
|
279
|
-
END IF;
|
|
280
|
-
END
|
|
281
|
-
$pattern_transport$;
|
|
282
|
-
`.trim();
|
|
283
|
-
const GENERIC_RECEIPT_MESSAGE = "Receipt does not match authorized current reporting evidence";
|
|
284
|
-
const MAX_PLAN = 1e3;
|
|
285
|
-
const MAX_MATERIALIZATIONS_PER_ACCOUNT = 1e5;
|
|
286
|
-
const MAX_RECEIPT_BATCHES_PER_CONSUMER = 1e4;
|
|
287
|
-
const MAX_RECEIPTS_PER_CONSUMER = 1e5;
|
|
288
|
-
const MAX_RECEIPTS_PER_ARRAY = 100;
|
|
289
|
-
const MAX_RECEIPT_RESULTS = 100;
|
|
290
|
-
const MAX_MATERIALIZATION_ATTEMPTS = 5;
|
|
291
|
-
const REVOCATION_RETRY_BACKOFF_MS = 5e3;
|
|
292
|
-
const RECEIPT_BATCH_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
293
|
-
const MAX_RECEIPT_BATCH_RESULT_BYTES = 64 * 1024;
|
|
294
|
-
class PostgresReportingManagedDeliveryStore {
|
|
295
|
-
constructor(pool, options = {}) {
|
|
296
|
-
this.pool = pool;
|
|
297
|
-
this.rowStorage = new ReportingRowStorageV1(pool);
|
|
298
|
-
if (options.statusRetentionDays !== void 0) positiveInteger(options.statusRetentionDays, "statusRetentionDays");
|
|
299
|
-
if (options.evidenceRetentionDays !== void 0) {
|
|
300
|
-
positiveInteger(options.evidenceRetentionDays, "evidenceRetentionDays");
|
|
301
|
-
const replayFloorDays = Math.ceil(RECEIPT_BATCH_RETENTION_MS / 864e5);
|
|
302
|
-
if (options.evidenceRetentionDays < replayFloorDays) {
|
|
303
|
-
throw new RangeError(
|
|
304
|
-
`evidenceRetentionDays must be at least the ${replayFloorDays}-day receipt replay retention`
|
|
305
|
-
);
|
|
306
|
-
}
|
|
307
|
-
if (options.statusRetentionDays !== void 0 && options.evidenceRetentionDays < options.statusRetentionDays) {
|
|
308
|
-
throw new RangeError(
|
|
309
|
-
`evidenceRetentionDays must be at least the advertised statusRetentionDays (${options.statusRetentionDays}); pruning inside an advertised horizon breaks it`
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
if (options.advertisedRecoveryWindowSeconds !== void 0) {
|
|
314
|
-
nonnegativeSafeInteger(options.advertisedRecoveryWindowSeconds, "advertisedRecoveryWindowSeconds");
|
|
315
|
-
}
|
|
316
|
-
this.pendingRecoveryWindowSeconds = options.advertisedRecoveryWindowSeconds;
|
|
317
|
-
this.statusRetentionDays = options.statusRetentionDays;
|
|
318
|
-
this.evidenceRetentionDays = options.evidenceRetentionDays;
|
|
319
|
-
this.notificationActivityPort = options.notificationActivityPort;
|
|
320
|
-
}
|
|
321
|
-
pool;
|
|
322
|
-
evidenceRetentionDays;
|
|
323
|
-
statusRetentionDays;
|
|
324
|
-
pendingRecoveryWindowSeconds;
|
|
325
|
-
policyRegistered = false;
|
|
326
|
-
bindingAdmission;
|
|
327
|
-
notificationActivityPort;
|
|
328
|
-
/**
|
|
329
|
-
* Durably records the agent-wide advertised recovery window.
|
|
330
|
-
*
|
|
331
|
-
* Written to the database, not held in memory: the promise is agent-wide,
|
|
332
|
-
* so a second store instance — in this process or another replica — must be
|
|
333
|
-
* held to the same value, and `installBinding` must read it inside its own
|
|
334
|
-
* transaction rather than trusting whatever the local object happens to
|
|
335
|
-
* know. Multiple replicas monotonically retain the smallest registered
|
|
336
|
-
* maximum: a shorter recovery promise is stronger and can never be widened
|
|
337
|
-
* by a later runtime.
|
|
338
|
-
*/
|
|
339
|
-
async adoptAdvertisedRecoveryWindowSeconds(seconds) {
|
|
340
|
-
nonnegativeSafeInteger(seconds, "automatedRecoveryWindowSeconds");
|
|
341
|
-
await this.upsertPolicy("advertised_recovery_window_seconds", seconds, {
|
|
342
|
-
strongest: Math.min,
|
|
343
|
-
// Checked inside the same transaction that would persist it. Writing
|
|
344
|
-
// first and validating afterwards poisoned the registry: a rejected
|
|
345
|
-
// 60s startup against an installed 900s binding still left a durable
|
|
346
|
-
// 60, and the correct 900s restart was then refused as a conflict.
|
|
347
|
-
validate: (client, effective) => this.validateRecoveryWindow(client, effective.advertised_recovery_window_seconds)
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
/**
|
|
351
|
-
* Durably records the advertised `status_retention_days`.
|
|
352
|
-
*
|
|
353
|
-
* Retention is enforced against the widest promise any runtime registered
|
|
354
|
-
* over this database, so a store configured with a short evidence retention
|
|
355
|
-
* cannot prune inside a horizon a differently configured runtime published.
|
|
356
|
-
*/
|
|
357
|
-
async adoptAdvertisedStatusRetentionDays(days) {
|
|
358
|
-
positiveInteger(days, "statusRetentionDays");
|
|
359
|
-
await this.upsertPolicy("advertised_status_retention_days", days, {
|
|
360
|
-
strongest: Math.max
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
/** Registers the complete capability policy under one lock and transaction. */
|
|
364
|
-
async adoptAdvertisedPolicies(policy) {
|
|
365
|
-
nonnegativeSafeInteger(policy.automatedRecoveryWindowSeconds, "automatedRecoveryWindowSeconds");
|
|
366
|
-
positiveInteger(policy.statusRetentionDays, "statusRetentionDays");
|
|
367
|
-
positiveInteger(policy.resourceRetentionDays, "resourceRetentionDays");
|
|
368
|
-
nonnegativeSafeInteger(policy.authorizationRevocationSeconds, "authorizationRevocationSeconds");
|
|
369
|
-
const adopted = await this.upsertPolicies(
|
|
370
|
-
[
|
|
371
|
-
{
|
|
372
|
-
column: "advertised_recovery_window_seconds",
|
|
373
|
-
value: policy.automatedRecoveryWindowSeconds,
|
|
374
|
-
strongest: Math.min
|
|
375
|
-
},
|
|
376
|
-
{
|
|
377
|
-
column: "advertised_status_retention_days",
|
|
378
|
-
value: policy.statusRetentionDays,
|
|
379
|
-
strongest: Math.max
|
|
380
|
-
},
|
|
381
|
-
{
|
|
382
|
-
column: "advertised_resource_retention_days",
|
|
383
|
-
value: policy.resourceRetentionDays,
|
|
384
|
-
strongest: Math.max
|
|
385
|
-
},
|
|
386
|
-
{
|
|
387
|
-
column: "advertised_authorization_revocation_seconds",
|
|
388
|
-
value: policy.authorizationRevocationSeconds,
|
|
389
|
-
strongest: Math.min
|
|
390
|
-
}
|
|
391
|
-
],
|
|
392
|
-
(client, effective) => this.validateRecoveryWindow(client, effective.advertised_recovery_window_seconds)
|
|
393
|
-
);
|
|
394
|
-
return {
|
|
395
|
-
automatedRecoveryWindowSeconds: adopted.advertised_recovery_window_seconds,
|
|
396
|
-
statusRetentionDays: adopted.advertised_status_retention_days,
|
|
397
|
-
resourceRetentionDays: adopted.advertised_resource_retention_days,
|
|
398
|
-
authorizationRevocationSeconds: adopted.advertised_authorization_revocation_seconds
|
|
399
|
-
};
|
|
400
|
-
}
|
|
401
|
-
/**
|
|
402
|
-
* Flushes constructor-supplied promises into the durable registry.
|
|
403
|
-
*
|
|
404
|
-
* Called from every entry point that depends on them, so a store built with
|
|
405
|
-
* options behaves identically to one a runtime adopted into.
|
|
406
|
-
*/
|
|
407
|
-
async ensurePolicyRegistered() {
|
|
408
|
-
if (this.policyRegistered) return;
|
|
409
|
-
if (this.pendingRecoveryWindowSeconds !== void 0 && this.statusRetentionDays !== void 0) {
|
|
410
|
-
await this.upsertPolicies(
|
|
411
|
-
[
|
|
412
|
-
{
|
|
413
|
-
column: "advertised_recovery_window_seconds",
|
|
414
|
-
value: this.pendingRecoveryWindowSeconds,
|
|
415
|
-
strongest: Math.min
|
|
416
|
-
},
|
|
417
|
-
{
|
|
418
|
-
column: "advertised_status_retention_days",
|
|
419
|
-
value: this.statusRetentionDays,
|
|
420
|
-
strongest: Math.max
|
|
421
|
-
}
|
|
422
|
-
],
|
|
423
|
-
(client, effective) => this.validateRecoveryWindow(client, effective.advertised_recovery_window_seconds)
|
|
424
|
-
);
|
|
425
|
-
} else if (this.pendingRecoveryWindowSeconds !== void 0) {
|
|
426
|
-
await this.adoptAdvertisedRecoveryWindowSeconds(this.pendingRecoveryWindowSeconds);
|
|
427
|
-
} else if (this.statusRetentionDays !== void 0) {
|
|
428
|
-
await this.adoptAdvertisedStatusRetentionDays(this.statusRetentionDays);
|
|
429
|
-
}
|
|
430
|
-
this.policyRegistered = true;
|
|
431
|
-
}
|
|
432
|
-
async upsertPolicy(column, value, options) {
|
|
433
|
-
await this.upsertPolicies([{ column, value, strongest: options.strongest }], options.validate);
|
|
434
|
-
}
|
|
435
|
-
async upsertPolicies(policies, validate) {
|
|
436
|
-
return this.transaction(async (client) => {
|
|
437
|
-
const current = await this.readPolicyRow(client, "update");
|
|
438
|
-
const effective = { ...current };
|
|
439
|
-
for (const policy of policies) {
|
|
440
|
-
const existing = current[policy.column];
|
|
441
|
-
effective[policy.column] = existing === null ? policy.value : policy.strongest(existing, policy.value);
|
|
442
|
-
}
|
|
443
|
-
await validate?.(client, effective);
|
|
444
|
-
for (const policy of policies) {
|
|
445
|
-
await client.query(
|
|
446
|
-
`UPDATE adcp_reporting_managed_policy SET ${policy.column} = $1,
|
|
447
|
-
changed_at = clock_timestamp() WHERE policy_key = 'agent'`,
|
|
448
|
-
[effective[policy.column]]
|
|
449
|
-
);
|
|
450
|
-
}
|
|
451
|
-
return effective;
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
async validateRecoveryWindow(client, seconds) {
|
|
455
|
-
const widest = await client.query(
|
|
456
|
-
`WITH installed AS (
|
|
457
|
-
SELECT configuration.data->'schedule'->>'recoveryWindowMilliseconds' AS milliseconds
|
|
458
|
-
FROM adcp_reporting_managed_bindings binding
|
|
459
|
-
JOIN adcp_reporting_configurations configuration
|
|
460
|
-
ON configuration.configuration_id = binding.configuration_id
|
|
461
|
-
)
|
|
462
|
-
SELECT COUNT(*)::text AS installed,
|
|
463
|
-
COUNT(*) FILTER (WHERE milliseconds ~ '^[0-9]+$')::text AS valid,
|
|
464
|
-
MAX(CASE WHEN milliseconds ~ '^[0-9]+$'
|
|
465
|
-
THEN CEIL(milliseconds::numeric / 1000) END)::text AS widest
|
|
466
|
-
FROM installed`
|
|
467
|
-
);
|
|
468
|
-
const row = widest.rows[0];
|
|
469
|
-
if (row?.installed !== row?.valid) {
|
|
470
|
-
throw new Error("Every installed managed Core configuration must have a usable non-negative recovery window");
|
|
471
|
-
}
|
|
472
|
-
const widestInstalled = Number(row?.widest ?? 0);
|
|
473
|
-
if (!Number.isSafeInteger(widestInstalled)) {
|
|
474
|
-
throw new Error("Installed managed Core recovery windows exceed the supported safe-integer range");
|
|
475
|
-
}
|
|
476
|
-
if (seconds < widestInstalled) {
|
|
477
|
-
throw new Error(
|
|
478
|
-
`automatedRecoveryWindowSeconds must be at least the widest installed managed Core recovery window (${widestInstalled}s); advertising ${seconds}s would promise a recovery bound this deployment does not keep for every tenant`
|
|
479
|
-
);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
async readPolicyRow(client, lock) {
|
|
483
|
-
const lockClause = lock === "share" ? " FOR SHARE" : lock === "update" ? " FOR UPDATE" : "";
|
|
484
|
-
const result = await client.query(
|
|
485
|
-
`SELECT advertised_recovery_window_seconds::text AS recovery,
|
|
486
|
-
advertised_status_retention_days::text AS status,
|
|
487
|
-
advertised_resource_retention_days::text AS resource,
|
|
488
|
-
advertised_authorization_revocation_seconds::text AS revocation
|
|
489
|
-
FROM adcp_reporting_managed_policy WHERE policy_key = 'agent'${lockClause}`
|
|
490
|
-
);
|
|
491
|
-
const row = result.rows[0];
|
|
492
|
-
if (!row) {
|
|
493
|
-
throw new Error(
|
|
494
|
-
"Managed reporting policy sentinel is missing; apply REPORTING_MANAGED_DELIVERY_MIGRATION before use"
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
return {
|
|
498
|
-
advertised_recovery_window_seconds: policyNumber(row?.recovery),
|
|
499
|
-
advertised_status_retention_days: policyNumber(row?.status),
|
|
500
|
-
advertised_resource_retention_days: policyNumber(row?.resource),
|
|
501
|
-
advertised_authorization_revocation_seconds: policyNumber(row?.revocation)
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
async readPolicy(client, lock) {
|
|
505
|
-
const policy = await this.readPolicyRow(client, lock);
|
|
506
|
-
return {
|
|
507
|
-
recoveryWindowSeconds: policy.advertised_recovery_window_seconds,
|
|
508
|
-
statusRetentionDays: policy.advertised_status_retention_days,
|
|
509
|
-
resourceRetentionDays: policy.advertised_resource_retention_days,
|
|
510
|
-
authorizationRevocationSeconds: policy.advertised_authorization_revocation_seconds
|
|
511
|
-
};
|
|
512
|
-
}
|
|
513
|
-
/** Read-only access to PostgreSQL row chunks for claim hydration. */
|
|
514
|
-
rowStorage;
|
|
515
|
-
/** SQL fragment scoping a count to the active retention window, if one is set. */
|
|
516
|
-
activeScope(column) {
|
|
517
|
-
return this.evidenceRetentionDays === void 0 ? "" : ` AND ${column} >= clock_timestamp() - (${this.evidenceRetentionDays}::bigint * INTERVAL '1 day')`;
|
|
518
|
-
}
|
|
519
|
-
/**
|
|
520
|
-
* Deletes managed evidence that has aged out of `evidenceRetentionDays`.
|
|
521
|
-
*
|
|
522
|
-
* Only ever removes evidence the deployment no longer promises: nothing
|
|
523
|
-
* inside the retention window, nothing still `pending` or leased, and no
|
|
524
|
-
* receipt batch inside its own replay retention. Immutable evidence stays
|
|
525
|
-
* immutable while it is retained — this frees capacity at the far end of the
|
|
526
|
-
* window rather than rewriting anything. Returns what it removed so a
|
|
527
|
-
* scheduler can page through with `limit`.
|
|
528
|
-
*/
|
|
529
|
-
async pruneExpiredEvidence(input) {
|
|
530
|
-
if (this.evidenceRetentionDays === void 0) {
|
|
531
|
-
throw new Error("pruneExpiredEvidence requires PostgresReportingManagedDeliveryStore({ evidenceRetentionDays })");
|
|
532
|
-
}
|
|
533
|
-
await this.ensurePolicyRegistered();
|
|
534
|
-
const limit = input.limit ?? 1e3;
|
|
535
|
-
positiveInteger(limit, "limit");
|
|
536
|
-
const days = this.evidenceRetentionDays;
|
|
537
|
-
return this.transaction(async (client) => {
|
|
538
|
-
const registered = await this.readPolicy(client, "share");
|
|
539
|
-
const retentionFloor = Math.max(registered.statusRetentionDays ?? 0, registered.resourceRetentionDays ?? 0);
|
|
540
|
-
if (days < retentionFloor) {
|
|
541
|
-
throw new Error(
|
|
542
|
-
`evidenceRetentionDays ${days} is shorter than the strongest advertised status/resource retention of ${retentionFloor} days registered for this database; pruning would cut inside an advertised horizon`
|
|
543
|
-
);
|
|
544
|
-
}
|
|
545
|
-
const cutoff = (await client.query(
|
|
546
|
-
`SELECT (clock_timestamp() - ($1::bigint * INTERVAL '1 day'))::text AS cutoff`,
|
|
547
|
-
[days]
|
|
548
|
-
)).rows[0].cutoff;
|
|
549
|
-
const doomedReceipts = await client.query(
|
|
550
|
-
`WITH live_referenced AS (
|
|
551
|
-
SELECT DISTINCT batch.consumer_id,
|
|
552
|
-
COALESCE(
|
|
553
|
-
elem ->> 'id',
|
|
554
|
-
elem ->> 'reporting_receipt_id',
|
|
555
|
-
elem -> 'receipt' ->> 'reporting_receipt_id',
|
|
556
|
-
elem -> 'adjustment_receipt' ->> 'reporting_receipt_id'
|
|
557
|
-
) AS reporting_receipt_id
|
|
558
|
-
FROM adcp_reporting_receipt_batches batch,
|
|
559
|
-
LATERAL jsonb_array_elements(batch.results) elem
|
|
560
|
-
-- Only a replay row still inside its own retention pins anything.
|
|
561
|
-
-- Selection now runs before the batch delete, so without this an
|
|
562
|
-
-- already-expired row kept holding its receipts alive forever.
|
|
563
|
-
WHERE batch.account_id = $1
|
|
564
|
-
AND batch.recorded_at >= clock_timestamp() - ($4::bigint * INTERVAL '1 millisecond')
|
|
565
|
-
)
|
|
566
|
-
SELECT receipt.consumer_id, receipt.reporting_receipt_id
|
|
567
|
-
FROM adcp_reporting_receipts receipt
|
|
568
|
-
WHERE receipt.account_id = $1
|
|
569
|
-
AND receipt.recorded_at < $3::timestamptz
|
|
570
|
-
AND NOT EXISTS (
|
|
571
|
-
SELECT 1 FROM live_referenced
|
|
572
|
-
WHERE live_referenced.consumer_id = receipt.consumer_id
|
|
573
|
-
AND live_referenced.reporting_receipt_id = receipt.reporting_receipt_id
|
|
574
|
-
)
|
|
575
|
-
-- An acceptance outlives its own age while the resource it
|
|
576
|
-
-- accepts is still readable. Pruning it sooner produced a period
|
|
577
|
-
-- that reads complete with no receipt to show for it.
|
|
578
|
-
--
|
|
579
|
-
-- An adjustment receipt names no materialization at all: it
|
|
580
|
-
-- names the revision it adjusts. Matching only on
|
|
581
|
-
-- reporting_materialization_id therefore held every revision
|
|
582
|
-
-- acceptance and no adjustment acceptance, so a period whose
|
|
583
|
-
-- revision resource was still readable lost the adjustment
|
|
584
|
-
-- evidence behind its own complete.
|
|
585
|
-
AND NOT EXISTS (
|
|
586
|
-
SELECT 1 FROM adcp_reporting_materializations live
|
|
587
|
-
WHERE live.account_id = receipt.account_id
|
|
588
|
-
AND (
|
|
589
|
-
live.materialization_id = receipt.data ->> 'reporting_materialization_id'
|
|
590
|
-
OR live.revision_id = receipt.data ->> 'adjusts_reporting_revision_id'
|
|
591
|
-
)
|
|
592
|
-
AND (live.data -> 'resource' ->> 'expires_at')::timestamptz > clock_timestamp()
|
|
593
|
-
)
|
|
594
|
-
ORDER BY receipt.recorded_at
|
|
595
|
-
LIMIT $2`,
|
|
596
|
-
[input.account_id, limit, cutoff, RECEIPT_BATCH_RETENTION_MS]
|
|
597
|
-
);
|
|
598
|
-
const doomedMaterializations = await client.query(
|
|
599
|
-
`SELECT materialization.materialization_id
|
|
600
|
-
FROM adcp_reporting_materializations materialization
|
|
601
|
-
WHERE materialization.account_id = $1
|
|
602
|
-
AND materialization.recorded_at < $3::timestamptz
|
|
603
|
-
AND materialization.status <> 'pending'
|
|
604
|
-
AND materialization.lease_owner IS NULL
|
|
605
|
-
AND COALESCE(
|
|
606
|
-
(materialization.data -> 'resource' ->> 'expires_at')::timestamptz <= clock_timestamp(),
|
|
607
|
-
true)
|
|
608
|
-
-- No SURVIVING receipt may still name it. Selection runs before
|
|
609
|
-
-- the receipt delete now, so a receipt already doomed in this
|
|
610
|
-
-- same pass must not keep its materialization alive forever.
|
|
611
|
-
AND NOT EXISTS (
|
|
612
|
-
SELECT 1 FROM adcp_reporting_receipts receipt
|
|
613
|
-
WHERE receipt.account_id = materialization.account_id
|
|
614
|
-
AND receipt.data ->> 'reporting_materialization_id' = materialization.materialization_id
|
|
615
|
-
AND NOT EXISTS (
|
|
616
|
-
SELECT 1 FROM unnest($4::text[], $5::text[]) AS doomed(consumer_id, reporting_receipt_id)
|
|
617
|
-
WHERE doomed.consumer_id = receipt.consumer_id
|
|
618
|
-
AND doomed.reporting_receipt_id = receipt.reporting_receipt_id
|
|
619
|
-
)
|
|
620
|
-
)
|
|
621
|
-
ORDER BY materialization.recorded_at
|
|
622
|
-
LIMIT $2`,
|
|
623
|
-
[
|
|
624
|
-
input.account_id,
|
|
625
|
-
limit,
|
|
626
|
-
cutoff,
|
|
627
|
-
doomedReceipts.rows.map((row) => row.consumer_id),
|
|
628
|
-
doomedReceipts.rows.map((row) => row.reporting_receipt_id)
|
|
629
|
-
]
|
|
630
|
-
);
|
|
631
|
-
await advisoryLock(client, accountLock(input.account_id));
|
|
632
|
-
const batches = await client.query(
|
|
633
|
-
`DELETE FROM adcp_reporting_receipt_batches
|
|
634
|
-
WHERE account_id = $1
|
|
635
|
-
AND recorded_at < clock_timestamp() - ($2::bigint * INTERVAL '1 millisecond')`,
|
|
636
|
-
[input.account_id, RECEIPT_BATCH_RETENTION_MS]
|
|
637
|
-
);
|
|
638
|
-
const doomedConsumerIds = doomedReceipts.rows.map((row) => row.consumer_id);
|
|
639
|
-
const doomedReceiptIds = doomedReceipts.rows.map((row) => row.reporting_receipt_id);
|
|
640
|
-
const receipts = await client.query(
|
|
641
|
-
`WITH doomed_keys AS (
|
|
642
|
-
SELECT * FROM unnest($2::text[], $3::text[]) AS pair(consumer_id, reporting_receipt_id)
|
|
643
|
-
), live_referenced AS (
|
|
644
|
-
-- Selection ran unlocked, so its answer is a proposal, not a
|
|
645
|
-
-- verdict: a replay could commit against a candidate between the
|
|
646
|
-
-- select and this delete, and the delete then removed the receipt
|
|
647
|
-
-- that replay had just promised to reproduce. Re-expanding the
|
|
648
|
-
-- live replay rows under the lock is one pass, and it is narrowed
|
|
649
|
-
-- to the candidates consumers rather than the whole account, so
|
|
650
|
-
-- the lock still holds no per-candidate join filter.
|
|
651
|
-
SELECT DISTINCT batch.consumer_id,
|
|
652
|
-
COALESCE(
|
|
653
|
-
elem ->> 'id',
|
|
654
|
-
elem ->> 'reporting_receipt_id',
|
|
655
|
-
elem -> 'receipt' ->> 'reporting_receipt_id',
|
|
656
|
-
elem -> 'adjustment_receipt' ->> 'reporting_receipt_id'
|
|
657
|
-
) AS reporting_receipt_id
|
|
658
|
-
FROM adcp_reporting_receipt_batches batch,
|
|
659
|
-
LATERAL jsonb_array_elements(batch.results) elem
|
|
660
|
-
WHERE batch.account_id = $1
|
|
661
|
-
AND batch.consumer_id = ANY($2::text[])
|
|
662
|
-
AND batch.recorded_at >= clock_timestamp() - ($4::bigint * INTERVAL '1 millisecond')
|
|
663
|
-
), doomed AS (
|
|
664
|
-
SELECT receipt.* FROM adcp_reporting_receipts receipt
|
|
665
|
-
JOIN doomed_keys ON doomed_keys.consumer_id = receipt.consumer_id
|
|
666
|
-
AND doomed_keys.reporting_receipt_id = receipt.reporting_receipt_id
|
|
667
|
-
WHERE receipt.account_id = $1
|
|
668
|
-
AND receipt.recorded_at < $5::timestamptz
|
|
669
|
-
AND NOT EXISTS (
|
|
670
|
-
SELECT 1 FROM live_referenced
|
|
671
|
-
WHERE live_referenced.consumer_id = receipt.consumer_id
|
|
672
|
-
AND live_referenced.reporting_receipt_id = receipt.reporting_receipt_id
|
|
673
|
-
)
|
|
674
|
-
AND NOT EXISTS (
|
|
675
|
-
SELECT 1 FROM adcp_reporting_materializations live
|
|
676
|
-
WHERE live.account_id = receipt.account_id
|
|
677
|
-
AND (
|
|
678
|
-
live.materialization_id = receipt.data ->> 'reporting_materialization_id'
|
|
679
|
-
OR live.revision_id = receipt.data ->> 'adjusts_reporting_revision_id'
|
|
680
|
-
)
|
|
681
|
-
AND (live.data -> 'resource' ->> 'expires_at')::timestamptz > clock_timestamp()
|
|
682
|
-
)
|
|
683
|
-
), superseded AS (
|
|
684
|
-
-- Successive prunes each tombstoned whatever was current at the
|
|
685
|
-
-- time, so a subject accumulated several current-leaf tombstones:
|
|
686
|
-
-- prune R1 while current, record R2 over it, prune R2. Reading
|
|
687
|
-
-- back, whichever the planner returned first answered for the
|
|
688
|
-
-- chain, and picking R1 rejected R3's legitimate succession to
|
|
689
|
-
-- R2. A subject has exactly one leaf, so demote the older
|
|
690
|
-
-- tombstones the moment a newer leaf is recorded.
|
|
691
|
-
UPDATE adcp_reporting_receipt_tombstones prior
|
|
692
|
-
SET was_current = false
|
|
693
|
-
FROM doomed
|
|
694
|
-
WHERE doomed.is_current
|
|
695
|
-
AND prior.account_id = doomed.account_id
|
|
696
|
-
AND prior.consumer_id = doomed.consumer_id
|
|
697
|
-
AND prior.receipt_kind = doomed.receipt_kind
|
|
698
|
-
AND prior.subject_id = doomed.subject_id
|
|
699
|
-
AND prior.reporting_receipt_id <> doomed.reporting_receipt_id
|
|
700
|
-
AND prior.was_current
|
|
701
|
-
), tombstoned AS (
|
|
702
|
-
INSERT INTO adcp_reporting_receipt_tombstones
|
|
703
|
-
(account_id, consumer_id, reporting_receipt_id, receipt_kind, subject_id,
|
|
704
|
-
status, was_current, semantic_fingerprint, subject_recorded_at, supersedes_receipt_id)
|
|
705
|
-
SELECT account_id, consumer_id, reporting_receipt_id, receipt_kind, subject_id,
|
|
706
|
-
COALESCE(data ->> 'status', 'unknown'), is_current, semantic_fingerprint,
|
|
707
|
-
recorded_at, supersedes_receipt_id
|
|
708
|
-
FROM doomed
|
|
709
|
-
ON CONFLICT (account_id, consumer_id, reporting_receipt_id) DO NOTHING
|
|
710
|
-
)
|
|
711
|
-
DELETE FROM adcp_reporting_receipts target USING doomed
|
|
712
|
-
WHERE target.account_id = doomed.account_id
|
|
713
|
-
AND target.consumer_id = doomed.consumer_id
|
|
714
|
-
AND target.reporting_receipt_id = doomed.reporting_receipt_id`,
|
|
715
|
-
[input.account_id, doomedConsumerIds, doomedReceiptIds, RECEIPT_BATCH_RETENTION_MS, cutoff]
|
|
716
|
-
);
|
|
717
|
-
const materializations = await client.query(
|
|
718
|
-
`WITH referenced AS (
|
|
719
|
-
-- Doomed receipts are already gone above, so a hit here is a
|
|
720
|
-
-- survivor: either one this pass never selected, or one a
|
|
721
|
-
-- concurrent sync recorded after selection read the table.
|
|
722
|
-
SELECT DISTINCT receipt.data ->> 'reporting_materialization_id' AS materialization_id
|
|
723
|
-
FROM adcp_reporting_receipts receipt
|
|
724
|
-
WHERE receipt.account_id = $2
|
|
725
|
-
AND receipt.data ->> 'reporting_materialization_id' = ANY($1::text[])
|
|
726
|
-
), doomed AS (
|
|
727
|
-
SELECT materialization.* FROM adcp_reporting_materializations materialization
|
|
728
|
-
WHERE materialization.materialization_id = ANY($1::text[])
|
|
729
|
-
AND materialization.account_id = $2
|
|
730
|
-
-- Every selection predicate re-asserted under the lock. A
|
|
731
|
-
-- claim, a settle or a re-record between the select and here
|
|
732
|
-
-- makes the row live again.
|
|
733
|
-
AND materialization.recorded_at < $3::timestamptz
|
|
734
|
-
AND materialization.status <> 'pending'
|
|
735
|
-
AND materialization.lease_owner IS NULL
|
|
736
|
-
AND COALESCE(
|
|
737
|
-
(materialization.data -> 'resource' ->> 'expires_at')::timestamptz <= clock_timestamp(),
|
|
738
|
-
true)
|
|
739
|
-
AND NOT EXISTS (
|
|
740
|
-
SELECT 1 FROM referenced
|
|
741
|
-
WHERE referenced.materialization_id = materialization.materialization_id
|
|
742
|
-
)
|
|
743
|
-
), tombstoned AS (
|
|
744
|
-
INSERT INTO adcp_reporting_materialization_tombstones
|
|
745
|
-
(configuration_id, revision_id, account_id, obligation_id, highest_attempt, reached_success,
|
|
746
|
-
reached_success_at)
|
|
747
|
-
SELECT configuration_id, revision_id, account_id, obligation_id,
|
|
748
|
-
MAX(attempt), BOOL_OR(status IN ('available', 'delivered')),
|
|
749
|
-
MIN(changed_at) FILTER (WHERE status IN ('available', 'delivered'))
|
|
750
|
-
FROM doomed GROUP BY configuration_id, revision_id, account_id, obligation_id
|
|
751
|
-
ON CONFLICT (configuration_id, revision_id) DO UPDATE SET
|
|
752
|
-
highest_attempt = GREATEST(
|
|
753
|
-
adcp_reporting_materialization_tombstones.highest_attempt, EXCLUDED.highest_attempt),
|
|
754
|
-
reached_success =
|
|
755
|
-
adcp_reporting_materialization_tombstones.reached_success OR EXCLUDED.reached_success,
|
|
756
|
-
-- The earliest success is what a historical cutoff must compare
|
|
757
|
-
-- against: a later one does not make the delivery newer.
|
|
758
|
-
reached_success_at = LEAST(
|
|
759
|
-
adcp_reporting_materialization_tombstones.reached_success_at, EXCLUDED.reached_success_at)
|
|
760
|
-
)
|
|
761
|
-
DELETE FROM adcp_reporting_materializations target USING doomed
|
|
762
|
-
WHERE target.materialization_id = doomed.materialization_id`,
|
|
763
|
-
[doomedMaterializations.rows.map((row) => row.materialization_id), input.account_id, cutoff]
|
|
764
|
-
);
|
|
765
|
-
return {
|
|
766
|
-
materializations: materializations.rowCount ?? 0,
|
|
767
|
-
receipts: receipts.rowCount ?? 0,
|
|
768
|
-
batches: batches.rowCount ?? 0
|
|
769
|
-
};
|
|
770
|
-
});
|
|
771
|
-
}
|
|
772
|
-
async probe(coreStore) {
|
|
773
|
-
const authority = coreStore[REPORTING_LEDGER_AUTHORITY];
|
|
774
|
-
if (!authority) {
|
|
775
|
-
throw new Error("Managed reporting requires a Core store that exposes REPORTING_LEDGER_AUTHORITY");
|
|
776
|
-
}
|
|
777
|
-
if (authority.substrate !== this.pool) {
|
|
778
|
-
throw new Error("Managed reporting Core and add-on stores must share the same PostgreSQL pool");
|
|
779
|
-
}
|
|
780
|
-
if (authority.managedDelivery !== true) {
|
|
781
|
-
throw new Error("Managed reporting requires PostgresReportingLedgerStore({ managedDelivery: true })");
|
|
782
|
-
}
|
|
783
|
-
await this.ensurePolicyRegistered();
|
|
784
|
-
const result = await this.query(
|
|
785
|
-
`SELECT to_regclass('adcp_reporting_managed_bindings') IS NOT NULL
|
|
786
|
-
AND to_regclass('adcp_reporting_materializations') IS NOT NULL
|
|
787
|
-
AND to_regclass('adcp_reporting_receipts') IS NOT NULL
|
|
788
|
-
AND EXISTS (
|
|
789
|
-
SELECT 1 FROM information_schema.columns
|
|
790
|
-
WHERE table_schema = current_schema()
|
|
791
|
-
AND table_name = 'adcp_reporting_managed_policy'
|
|
792
|
-
AND column_name = 'materialization_planning_cursor'
|
|
793
|
-
) AS ready`
|
|
794
|
-
);
|
|
795
|
-
if (result.rows[0]?.ready !== true) {
|
|
796
|
-
throw new Error(
|
|
797
|
-
"Managed reporting schema is unavailable; apply REPORTING_MANAGED_DELIVERY_MIGRATION after REPORTING_LEDGER_MIGRATION"
|
|
798
|
-
);
|
|
799
|
-
}
|
|
800
|
-
return true;
|
|
801
|
-
}
|
|
802
|
-
async listInstalledRecoveryWindowSeconds() {
|
|
803
|
-
const result = await this.query(
|
|
804
|
-
`SELECT DISTINCT configuration.data->'schedule'->>'recoveryWindowMilliseconds' AS milliseconds
|
|
805
|
-
FROM adcp_reporting_managed_bindings binding
|
|
806
|
-
JOIN adcp_reporting_configurations configuration
|
|
807
|
-
ON configuration.configuration_id = binding.configuration_id
|
|
808
|
-
ORDER BY milliseconds`
|
|
809
|
-
);
|
|
810
|
-
return result.rows.map((row) => {
|
|
811
|
-
const milliseconds = Number(row.milliseconds);
|
|
812
|
-
if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) {
|
|
813
|
-
throw new Error("Installed managed Core recovery windows must be non-negative");
|
|
814
|
-
}
|
|
815
|
-
return Math.ceil(milliseconds / 1e3);
|
|
816
|
-
});
|
|
817
|
-
}
|
|
818
|
-
async authorizeDestination(input) {
|
|
819
|
-
positiveInteger(input.generation, "generation");
|
|
820
|
-
const pattern = input.pattern ?? null;
|
|
821
|
-
const transport = input.transport ?? null;
|
|
822
|
-
if (pattern !== null && !REPORTING_DESTINATION_PATTERNS_V1.includes(pattern)) {
|
|
823
|
-
throw new TypeError(
|
|
824
|
-
"Destination authorization pattern must be file_transfer, dataset_share, or warehouse_materialization"
|
|
825
|
-
);
|
|
826
|
-
}
|
|
827
|
-
if (transport !== null) {
|
|
828
|
-
if (pattern === null) throw new TypeError("Destination authorization transport requires its pattern");
|
|
829
|
-
if (typeof transport !== "string" || !REPORTING_DESTINATION_TRANSPORT_PATTERN_V1.test(transport)) {
|
|
830
|
-
throw new TypeError("Destination authorization transport must match ^[a-z][a-z0-9_.-]{0,63}$");
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
await this.transaction(async (client) => {
|
|
834
|
-
await advisoryLock(client, accountLock(input.account_id));
|
|
835
|
-
await advisoryLock(client, authLock(input.account_id, input.destination_ref));
|
|
836
|
-
const current = await client.query(
|
|
837
|
-
`SELECT generation::text, pattern, transport FROM adcp_reporting_destination_authorizations
|
|
838
|
-
WHERE account_id = $1 AND destination_ref = $2 AND revoked_at IS NULL`,
|
|
839
|
-
[input.account_id, input.destination_ref]
|
|
840
|
-
);
|
|
841
|
-
if (current.rowCount) {
|
|
842
|
-
if (Number(current.rows[0].generation) === input.generation) {
|
|
843
|
-
const recorded2 = current.rows[0];
|
|
844
|
-
if (pattern !== null && pattern !== recorded2.pattern || transport !== null && transport !== recorded2.transport) {
|
|
845
|
-
throw new Error("Destination authorization pattern and transport are immutable for a generation");
|
|
846
|
-
}
|
|
847
|
-
return;
|
|
848
|
-
}
|
|
849
|
-
throw new Error("A destination authorization generation is already current");
|
|
850
|
-
}
|
|
851
|
-
const latest = await client.query(
|
|
852
|
-
`SELECT MAX(generation)::text AS generation FROM adcp_reporting_destination_authorizations
|
|
853
|
-
WHERE account_id = $1 AND destination_ref = $2`,
|
|
854
|
-
[input.account_id, input.destination_ref]
|
|
855
|
-
);
|
|
856
|
-
if (input.generation <= Number(latest.rows[0]?.generation ?? 0)) {
|
|
857
|
-
throw new Error("Destination authorization generation must increase after revocation");
|
|
858
|
-
}
|
|
859
|
-
await client.query(
|
|
860
|
-
// Both ends of the authorization window come from the committing
|
|
861
|
-
// database. Mixing a caller's `authorized_at` with a DB-clock
|
|
862
|
-
// `revoked_at` also violates the table's own ordering check whenever
|
|
863
|
-
// the caller's clock runs ahead.
|
|
864
|
-
`INSERT INTO adcp_reporting_destination_authorizations
|
|
865
|
-
(account_id, destination_ref, generation, authorized_at, pattern, transport, data)
|
|
866
|
-
VALUES ($1, $2, $3, clock_timestamp(), $6::text, $7::text,
|
|
867
|
-
$4::jsonb || jsonb_build_object(
|
|
868
|
-
'authorized_at', ${rfc3339Micro("clock_timestamp()")},
|
|
869
|
-
'requested_authorized_at', $5::text))`,
|
|
870
|
-
[
|
|
871
|
-
input.account_id,
|
|
872
|
-
input.destination_ref,
|
|
873
|
-
input.generation,
|
|
874
|
-
JSON.stringify(input),
|
|
875
|
-
input.authorized_at,
|
|
876
|
-
pattern,
|
|
877
|
-
transport
|
|
878
|
-
]
|
|
879
|
-
);
|
|
880
|
-
});
|
|
881
|
-
}
|
|
882
|
-
async revokeDestination(input) {
|
|
883
|
-
return this.transaction(async (client) => {
|
|
884
|
-
await advisoryLock(client, accountLock(input.account_id));
|
|
885
|
-
await advisoryLock(client, authLock(input.account_id, input.destination_ref));
|
|
886
|
-
const updated = await client.query(
|
|
887
|
-
// The SLA is measured from this instant and evaluated against
|
|
888
|
-
// clock_timestamp() everywhere else, so recording a caller's host
|
|
889
|
-
// clock let a fast host shorten the promised window and a slow one
|
|
890
|
-
// extend it. The caller's value is kept only as stated intent.
|
|
891
|
-
`UPDATE adcp_reporting_destination_authorizations
|
|
892
|
-
SET revoked_at = clock_timestamp(),
|
|
893
|
-
changed_at = clock_timestamp(),
|
|
894
|
-
data = data || jsonb_build_object(
|
|
895
|
-
'revoked_at', ${rfc3339Micro("clock_timestamp()")},
|
|
896
|
-
'requested_revoked_at', $4::text
|
|
897
|
-
)
|
|
898
|
-
WHERE account_id = $1 AND destination_ref = $2 AND generation = $3
|
|
899
|
-
AND revoked_at IS NULL`,
|
|
900
|
-
[input.account_id, input.destination_ref, input.generation, input.revoked_at]
|
|
901
|
-
);
|
|
902
|
-
await client.query(
|
|
903
|
-
`UPDATE adcp_reporting_materializations SET
|
|
904
|
-
status = 'failed',
|
|
905
|
-
data = data || jsonb_build_object(
|
|
906
|
-
'status', 'failed',
|
|
907
|
-
'failed_at', ${rfc3339Micro("clock_timestamp()")},
|
|
908
|
-
'failure_code', 'AUTHORIZATION_REVOKED'
|
|
909
|
-
),
|
|
910
|
-
changed_at = clock_timestamp(), lease_owner = NULL, lease_expires_at = NULL
|
|
911
|
-
WHERE account_id = $1 AND destination_ref = $2 AND authorization_generation = $3
|
|
912
|
-
AND status = 'pending'`,
|
|
913
|
-
[input.account_id, input.destination_ref, input.generation]
|
|
914
|
-
);
|
|
915
|
-
return updated.rowCount === 1;
|
|
916
|
-
});
|
|
917
|
-
}
|
|
918
|
-
setBindingAdmission(admit) {
|
|
919
|
-
this.bindingAdmission = admit;
|
|
920
|
-
}
|
|
921
|
-
async installBinding(binding) {
|
|
922
|
-
positiveInteger(binding.resource_retention_days, "resource_retention_days");
|
|
923
|
-
if (binding.reconciliation_mode === "consumer_receipt" && binding.verification_profile !== "canonical_digest") {
|
|
924
|
-
throw new Error("Reconciled Billing bindings require canonical-digest verification");
|
|
925
|
-
}
|
|
926
|
-
const expectedFingerprint = managedBindingFingerprint(binding);
|
|
927
|
-
if (binding.semantic_fingerprint !== expectedFingerprint) {
|
|
928
|
-
throw new Error("Managed binding semantic fingerprint does not match its immutable content");
|
|
929
|
-
}
|
|
930
|
-
this.bindingAdmission?.(binding);
|
|
931
|
-
await this.ensurePolicyRegistered();
|
|
932
|
-
return this.transaction(async (client) => {
|
|
933
|
-
const registeredPolicy = await this.readPolicy(client, "update");
|
|
934
|
-
await advisoryLock(client, accountLock(binding.account_id));
|
|
935
|
-
await advisoryLock(client, `adcp-reporting-binding:${binding.account_id}:${binding.delivery_config_id}`);
|
|
936
|
-
const existing = await client.query(
|
|
937
|
-
"SELECT semantic_fingerprint FROM adcp_reporting_managed_bindings WHERE configuration_id = $1",
|
|
938
|
-
[binding.configurationId]
|
|
939
|
-
);
|
|
940
|
-
if (existing.rowCount) {
|
|
941
|
-
if (existing.rows[0]?.semantic_fingerprint !== expectedFingerprint) {
|
|
942
|
-
throw new Error("Immutable managed binding identity names different content");
|
|
943
|
-
}
|
|
944
|
-
return { inserted: false };
|
|
945
|
-
}
|
|
946
|
-
const eligible = await client.query(
|
|
947
|
-
`SELECT configuration.data AS configuration,
|
|
948
|
-
authz.pattern AS authz_pattern, authz.transport AS authz_transport
|
|
949
|
-
FROM adcp_reporting_configurations configuration
|
|
950
|
-
JOIN adcp_reporting_destination_authorizations authz
|
|
951
|
-
ON authz.account_id = $2 AND authz.destination_ref = $5
|
|
952
|
-
AND authz.generation = $6 AND authz.revoked_at IS NULL
|
|
953
|
-
WHERE configuration.configuration_id = $1 AND configuration.account_id = $2
|
|
954
|
-
AND configuration.delivery_config_id = $3 AND configuration.delivery_config_version = $4
|
|
955
|
-
AND NOT EXISTS (
|
|
956
|
-
SELECT 1 FROM adcp_reporting_obligations obligation
|
|
957
|
-
WHERE obligation.configuration_id = configuration.configuration_id
|
|
958
|
-
)`,
|
|
959
|
-
[
|
|
960
|
-
binding.configurationId,
|
|
961
|
-
binding.account_id,
|
|
962
|
-
binding.delivery_config_id,
|
|
963
|
-
binding.delivery_config_version,
|
|
964
|
-
binding.destination_ref,
|
|
965
|
-
binding.authorization_generation
|
|
966
|
-
]
|
|
967
|
-
);
|
|
968
|
-
if (!eligible.rowCount) throw new Error("Managed binding is not authorized for the exact Core configuration");
|
|
969
|
-
const configuration = eligible.rows[0].configuration;
|
|
970
|
-
const recordedContract = eligible.rows[0];
|
|
971
|
-
if (recordedContract.authz_pattern !== null && binding.method !== recordedContract.authz_pattern) {
|
|
972
|
-
throw new Error("Managed binding method differs from the pattern recorded on its destination authorization");
|
|
973
|
-
}
|
|
974
|
-
if (recordedContract.authz_transport !== null && binding.transport !== recordedContract.authz_transport) {
|
|
975
|
-
throw new Error(
|
|
976
|
-
"Managed binding transport differs from the transport recorded on its destination authorization"
|
|
977
|
-
);
|
|
978
|
-
}
|
|
979
|
-
const advertisedRecoveryWindowSeconds = registeredPolicy.recoveryWindowSeconds;
|
|
980
|
-
if (advertisedRecoveryWindowSeconds !== null) {
|
|
981
|
-
const windowMilliseconds = Number(configuration.schedule?.recoveryWindowMilliseconds);
|
|
982
|
-
if (!Number.isFinite(windowMilliseconds) || windowMilliseconds < 0) {
|
|
983
|
-
throw new Error("Managed binding Core configuration has no usable recovery window");
|
|
984
|
-
}
|
|
985
|
-
const windowSeconds = Math.ceil(windowMilliseconds / 1e3);
|
|
986
|
-
if (windowSeconds > advertisedRecoveryWindowSeconds) {
|
|
987
|
-
throw new Error(
|
|
988
|
-
`Managed binding Core recovery window is ${windowSeconds}s but this agent advertises automated_recovery_window_seconds ${advertisedRecoveryWindowSeconds}s; installing it would publish a recovery bound the deployment does not keep`
|
|
989
|
-
);
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
if (binding.feed_purpose === "billing" && binding.reconciliation_mode !== "consumer_receipt") {
|
|
993
|
-
throw new Error("Billing managed bindings require consumer-receipt reconciliation");
|
|
994
|
-
}
|
|
995
|
-
if (binding.feed_purpose === "billing" && configuration.requiredFinality !== "official") {
|
|
996
|
-
throw new Error("Billing managed bindings require a Core configuration with official finality");
|
|
997
|
-
}
|
|
998
|
-
if (configuration.feedPurpose !== binding.feed_purpose) {
|
|
999
|
-
throw new Error("Managed binding feed purpose differs from the exact Core configuration");
|
|
1000
|
-
}
|
|
1001
|
-
if (binding.reconciliation_mode === "consumer_receipt" && !configuration.canonicalization) {
|
|
1002
|
-
throw new Error("Reconciled Billing requires a Core configuration with pinned canonicalization");
|
|
1003
|
-
}
|
|
1004
|
-
const inserted = await client.query(
|
|
1005
|
-
`INSERT INTO adcp_reporting_managed_bindings
|
|
1006
|
-
(configuration_id, account_id, delivery_config_id, delivery_config_version,
|
|
1007
|
-
destination_ref, authorization_generation, semantic_fingerprint, data, created_at)
|
|
1008
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)
|
|
1009
|
-
ON CONFLICT DO NOTHING RETURNING semantic_fingerprint`,
|
|
1010
|
-
[
|
|
1011
|
-
binding.configurationId,
|
|
1012
|
-
binding.account_id,
|
|
1013
|
-
binding.delivery_config_id,
|
|
1014
|
-
binding.delivery_config_version,
|
|
1015
|
-
binding.destination_ref,
|
|
1016
|
-
binding.authorization_generation,
|
|
1017
|
-
binding.semantic_fingerprint,
|
|
1018
|
-
JSON.stringify(binding),
|
|
1019
|
-
binding.created_at
|
|
1020
|
-
]
|
|
1021
|
-
);
|
|
1022
|
-
if (!inserted.rowCount) {
|
|
1023
|
-
const raced = await client.query(
|
|
1024
|
-
"SELECT semantic_fingerprint FROM adcp_reporting_managed_bindings WHERE configuration_id = $1",
|
|
1025
|
-
[binding.configurationId]
|
|
1026
|
-
);
|
|
1027
|
-
if (raced.rows[0]?.semantic_fingerprint !== binding.semantic_fingerprint) {
|
|
1028
|
-
throw new Error("Immutable managed binding identity names different content");
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
return { inserted: inserted.rowCount === 1 };
|
|
1032
|
-
});
|
|
1033
|
-
}
|
|
1034
|
-
async planMaterializations(input = {}) {
|
|
1035
|
-
const limit = input.limit ?? MAX_PLAN;
|
|
1036
|
-
positiveInteger(limit, "limit");
|
|
1037
|
-
if (limit > MAX_PLAN) throw new RangeError(`limit must not exceed ${MAX_PLAN}`);
|
|
1038
|
-
if (!input.account_id) {
|
|
1039
|
-
const accounts = await this.claimMaterializationPlanningAccounts(limit);
|
|
1040
|
-
let planned = 0;
|
|
1041
|
-
let firstPass = true;
|
|
1042
|
-
while (planned < limit) {
|
|
1043
|
-
let progressed = 0;
|
|
1044
|
-
for (let index = 0; index < accounts.length && planned < limit; index += 1) {
|
|
1045
|
-
const accountsRemaining = accounts.length - index;
|
|
1046
|
-
const accountLimit = firstPass ? Math.max(1, Math.floor((limit - planned) / accountsRemaining)) : limit - planned;
|
|
1047
|
-
const accountPlanned = await this.planMaterializations({
|
|
1048
|
-
account_id: accounts[index],
|
|
1049
|
-
limit: accountLimit
|
|
1050
|
-
});
|
|
1051
|
-
planned += accountPlanned;
|
|
1052
|
-
progressed += accountPlanned;
|
|
1053
|
-
}
|
|
1054
|
-
if (progressed === 0) break;
|
|
1055
|
-
firstPass = false;
|
|
1056
|
-
}
|
|
1057
|
-
return planned;
|
|
1058
|
-
}
|
|
1059
|
-
return this.transaction(async (client) => {
|
|
1060
|
-
const accounts = await client.query(
|
|
1061
|
-
`SELECT DISTINCT account_id FROM adcp_reporting_managed_bindings
|
|
1062
|
-
WHERE ($1::text IS NULL OR account_id = $1) ORDER BY account_id`,
|
|
1063
|
-
[input.account_id ?? null]
|
|
1064
|
-
);
|
|
1065
|
-
for (const { account_id } of accounts.rows) await advisoryLock(client, accountLock(account_id));
|
|
1066
|
-
const capacity = await client.query(
|
|
1067
|
-
`SELECT account_id, COUNT(*)::text AS count FROM adcp_reporting_materializations
|
|
1068
|
-
WHERE ($1::text IS NULL OR account_id = $1)${this.activeScope("recorded_at")} GROUP BY account_id`,
|
|
1069
|
-
[input.account_id ?? null]
|
|
1070
|
-
);
|
|
1071
|
-
const remainingByAccount = new Map(
|
|
1072
|
-
accounts.rows.map(({ account_id }) => [
|
|
1073
|
-
account_id,
|
|
1074
|
-
MAX_MATERIALIZATIONS_PER_ACCOUNT - Number(capacity.rows.find((value) => value.account_id === account_id)?.count ?? 0)
|
|
1075
|
-
])
|
|
1076
|
-
);
|
|
1077
|
-
const candidates = await client.query(
|
|
1078
|
-
`SELECT binding.data AS binding, obligation.data AS obligation, revision.revision_id AS revision_id,
|
|
1079
|
-
GREATEST(COALESCE(MAX(existing.attempt), 0), COALESCE(MAX(tomb.highest_attempt), 0))::integer + 1
|
|
1080
|
-
AS attempt
|
|
1081
|
-
FROM adcp_reporting_managed_bindings binding
|
|
1082
|
-
JOIN adcp_reporting_destination_authorizations authz
|
|
1083
|
-
ON authz.account_id = binding.account_id
|
|
1084
|
-
AND authz.destination_ref = binding.destination_ref
|
|
1085
|
-
AND authz.generation = binding.authorization_generation
|
|
1086
|
-
AND authz.revoked_at IS NULL
|
|
1087
|
-
JOIN adcp_reporting_obligations obligation ON obligation.configuration_id = binding.configuration_id
|
|
1088
|
-
JOIN adcp_reporting_revisions revision ON revision.obligation_id = obligation.obligation_id
|
|
1089
|
-
LEFT JOIN adcp_reporting_materializations existing
|
|
1090
|
-
ON existing.configuration_id = binding.configuration_id AND existing.revision_id = revision.revision_id
|
|
1091
|
-
-- Pruned attempts still count. Without this a revision that exhausted
|
|
1092
|
-
-- its attempts, or already succeeded, restarts at attempt 1 as soon as
|
|
1093
|
-
-- its rows age out of retention.
|
|
1094
|
-
LEFT JOIN adcp_reporting_materialization_tombstones tomb
|
|
1095
|
-
ON tomb.configuration_id = binding.configuration_id AND tomb.revision_id = revision.revision_id
|
|
1096
|
-
WHERE ($1::text IS NULL OR binding.account_id = $1)
|
|
1097
|
-
GROUP BY binding.configuration_id, binding.data, obligation.obligation_id, obligation.data,
|
|
1098
|
-
revision.revision_id
|
|
1099
|
-
HAVING NOT COALESCE(BOOL_OR(existing.status IN ('pending','available','delivered')), false)
|
|
1100
|
-
AND NOT COALESCE(BOOL_OR(tomb.reached_success), false)
|
|
1101
|
-
AND GREATEST(COALESCE(MAX(existing.attempt), 0), COALESCE(MAX(tomb.highest_attempt), 0))
|
|
1102
|
-
< ${MAX_MATERIALIZATION_ATTEMPTS}
|
|
1103
|
-
ORDER BY MIN(revision.recorded_at), revision.revision_id
|
|
1104
|
-
LIMIT $2`,
|
|
1105
|
-
[input.account_id ?? null, limit]
|
|
1106
|
-
);
|
|
1107
|
-
let planned = 0;
|
|
1108
|
-
for (const candidate of candidates.rows) {
|
|
1109
|
-
const binding = candidate.binding;
|
|
1110
|
-
const remaining = remainingByAccount.get(binding.account_id) ?? 0;
|
|
1111
|
-
if (remaining <= 0) continue;
|
|
1112
|
-
const obligation = candidate.obligation;
|
|
1113
|
-
const revisionId = candidate.revision_id;
|
|
1114
|
-
const created_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
1115
|
-
const materialization = {
|
|
1116
|
-
reporting_materialization_id: `rmat_${randomUUID()}`,
|
|
1117
|
-
reporting_revision_id: revisionId,
|
|
1118
|
-
reporting_obligation_id: obligation.reporting_obligation_id,
|
|
1119
|
-
delivery_config_id: binding.delivery_config_id,
|
|
1120
|
-
delivery_config_version: binding.delivery_config_version,
|
|
1121
|
-
destination_ref: binding.destination_ref,
|
|
1122
|
-
feed_purpose: binding.feed_purpose,
|
|
1123
|
-
method: binding.method,
|
|
1124
|
-
...binding.transport ? { transport: binding.transport } : {},
|
|
1125
|
-
attempt: candidate.attempt,
|
|
1126
|
-
status: "pending",
|
|
1127
|
-
created_at
|
|
1128
|
-
};
|
|
1129
|
-
const inserted = await client.query(
|
|
1130
|
-
`INSERT INTO adcp_reporting_materializations
|
|
1131
|
-
(materialization_id, account_id, configuration_id, obligation_id, revision_id,
|
|
1132
|
-
destination_ref, authorization_generation, attempt, status, data, created_at)
|
|
1133
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending',$9::jsonb,$10)
|
|
1134
|
-
ON CONFLICT DO NOTHING`,
|
|
1135
|
-
[
|
|
1136
|
-
materialization.reporting_materialization_id,
|
|
1137
|
-
binding.account_id,
|
|
1138
|
-
binding.configurationId,
|
|
1139
|
-
obligation.reporting_obligation_id,
|
|
1140
|
-
revisionId,
|
|
1141
|
-
binding.destination_ref,
|
|
1142
|
-
binding.authorization_generation,
|
|
1143
|
-
candidate.attempt,
|
|
1144
|
-
JSON.stringify(materialization),
|
|
1145
|
-
created_at
|
|
1146
|
-
]
|
|
1147
|
-
);
|
|
1148
|
-
planned += inserted.rowCount ?? 0;
|
|
1149
|
-
if (inserted.rowCount) remainingByAccount.set(binding.account_id, remaining - 1);
|
|
1150
|
-
}
|
|
1151
|
-
return planned;
|
|
1152
|
-
});
|
|
1153
|
-
}
|
|
1154
|
-
/**
|
|
1155
|
-
* Claims a bounded, durable round-robin page of accounts that currently
|
|
1156
|
-
* have at least one materialization candidate.
|
|
1157
|
-
*
|
|
1158
|
-
* The policy row serializes only the final cursor compare-and-set. Eligible
|
|
1159
|
-
* account discovery runs without that global lock so retained revision
|
|
1160
|
-
* history cannot block materialization settlement or revocation. The cursor
|
|
1161
|
-
* advances before provider work; a crash may defer a selected account until
|
|
1162
|
-
* the ring wraps, but can never pin every future sweep behind it.
|
|
1163
|
-
*/
|
|
1164
|
-
async claimMaterializationPlanningAccounts(limit) {
|
|
1165
|
-
for (let contentionAttempt = 0; contentionAttempt < 4; contentionAttempt += 1) {
|
|
1166
|
-
const policy = await this.query(
|
|
1167
|
-
`SELECT materialization_planning_cursor
|
|
1168
|
-
FROM adcp_reporting_managed_policy
|
|
1169
|
-
WHERE policy_key = 'agent'`
|
|
1170
|
-
);
|
|
1171
|
-
if (!policy.rows[0]) {
|
|
1172
|
-
throw new Error(
|
|
1173
|
-
"Managed reporting policy sentinel is missing; apply REPORTING_MANAGED_DELIVERY_MIGRATION before use"
|
|
1174
|
-
);
|
|
1175
|
-
}
|
|
1176
|
-
const eligiblePageSql = `SELECT DISTINCT binding.account_id
|
|
1177
|
-
FROM adcp_reporting_managed_bindings binding
|
|
1178
|
-
JOIN adcp_reporting_destination_authorizations authz
|
|
1179
|
-
ON authz.account_id = binding.account_id
|
|
1180
|
-
AND authz.destination_ref = binding.destination_ref
|
|
1181
|
-
AND authz.generation = binding.authorization_generation
|
|
1182
|
-
AND authz.revoked_at IS NULL
|
|
1183
|
-
JOIN adcp_reporting_obligations obligation
|
|
1184
|
-
ON obligation.configuration_id = binding.configuration_id
|
|
1185
|
-
JOIN adcp_reporting_revisions revision
|
|
1186
|
-
ON revision.obligation_id = obligation.obligation_id
|
|
1187
|
-
WHERE ($1::text IS NULL OR CASE WHEN $3::boolean
|
|
1188
|
-
THEN binding.account_id > $1
|
|
1189
|
-
ELSE binding.account_id <= $1
|
|
1190
|
-
END)
|
|
1191
|
-
AND NOT EXISTS (
|
|
1192
|
-
SELECT 1 FROM adcp_reporting_materializations existing
|
|
1193
|
-
WHERE existing.configuration_id = binding.configuration_id
|
|
1194
|
-
AND existing.revision_id = revision.revision_id
|
|
1195
|
-
AND existing.status IN ('pending', 'available', 'delivered')
|
|
1196
|
-
)
|
|
1197
|
-
AND NOT EXISTS (
|
|
1198
|
-
SELECT 1 FROM adcp_reporting_materialization_tombstones tomb
|
|
1199
|
-
WHERE tomb.configuration_id = binding.configuration_id
|
|
1200
|
-
AND tomb.revision_id = revision.revision_id
|
|
1201
|
-
AND tomb.reached_success
|
|
1202
|
-
)
|
|
1203
|
-
AND GREATEST(
|
|
1204
|
-
COALESCE((
|
|
1205
|
-
SELECT MAX(existing.attempt)
|
|
1206
|
-
FROM adcp_reporting_materializations existing
|
|
1207
|
-
WHERE existing.configuration_id = binding.configuration_id
|
|
1208
|
-
AND existing.revision_id = revision.revision_id
|
|
1209
|
-
), 0),
|
|
1210
|
-
COALESCE((
|
|
1211
|
-
SELECT tomb.highest_attempt
|
|
1212
|
-
FROM adcp_reporting_materialization_tombstones tomb
|
|
1213
|
-
WHERE tomb.configuration_id = binding.configuration_id
|
|
1214
|
-
AND tomb.revision_id = revision.revision_id
|
|
1215
|
-
), 0)
|
|
1216
|
-
) < ${MAX_MATERIALIZATION_ATTEMPTS}
|
|
1217
|
-
AND (
|
|
1218
|
-
SELECT COUNT(*) FROM adcp_reporting_materializations capacity
|
|
1219
|
-
WHERE capacity.account_id = binding.account_id${this.activeScope("capacity.recorded_at")}
|
|
1220
|
-
) < ${MAX_MATERIALIZATIONS_PER_ACCOUNT}
|
|
1221
|
-
ORDER BY binding.account_id
|
|
1222
|
-
LIMIT $2`;
|
|
1223
|
-
const cursor = policy.rows[0].materialization_planning_cursor;
|
|
1224
|
-
const after = await this.query(eligiblePageSql, [cursor, limit, true]);
|
|
1225
|
-
const selected = after.rows.map((row) => row.account_id);
|
|
1226
|
-
if (cursor !== null && selected.length < limit) {
|
|
1227
|
-
const wrapped = await this.query(eligiblePageSql, [
|
|
1228
|
-
cursor,
|
|
1229
|
-
limit - selected.length,
|
|
1230
|
-
false
|
|
1231
|
-
]);
|
|
1232
|
-
selected.push(...wrapped.rows.map((row) => row.account_id));
|
|
1233
|
-
}
|
|
1234
|
-
if (selected.length === 0) return [];
|
|
1235
|
-
const advanced = await this.transaction(async (client) => {
|
|
1236
|
-
const current = await client.query(
|
|
1237
|
-
`SELECT materialization_planning_cursor
|
|
1238
|
-
FROM adcp_reporting_managed_policy
|
|
1239
|
-
WHERE policy_key = 'agent'
|
|
1240
|
-
FOR UPDATE`
|
|
1241
|
-
);
|
|
1242
|
-
if (!current.rows[0]) {
|
|
1243
|
-
throw new Error(
|
|
1244
|
-
"Managed reporting policy sentinel is missing; apply REPORTING_MANAGED_DELIVERY_MIGRATION before use"
|
|
1245
|
-
);
|
|
1246
|
-
}
|
|
1247
|
-
if (current.rows[0].materialization_planning_cursor !== cursor) return false;
|
|
1248
|
-
await client.query(
|
|
1249
|
-
`UPDATE adcp_reporting_managed_policy
|
|
1250
|
-
SET materialization_planning_cursor = $1, changed_at = clock_timestamp()
|
|
1251
|
-
WHERE policy_key = 'agent'`,
|
|
1252
|
-
[selected[selected.length - 1]]
|
|
1253
|
-
);
|
|
1254
|
-
return true;
|
|
1255
|
-
});
|
|
1256
|
-
if (advanced) return selected;
|
|
1257
|
-
}
|
|
1258
|
-
return [];
|
|
1259
|
-
}
|
|
1260
|
-
async claimMaterialization(input) {
|
|
1261
|
-
positiveInteger(input.lease_milliseconds, "lease_milliseconds");
|
|
1262
|
-
const lease = await this.transaction(async (client) => {
|
|
1263
|
-
const selected = await client.query(
|
|
1264
|
-
`WITH candidate AS (
|
|
1265
|
-
SELECT materialization.materialization_id
|
|
1266
|
-
FROM adcp_reporting_materializations materialization
|
|
1267
|
-
JOIN adcp_reporting_destination_authorizations authz
|
|
1268
|
-
ON authz.account_id = materialization.account_id
|
|
1269
|
-
AND authz.destination_ref = materialization.destination_ref
|
|
1270
|
-
AND authz.generation = materialization.authorization_generation
|
|
1271
|
-
AND authz.revoked_at IS NULL
|
|
1272
|
-
WHERE materialization.status = 'pending'
|
|
1273
|
-
AND ($1::text IS NULL OR materialization.account_id = $1)
|
|
1274
|
-
-- Every claim of a pending row is a delivery attempt, settled
|
|
1275
|
-
-- or not. The attempt column only advances in the planner, so a
|
|
1276
|
-
-- row whose settle kept losing its lease was reclaimed without
|
|
1277
|
-
-- bound and re-delivered each time. Lease generation is the
|
|
1278
|
-
-- durable count of those attempts.
|
|
1279
|
-
AND materialization.lease_generation < ${MAX_MATERIALIZATION_ATTEMPTS}
|
|
1280
|
-
-- One authoritative clock. settleMaterialization fences on
|
|
1281
|
-
-- clock_timestamp(), so issuing the lease from the caller's clock
|
|
1282
|
-
-- made ordinary NTP drift fatal: the claim succeeded, the settle
|
|
1283
|
-
-- matched zero rows, and the row stayed 'pending' at attempt 1
|
|
1284
|
-
-- where the planner's HAVING cannot see it \u2014 so the attempt cap
|
|
1285
|
-
-- never engaged and the adapter was asked to deliver again, and
|
|
1286
|
-
-- again, with no bound.
|
|
1287
|
-
AND (materialization.lease_expires_at IS NULL OR materialization.lease_expires_at <= clock_timestamp())
|
|
1288
|
-
ORDER BY materialization.created_at, materialization.materialization_id
|
|
1289
|
-
FOR UPDATE OF materialization SKIP LOCKED LIMIT 1
|
|
1290
|
-
), claimed AS (
|
|
1291
|
-
UPDATE adcp_reporting_materializations materialization
|
|
1292
|
-
SET lease_owner = $2, lease_generation = lease_generation + 1,
|
|
1293
|
-
lease_expires_at = clock_timestamp() + ($3::bigint * INTERVAL '1 millisecond')
|
|
1294
|
-
FROM candidate WHERE materialization.materialization_id = candidate.materialization_id
|
|
1295
|
-
RETURNING materialization.*
|
|
1296
|
-
)
|
|
1297
|
-
SELECT claimed.materialization_id, claimed.lease_generation::text AS generation,
|
|
1298
|
-
claimed.lease_expires_at AS expires_at, claimed.data AS materialization,
|
|
1299
|
-
binding.data AS binding, obligation.data AS obligation, revision.data AS revision
|
|
1300
|
-
FROM claimed
|
|
1301
|
-
JOIN adcp_reporting_managed_bindings binding ON binding.configuration_id = claimed.configuration_id
|
|
1302
|
-
JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = claimed.obligation_id
|
|
1303
|
-
JOIN adcp_reporting_revisions revision ON revision.revision_id = claimed.revision_id`,
|
|
1304
|
-
[input.account_id ?? null, input.owner, input.lease_milliseconds]
|
|
1305
|
-
);
|
|
1306
|
-
const row = selected.rows[0];
|
|
1307
|
-
if (!row) return null;
|
|
1308
|
-
return {
|
|
1309
|
-
materialization: structuredClone(row.materialization),
|
|
1310
|
-
binding: structuredClone(row.binding),
|
|
1311
|
-
obligation: structuredClone(row.obligation),
|
|
1312
|
-
revision: structuredClone(row.revision),
|
|
1313
|
-
owner: input.owner,
|
|
1314
|
-
generation: Number(row.generation),
|
|
1315
|
-
expires_at: row.expires_at.toISOString()
|
|
1316
|
-
};
|
|
1317
|
-
});
|
|
1318
|
-
if (!lease || Array.isArray(lease.revision.rows)) return lease;
|
|
1319
|
-
try {
|
|
1320
|
-
const handle = await this.rowStorage.find(
|
|
1321
|
-
lease.revision.reporting_revision_id,
|
|
1322
|
-
lease.obligation.account.account_id
|
|
1323
|
-
);
|
|
1324
|
-
if (handle?.kind === "postgres") {
|
|
1325
|
-
lease.revision = {
|
|
1326
|
-
...lease.revision,
|
|
1327
|
-
rows: await this.rowStorage.readAll(handle, lease.revision.wireRevision.control_totals)
|
|
1328
|
-
};
|
|
1329
|
-
}
|
|
1330
|
-
} catch (error) {
|
|
1331
|
-
if (!isReportingRowStoreError(error)) throw error;
|
|
1332
|
-
}
|
|
1333
|
-
return lease;
|
|
1334
|
-
}
|
|
1335
|
-
/**
|
|
1336
|
-
* Fails materializations that have used every delivery attempt.
|
|
1337
|
-
*
|
|
1338
|
-
* The claim predicate stops handing them out, so without this they would
|
|
1339
|
-
* stay `pending` — invisible to the planner, which skips an obligation with
|
|
1340
|
-
* any pending row, and so never retried or replanned.
|
|
1341
|
-
*/
|
|
1342
|
-
async failExhaustedMaterializations(input) {
|
|
1343
|
-
const limit = input?.limit ?? 1e3;
|
|
1344
|
-
positiveInteger(limit, "limit");
|
|
1345
|
-
return this.transaction(async (client) => {
|
|
1346
|
-
const accounts = await client.query(
|
|
1347
|
-
`SELECT DISTINCT account_id FROM adcp_reporting_materializations
|
|
1348
|
-
WHERE status = 'pending'
|
|
1349
|
-
AND lease_generation >= ${MAX_MATERIALIZATION_ATTEMPTS}
|
|
1350
|
-
AND (lease_expires_at IS NULL OR lease_expires_at <= clock_timestamp())
|
|
1351
|
-
AND ($1::text IS NULL OR account_id = $1)
|
|
1352
|
-
ORDER BY account_id`,
|
|
1353
|
-
[input?.account_id ?? null]
|
|
1354
|
-
);
|
|
1355
|
-
for (const { account_id } of accounts.rows) await advisoryLock(client, accountLock(account_id));
|
|
1356
|
-
const lockedAccounts = accounts.rows.map((row) => row.account_id);
|
|
1357
|
-
const result = await client.query(
|
|
1358
|
-
`UPDATE adcp_reporting_materializations SET status = 'failed',
|
|
1359
|
-
data = data || jsonb_build_object(
|
|
1360
|
-
'status', 'failed',
|
|
1361
|
-
'failed_at', ${rfc3339Micro("clock_timestamp()")},
|
|
1362
|
-
'failure_code', 'DELIVERY_ATTEMPTS_EXHAUSTED'
|
|
1363
|
-
),
|
|
1364
|
-
changed_at = clock_timestamp(), lease_owner = NULL, lease_expires_at = NULL
|
|
1365
|
-
WHERE
|
|
1366
|
-
-- Re-evaluated after the row lock, not only as a subquery. The
|
|
1367
|
-
-- subquery runs against the statement's snapshot, so a settle that
|
|
1368
|
-
-- committed while this UPDATE waited on the row was overwritten:
|
|
1369
|
-
-- a delivered materialization became failed /
|
|
1370
|
-
-- DELIVERY_ATTEMPTS_EXHAUSTED. Repeating the predicates here makes
|
|
1371
|
-
-- PostgreSQL recheck them against the committed row.
|
|
1372
|
-
status = 'pending'
|
|
1373
|
-
AND lease_generation >= ${MAX_MATERIALIZATION_ATTEMPTS}
|
|
1374
|
-
AND (lease_expires_at IS NULL OR lease_expires_at <= clock_timestamp())
|
|
1375
|
-
AND account_id = ANY($2::text[])
|
|
1376
|
-
AND materialization_id IN (
|
|
1377
|
-
SELECT materialization_id FROM adcp_reporting_materializations
|
|
1378
|
-
WHERE status = 'pending'
|
|
1379
|
-
AND lease_generation >= ${MAX_MATERIALIZATION_ATTEMPTS}
|
|
1380
|
-
AND (lease_expires_at IS NULL OR lease_expires_at <= clock_timestamp())
|
|
1381
|
-
AND account_id = ANY($2::text[])
|
|
1382
|
-
ORDER BY created_at LIMIT $1)`,
|
|
1383
|
-
[limit, lockedAccounts]
|
|
1384
|
-
);
|
|
1385
|
-
return result.rowCount ?? 0;
|
|
1386
|
-
});
|
|
1387
|
-
}
|
|
1388
|
-
async settleMaterialization(input) {
|
|
1389
|
-
const requested = input.minimum_resource_retention_days;
|
|
1390
|
-
if (requested !== void 0 && (!Number.isSafeInteger(requested) || requested < 0)) {
|
|
1391
|
-
throw new RangeError("minimum_resource_retention_days must be a non-negative safe integer");
|
|
1392
|
-
}
|
|
1393
|
-
if (input.outcome.status !== "failed" && input.outcome.resource.expires_at !== void 0) {
|
|
1394
|
-
assertMaterializationOutcome(
|
|
1395
|
-
input.lease,
|
|
1396
|
-
input.outcome,
|
|
1397
|
-
input.now,
|
|
1398
|
-
Math.max(requested ?? 0, input.lease.binding.resource_retention_days)
|
|
1399
|
-
);
|
|
1400
|
-
}
|
|
1401
|
-
return this.transaction(async (client) => {
|
|
1402
|
-
const { lease } = input;
|
|
1403
|
-
const registeredPolicy = await this.readPolicy(client, "share");
|
|
1404
|
-
await advisoryLock(client, accountLock(lease.binding.account_id));
|
|
1405
|
-
const minimumRetentionDays = Math.max(
|
|
1406
|
-
requested ?? 0,
|
|
1407
|
-
lease.binding.resource_retention_days,
|
|
1408
|
-
registeredPolicy.resourceRetentionDays ?? 0
|
|
1409
|
-
);
|
|
1410
|
-
const authorized = await client.query(
|
|
1411
|
-
`SELECT 1 FROM adcp_reporting_destination_authorizations
|
|
1412
|
-
WHERE account_id = $1 AND destination_ref = $2 AND generation = $3 AND revoked_at IS NULL`,
|
|
1413
|
-
[lease.binding.account_id, lease.binding.destination_ref, lease.binding.authorization_generation]
|
|
1414
|
-
);
|
|
1415
|
-
const outcome = authorized.rowCount ? input.outcome : { status: "failed", failure_code: "AUTHORIZATION_REVOKED" };
|
|
1416
|
-
const materialization = {
|
|
1417
|
-
...lease.materialization,
|
|
1418
|
-
status: outcome.status,
|
|
1419
|
-
...outcome.status === "failed" ? { failed_at: input.now, failure_code: outcome.failure_code } : { ready_at: input.now, resource: outcome.resource, verification: outcome.verification }
|
|
1420
|
-
};
|
|
1421
|
-
const updated = await client.query(
|
|
1422
|
-
`UPDATE adcp_reporting_materializations SET status = $5, data = $6::jsonb,
|
|
1423
|
-
changed_at = clock_timestamp(), lease_owner = NULL, lease_expires_at = NULL
|
|
1424
|
-
WHERE materialization_id = $1 AND lease_owner = $2 AND lease_generation = $3
|
|
1425
|
-
AND lease_expires_at > clock_timestamp() AND status = 'pending' AND authorization_generation = $4
|
|
1426
|
-
-- Retention is judged entirely by the clock that stores it. A
|
|
1427
|
-
-- worker running behind could otherwise satisfy the window on its
|
|
1428
|
-
-- own clock and publish a resource the database already considers
|
|
1429
|
-
-- expired, or under-retain one \u2014 leaving a delivered
|
|
1430
|
-
-- materialization whose bytes no reader can fetch.
|
|
1431
|
-
-- Exempt only a failed outcome, which retains nothing. Treating
|
|
1432
|
-
-- a NULL expiry as exempt let a direct caller settle a
|
|
1433
|
-
-- successful materialization with no expiry at all: permanently
|
|
1434
|
-
-- readable to the projection, permanently unreadable in fact,
|
|
1435
|
-
-- and never revisited because it is not pending.
|
|
1436
|
-
AND ($5 = 'failed'
|
|
1437
|
-
OR ($7::timestamptz IS NOT NULL
|
|
1438
|
-
AND $7::timestamptz >= clock_timestamp() + ($8::bigint * INTERVAL '1 day')))`,
|
|
1439
|
-
[
|
|
1440
|
-
lease.materialization.reporting_materialization_id,
|
|
1441
|
-
lease.owner,
|
|
1442
|
-
lease.generation,
|
|
1443
|
-
lease.binding.authorization_generation,
|
|
1444
|
-
outcome.status,
|
|
1445
|
-
JSON.stringify(materialization),
|
|
1446
|
-
outcome.status === "failed" ? null : outcome.resource.expires_at,
|
|
1447
|
-
minimumRetentionDays
|
|
1448
|
-
]
|
|
1449
|
-
);
|
|
1450
|
-
if (updated.rowCount === 1 && authorized.rowCount === 1 && outcome.status !== "failed" && this.notificationActivityPort?.recordDeliveryReady) {
|
|
1451
|
-
await this.notificationActivityPort.recordDeliveryReady(
|
|
1452
|
-
{
|
|
1453
|
-
obligation: lease.obligation,
|
|
1454
|
-
revision: lease.revision,
|
|
1455
|
-
materialization
|
|
1456
|
-
},
|
|
1457
|
-
client
|
|
1458
|
-
);
|
|
1459
|
-
}
|
|
1460
|
-
if (updated.rowCount === 0 && outcome.status !== "failed") {
|
|
1461
|
-
await client.query(
|
|
1462
|
-
`UPDATE adcp_reporting_materializations SET status = 'failed',
|
|
1463
|
-
data = data || jsonb_build_object(
|
|
1464
|
-
'status', 'failed',
|
|
1465
|
-
'failed_at', ${rfc3339Micro("clock_timestamp()")},
|
|
1466
|
-
'failure_code', 'RESOURCE_RETENTION_INSUFFICIENT'
|
|
1467
|
-
),
|
|
1468
|
-
changed_at = clock_timestamp(), lease_owner = NULL, lease_expires_at = NULL
|
|
1469
|
-
-- Deliberately not fenced on an unexpired lease. If the settle
|
|
1470
|
-
-- waited on the account lock until the lease expired, both it and
|
|
1471
|
-
-- this terminalizer rejected, leaving the row pending \u2014
|
|
1472
|
-
-- reclaimable forever, and re-delivered each time. Owner and
|
|
1473
|
-
-- generation still fence it against a newer holder.
|
|
1474
|
-
WHERE materialization_id = $1 AND lease_owner = $2 AND lease_generation = $3
|
|
1475
|
-
AND status = 'pending'`,
|
|
1476
|
-
[lease.materialization.reporting_materialization_id, lease.owner, lease.generation]
|
|
1477
|
-
);
|
|
1478
|
-
}
|
|
1479
|
-
return updated.rowCount === 1 && authorized.rowCount === 1 && outcome.status !== "failed";
|
|
1480
|
-
});
|
|
1481
|
-
}
|
|
1482
|
-
async claimRevocation(input) {
|
|
1483
|
-
positiveInteger(input.lease_milliseconds, "lease_milliseconds");
|
|
1484
|
-
if (input.authorization_revocation_seconds !== void 0) {
|
|
1485
|
-
nonnegativeSafeInteger(input.authorization_revocation_seconds, "authorization_revocation_seconds");
|
|
1486
|
-
}
|
|
1487
|
-
return this.transaction(async (client) => {
|
|
1488
|
-
const registeredPolicy = await this.readPolicy(client, "share");
|
|
1489
|
-
const revocationSeconds = registeredPolicy.authorizationRevocationSeconds === null ? input.authorization_revocation_seconds : Math.min(
|
|
1490
|
-
registeredPolicy.authorizationRevocationSeconds,
|
|
1491
|
-
input.authorization_revocation_seconds ?? registeredPolicy.authorizationRevocationSeconds
|
|
1492
|
-
);
|
|
1493
|
-
const result = await client.query(
|
|
1494
|
-
`WITH candidate AS (
|
|
1495
|
-
SELECT account_id, destination_ref, generation
|
|
1496
|
-
FROM adcp_reporting_destination_authorizations
|
|
1497
|
-
WHERE revoked_at IS NOT NULL AND cleanup_completed_at IS NULL
|
|
1498
|
-
-- Same single-clock rule as claimMaterialization: completeRevocation
|
|
1499
|
-
-- fences on clock_timestamp(), so the lease must be issued from it
|
|
1500
|
-
-- too or cleanup can never commit and the grant is never torn down.
|
|
1501
|
-
--
|
|
1502
|
-
-- The last arm reclaims at the SLA boundary. A worker that
|
|
1503
|
-
-- crashed holding a long lease would otherwise make the grant
|
|
1504
|
-
-- unreclaimable for the rest of that lease while the advertised
|
|
1505
|
-
-- window elapsed. Lease generation fences the old holder, so its
|
|
1506
|
-
-- late completion cannot commit over the new one.
|
|
1507
|
-
-- One stable policy. Reclaiming purely because the SLA had
|
|
1508
|
-
-- elapsed let every worker steal from every other on an already
|
|
1509
|
-
-- late grant: generations bumped each pass and each holder's
|
|
1510
|
-
-- completion was invalidated by the next, so cleanup never
|
|
1511
|
-
-- committed at all. A holder is displaced only once it has had a
|
|
1512
|
-
-- full attempt's worth of time and still not finished, which
|
|
1513
|
-
-- recovers a crashed worker without disturbing a live one.
|
|
1514
|
-
AND (cleanup_lease_expires_at IS NULL
|
|
1515
|
-
OR cleanup_lease_expires_at <= clock_timestamp()
|
|
1516
|
-
OR ($4::bigint IS NOT NULL
|
|
1517
|
-
AND revoked_at + ($4::bigint * INTERVAL '1 second') <= clock_timestamp()
|
|
1518
|
-
AND cleanup_lease_issued_at IS NOT NULL
|
|
1519
|
-
AND cleanup_lease_issued_at + ($5::bigint * INTERVAL '1 millisecond')
|
|
1520
|
-
<= clock_timestamp()))
|
|
1521
|
-
AND ($3::text IS NULL OR account_id = $3)
|
|
1522
|
-
ORDER BY cleanup_lease_generation, revoked_at, account_id, destination_ref
|
|
1523
|
-
FOR UPDATE SKIP LOCKED LIMIT 1
|
|
1524
|
-
)
|
|
1525
|
-
UPDATE adcp_reporting_destination_authorizations target SET
|
|
1526
|
-
cleanup_lease_owner = $1, cleanup_lease_generation = target.cleanup_lease_generation + 1,
|
|
1527
|
-
cleanup_lease_issued_at = clock_timestamp(),
|
|
1528
|
-
cleanup_lease_expires_at = clock_timestamp() + ($2::bigint * INTERVAL '1 millisecond')
|
|
1529
|
-
FROM candidate
|
|
1530
|
-
WHERE target.account_id = candidate.account_id AND target.destination_ref = candidate.destination_ref
|
|
1531
|
-
AND target.generation = candidate.generation
|
|
1532
|
-
RETURNING target.data || jsonb_strip_nulls(
|
|
1533
|
-
jsonb_build_object('pattern', target.pattern, 'transport', target.transport)
|
|
1534
|
-
) AS data,
|
|
1535
|
-
target.cleanup_lease_generation::text AS generation,
|
|
1536
|
-
target.cleanup_lease_expires_at AS expires_at,
|
|
1537
|
-
target.revoked_at AS revoked_at,
|
|
1538
|
-
CASE WHEN $4::bigint IS NULL THEN NULL
|
|
1539
|
-
ELSE target.revoked_at + ($4::bigint * INTERVAL '1 second') <= clock_timestamp()
|
|
1540
|
-
END AS overdue,
|
|
1541
|
-
CASE WHEN $4::bigint IS NULL THEN NULL
|
|
1542
|
-
ELSE EXTRACT(EPOCH FROM (target.revoked_at
|
|
1543
|
-
+ ($4::bigint * INTERVAL '1 second') - clock_timestamp())) * 1000
|
|
1544
|
-
END AS remaining_milliseconds`,
|
|
1545
|
-
[
|
|
1546
|
-
input.owner,
|
|
1547
|
-
input.lease_milliseconds,
|
|
1548
|
-
input.account_id ?? null,
|
|
1549
|
-
revocationSeconds ?? null,
|
|
1550
|
-
input.steal_after_milliseconds ?? input.lease_milliseconds
|
|
1551
|
-
]
|
|
1552
|
-
);
|
|
1553
|
-
const row = result.rows[0];
|
|
1554
|
-
if (!row) return null;
|
|
1555
|
-
return {
|
|
1556
|
-
authorization: structuredClone(row.data),
|
|
1557
|
-
owner: input.owner,
|
|
1558
|
-
generation: Number(row.generation),
|
|
1559
|
-
expires_at: row.expires_at.toISOString(),
|
|
1560
|
-
// Every SLA fact comes from the database that committed the
|
|
1561
|
-
// revocation, never from the worker host: a skewed worker must not be
|
|
1562
|
-
// able to decide a grant is still inside its promised window.
|
|
1563
|
-
revoked_at: row.revoked_at.toISOString(),
|
|
1564
|
-
...row.overdue === null ? {} : { overdue: row.overdue },
|
|
1565
|
-
...row.remaining_milliseconds === null ? {} : { remaining_milliseconds: Number(row.remaining_milliseconds) }
|
|
1566
|
-
};
|
|
1567
|
-
});
|
|
1568
|
-
}
|
|
1569
|
-
async completeRevocation(input) {
|
|
1570
|
-
const result = await this.query(
|
|
1571
|
-
`UPDATE adcp_reporting_destination_authorizations SET cleanup_completed_at = $6::timestamptz,
|
|
1572
|
-
cleanup_lease_owner = NULL, cleanup_lease_expires_at = NULL,
|
|
1573
|
-
data = data || jsonb_build_object('cleanup_completed_at', $6::text)
|
|
1574
|
-
WHERE account_id = $1 AND destination_ref = $2 AND generation = $3
|
|
1575
|
-
AND cleanup_lease_owner = $4 AND cleanup_lease_generation = $5
|
|
1576
|
-
AND cleanup_lease_expires_at > clock_timestamp()
|
|
1577
|
-
AND revoked_at IS NOT NULL AND cleanup_completed_at IS NULL`,
|
|
1578
|
-
[
|
|
1579
|
-
input.lease.authorization.account_id,
|
|
1580
|
-
input.lease.authorization.destination_ref,
|
|
1581
|
-
input.lease.authorization.generation,
|
|
1582
|
-
input.lease.owner,
|
|
1583
|
-
input.lease.generation,
|
|
1584
|
-
input.completed_at
|
|
1585
|
-
]
|
|
1586
|
-
);
|
|
1587
|
-
return result.rowCount === 1;
|
|
1588
|
-
}
|
|
1589
|
-
async releaseRevocation(input) {
|
|
1590
|
-
const result = await this.query(
|
|
1591
|
-
`UPDATE adcp_reporting_destination_authorizations
|
|
1592
|
-
-- Release enables a later recovery, not an immediate one. Clearing
|
|
1593
|
-
-- the lease outright let the very next iteration of the same worker
|
|
1594
|
-
-- tick reclaim the same grant, so one broken provider consumed
|
|
1595
|
-
-- every iteration and nothing else was ever cleaned up. Holding a
|
|
1596
|
-
-- short backoff keeps the grant reclaimable soon without that
|
|
1597
|
-
-- tight loop; the generation bump still fences the failed holder.
|
|
1598
|
-
SET cleanup_lease_owner = NULL, cleanup_lease_issued_at = NULL,
|
|
1599
|
-
cleanup_lease_expires_at = clock_timestamp() + ($6::bigint * INTERVAL '1 millisecond')
|
|
1600
|
-
WHERE account_id = $1 AND destination_ref = $2 AND generation = $3
|
|
1601
|
-
AND cleanup_lease_owner = $4 AND cleanup_lease_generation = $5
|
|
1602
|
-
AND cleanup_completed_at IS NULL`,
|
|
1603
|
-
[
|
|
1604
|
-
input.lease.authorization.account_id,
|
|
1605
|
-
input.lease.authorization.destination_ref,
|
|
1606
|
-
input.lease.authorization.generation,
|
|
1607
|
-
input.lease.owner,
|
|
1608
|
-
input.lease.generation,
|
|
1609
|
-
input.backoff_milliseconds ?? REVOCATION_RETRY_BACKOFF_MS
|
|
1610
|
-
]
|
|
1611
|
-
);
|
|
1612
|
-
return result.rowCount === 1;
|
|
1613
|
-
}
|
|
1614
|
-
async getReadableResource(input) {
|
|
1615
|
-
const result = await this.query(
|
|
1616
|
-
`SELECT materialization.data AS materialization, binding.data AS binding
|
|
1617
|
-
FROM adcp_reporting_materializations materialization
|
|
1618
|
-
JOIN adcp_reporting_managed_bindings binding ON binding.configuration_id = materialization.configuration_id
|
|
1619
|
-
JOIN adcp_reporting_destination_authorizations authz
|
|
1620
|
-
ON authz.account_id = materialization.account_id
|
|
1621
|
-
AND authz.destination_ref = materialization.destination_ref
|
|
1622
|
-
AND authz.generation = materialization.authorization_generation
|
|
1623
|
-
AND authz.revoked_at IS NULL
|
|
1624
|
-
WHERE materialization.account_id = $1
|
|
1625
|
-
AND materialization.status IN ('available','delivered')
|
|
1626
|
-
AND materialization.data->'resource'->>'resource_ref' = $2
|
|
1627
|
-
AND (materialization.data->'resource'->>'expires_at')::timestamptz > clock_timestamp()`,
|
|
1628
|
-
[input.account_id, input.resource_ref]
|
|
1629
|
-
);
|
|
1630
|
-
const row = result.rows[0];
|
|
1631
|
-
return row ? { materialization: structuredClone(row.materialization), binding: structuredClone(row.binding) } : null;
|
|
1632
|
-
}
|
|
1633
|
-
/** Freeze all writable names under the same locks as revokeDestination. */
|
|
1634
|
-
async registerObjectWritePlan(input, context = {}) {
|
|
1635
|
-
const plan = structuredClone(input);
|
|
1636
|
-
const fingerprint = reportingObjectWritePlanFingerprint(plan);
|
|
1637
|
-
return this.transaction(async (client) => {
|
|
1638
|
-
await advisoryLock(client, accountLock(plan.account_id));
|
|
1639
|
-
await advisoryLock(client, authLock(plan.account_id, plan.destination_ref));
|
|
1640
|
-
const authorized = await client.query(
|
|
1641
|
-
`SELECT 1 FROM adcp_reporting_destination_authorizations
|
|
1642
|
-
WHERE account_id = $1 AND destination_ref = $2 AND generation = $3 AND revoked_at IS NULL`,
|
|
1643
|
-
[plan.account_id, plan.destination_ref, plan.generation]
|
|
1644
|
-
);
|
|
1645
|
-
if (!authorized.rowCount) return "revoked";
|
|
1646
|
-
await client.query(
|
|
1647
|
-
`INSERT INTO adcp_reporting_object_write_bindings (account_id,destination_ref,generation,bucket,namespace_key)
|
|
1648
|
-
VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`,
|
|
1649
|
-
[plan.account_id, plan.destination_ref, plan.generation, plan.provider.bucket, plan.provider.namespace_key]
|
|
1650
|
-
);
|
|
1651
|
-
const binding = await client.query(
|
|
1652
|
-
`SELECT bucket,namespace_key FROM adcp_reporting_object_write_bindings
|
|
1653
|
-
WHERE account_id=$1 AND destination_ref=$2 AND generation=$3`,
|
|
1654
|
-
[plan.account_id, plan.destination_ref, plan.generation]
|
|
1655
|
-
);
|
|
1656
|
-
if (binding.rows[0]?.bucket !== plan.provider.bucket || binding.rows[0]?.namespace_key !== plan.provider.namespace_key)
|
|
1657
|
-
throw new ReportingObjectWriteConflictError();
|
|
1658
|
-
const inserted = await client.query(
|
|
1659
|
-
`INSERT INTO adcp_reporting_object_write_plans (account_id, destination_ref, generation, plan_id, fingerprint)
|
|
1660
|
-
VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`,
|
|
1661
|
-
[plan.account_id, plan.destination_ref, plan.generation, plan.plan_id, fingerprint]
|
|
1662
|
-
);
|
|
1663
|
-
if (!inserted.rowCount) {
|
|
1664
|
-
const existing = await client.query(
|
|
1665
|
-
`SELECT fingerprint FROM adcp_reporting_object_write_plans
|
|
1666
|
-
WHERE account_id=$1 AND destination_ref=$2 AND generation=$3 AND plan_id=$4`,
|
|
1667
|
-
[plan.account_id, plan.destination_ref, plan.generation, plan.plan_id]
|
|
1668
|
-
);
|
|
1669
|
-
if (existing.rows[0]?.fingerprint !== fingerprint) throw new ReportingObjectWriteConflictError();
|
|
1670
|
-
return "unchanged";
|
|
1671
|
-
}
|
|
1672
|
-
await client.query(
|
|
1673
|
-
`INSERT INTO adcp_reporting_object_writes
|
|
1674
|
-
(account_id,destination_ref,generation,plan_id,object_index,bucket,object_name,sha256,size_bytes)
|
|
1675
|
-
SELECT $1,$2,$3,$4,(ordinality-1)::integer,value->>'bucket',value->>'object_name',value->>'sha256',
|
|
1676
|
-
(value->>'size_bytes')::bigint FROM jsonb_array_elements($5::jsonb) WITH ORDINALITY`,
|
|
1677
|
-
[
|
|
1678
|
-
plan.account_id,
|
|
1679
|
-
plan.destination_ref,
|
|
1680
|
-
plan.generation,
|
|
1681
|
-
plan.plan_id,
|
|
1682
|
-
JSON.stringify(
|
|
1683
|
-
plan.objects.map(({ bucket, object_name, sha256, size_bytes }) => ({
|
|
1684
|
-
bucket,
|
|
1685
|
-
object_name,
|
|
1686
|
-
sha256,
|
|
1687
|
-
size_bytes
|
|
1688
|
-
}))
|
|
1689
|
-
)
|
|
1690
|
-
]
|
|
1691
|
-
);
|
|
1692
|
-
return "registered";
|
|
1693
|
-
}, context.signal);
|
|
1694
|
-
}
|
|
1695
|
-
async listRevokedObjectWrites(inputScope, inputOptions = {}, context = {}) {
|
|
1696
|
-
const scope = structuredClone(inputScope);
|
|
1697
|
-
const options = structuredClone(inputOptions);
|
|
1698
|
-
assertReportingObjectWriteScope(scope);
|
|
1699
|
-
const limit = options.limit ?? 100;
|
|
1700
|
-
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e3 || options.after && (!/^[a-f0-9]{64}$/.test(options.after.plan_id) || !Number.isSafeInteger(options.after.object_index) || options.after.object_index < 0 || options.after.object_index >= 128)) {
|
|
1701
|
-
throw new Error("Invalid reporting object-write page");
|
|
1702
|
-
}
|
|
1703
|
-
return this.transaction(async (client) => {
|
|
1704
|
-
await advisoryLock(client, accountLock(scope.account_id));
|
|
1705
|
-
await advisoryLock(client, authLock(scope.account_id, scope.destination_ref));
|
|
1706
|
-
const revoked = await client.query(
|
|
1707
|
-
`SELECT 1 FROM adcp_reporting_destination_authorizations
|
|
1708
|
-
WHERE account_id=$1 AND destination_ref=$2 AND generation=$3 AND revoked_at IS NOT NULL`,
|
|
1709
|
-
[scope.account_id, scope.destination_ref, scope.generation]
|
|
1710
|
-
);
|
|
1711
|
-
if (!revoked.rowCount) throw new ReportingObjectWriteNotRevokedError();
|
|
1712
|
-
const rows = await client.query(
|
|
1713
|
-
`SELECT plan_id,object_index,bucket,object_name,sha256,size_bytes::float8 AS size_bytes
|
|
1714
|
-
FROM adcp_reporting_object_writes WHERE account_id=$1 AND destination_ref=$2 AND generation=$3 AND fenced_at IS NULL
|
|
1715
|
-
AND ($4::text IS NULL OR (plan_id,object_index) > ($4,$5::integer))
|
|
1716
|
-
ORDER BY plan_id,object_index LIMIT $6`,
|
|
1717
|
-
[
|
|
1718
|
-
scope.account_id,
|
|
1719
|
-
scope.destination_ref,
|
|
1720
|
-
scope.generation,
|
|
1721
|
-
options.after?.plan_id ?? null,
|
|
1722
|
-
options.after?.object_index ?? null,
|
|
1723
|
-
limit
|
|
1724
|
-
]
|
|
1725
|
-
);
|
|
1726
|
-
return rows.rows;
|
|
1727
|
-
}, context.signal);
|
|
1728
|
-
}
|
|
1729
|
-
async markObjectWriteFenced(input, context = {}) {
|
|
1730
|
-
const frozen = structuredClone(input);
|
|
1731
|
-
assertReportingObjectWriteScope(frozen);
|
|
1732
|
-
if (!/^[a-f0-9]{64}$/.test(frozen.plan_id) || !Number.isSafeInteger(frozen.object_index) || frozen.object_index < 0 || frozen.object_index >= 128 || !/^[1-9][0-9]{0,39}$/.test(frozen.tombstone_generation)) {
|
|
1733
|
-
throw new Error("Invalid reporting object-write fence");
|
|
1734
|
-
}
|
|
1735
|
-
await this.transaction(async (client) => {
|
|
1736
|
-
await advisoryLock(client, accountLock(frozen.account_id));
|
|
1737
|
-
await advisoryLock(client, authLock(frozen.account_id, frozen.destination_ref));
|
|
1738
|
-
const updated = await client.query(
|
|
1739
|
-
`UPDATE adcp_reporting_object_writes object SET fenced_at=COALESCE(fenced_at,clock_timestamp()), tombstone_generation=$6
|
|
1740
|
-
WHERE account_id=$1 AND destination_ref=$2 AND generation=$3 AND plan_id=$4 AND object_index=$5
|
|
1741
|
-
AND (tombstone_generation IS NULL OR tombstone_generation=$6)
|
|
1742
|
-
AND EXISTS (SELECT 1 FROM adcp_reporting_destination_authorizations authz
|
|
1743
|
-
WHERE authz.account_id=object.account_id AND authz.destination_ref=object.destination_ref
|
|
1744
|
-
AND authz.generation=object.generation AND authz.revoked_at IS NOT NULL)`,
|
|
1745
|
-
[
|
|
1746
|
-
frozen.account_id,
|
|
1747
|
-
frozen.destination_ref,
|
|
1748
|
-
frozen.generation,
|
|
1749
|
-
frozen.plan_id,
|
|
1750
|
-
frozen.object_index,
|
|
1751
|
-
frozen.tombstone_generation
|
|
1752
|
-
]
|
|
1753
|
-
);
|
|
1754
|
-
if (updated.rowCount !== 1) throw new Error("Reporting object-write fence is not eligible");
|
|
1755
|
-
}, context.signal);
|
|
1756
|
-
}
|
|
1757
|
-
async getObjectWriteBinding(input, context = {}) {
|
|
1758
|
-
const scope = structuredClone(input);
|
|
1759
|
-
assertReportingObjectWriteScope(scope);
|
|
1760
|
-
return this.transaction(async (client) => {
|
|
1761
|
-
await advisoryLock(client, accountLock(scope.account_id));
|
|
1762
|
-
await advisoryLock(client, authLock(scope.account_id, scope.destination_ref));
|
|
1763
|
-
const revoked = await client.query(
|
|
1764
|
-
`SELECT 1 FROM adcp_reporting_destination_authorizations
|
|
1765
|
-
WHERE account_id=$1 AND destination_ref=$2 AND generation=$3 AND revoked_at IS NOT NULL`,
|
|
1766
|
-
[scope.account_id, scope.destination_ref, scope.generation]
|
|
1767
|
-
);
|
|
1768
|
-
if (!revoked.rowCount) throw new ReportingObjectWriteNotRevokedError();
|
|
1769
|
-
const binding = await client.query(
|
|
1770
|
-
`SELECT bucket,namespace_key FROM adcp_reporting_object_write_bindings
|
|
1771
|
-
WHERE account_id=$1 AND destination_ref=$2 AND generation=$3`,
|
|
1772
|
-
[scope.account_id, scope.destination_ref, scope.generation]
|
|
1773
|
-
);
|
|
1774
|
-
return binding.rows[0] ?? null;
|
|
1775
|
-
}, context.signal);
|
|
1776
|
-
}
|
|
1777
|
-
async probeObjectWriteAuthority(context = {}) {
|
|
1778
|
-
await this.transaction(async (client) => {
|
|
1779
|
-
await client.query(
|
|
1780
|
-
`SELECT installation_id,schema_version FROM adcp_reporting_object_write_authority WHERE singleton AND schema_version=1`
|
|
1781
|
-
);
|
|
1782
|
-
await client.query(
|
|
1783
|
-
`SELECT account_id,destination_ref,generation,bucket,namespace_key FROM adcp_reporting_object_write_bindings LIMIT 0`
|
|
1784
|
-
);
|
|
1785
|
-
await client.query(
|
|
1786
|
-
`SELECT account_id,destination_ref,generation,plan_id,fingerprint FROM adcp_reporting_object_write_plans LIMIT 0`
|
|
1787
|
-
);
|
|
1788
|
-
await client.query(
|
|
1789
|
-
`SELECT plan_id,object_index,bucket,object_name,sha256,size_bytes,fenced_at,tombstone_generation FROM adcp_reporting_object_writes LIMIT 0`
|
|
1790
|
-
);
|
|
1791
|
-
}, context.signal);
|
|
1792
|
-
await this.getObjectWriteAuthorityId(context);
|
|
1793
|
-
return true;
|
|
1794
|
-
}
|
|
1795
|
-
async getObjectWriteAuthorityId(context = {}) {
|
|
1796
|
-
return this.transaction(async (client) => {
|
|
1797
|
-
const result = await client.query(
|
|
1798
|
-
`SELECT installation_id::text FROM adcp_reporting_object_write_authority WHERE singleton AND schema_version=1`
|
|
1799
|
-
);
|
|
1800
|
-
const id = result.rows[0]?.installation_id;
|
|
1801
|
-
if (result.rowCount !== 1 || !id || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id))
|
|
1802
|
-
throw new Error("Reporting object-write authority migration is unavailable");
|
|
1803
|
-
return id;
|
|
1804
|
-
}, context.signal);
|
|
1805
|
-
}
|
|
1806
|
-
async getAuthorizedObjectWriteBinding(input, context = {}) {
|
|
1807
|
-
const scope = {
|
|
1808
|
-
account_id: input.account_id,
|
|
1809
|
-
destination_ref: input.destination_ref,
|
|
1810
|
-
generation: input.generation
|
|
1811
|
-
};
|
|
1812
|
-
assertReportingObjectWriteScope(scope);
|
|
1813
|
-
return this.transaction(async (client) => {
|
|
1814
|
-
const result = await client.query(
|
|
1815
|
-
`SELECT binding.bucket,binding.namespace_key FROM adcp_reporting_object_write_bindings binding
|
|
1816
|
-
JOIN adcp_reporting_destination_authorizations authz USING (account_id,destination_ref,generation)
|
|
1817
|
-
WHERE binding.account_id=$1 AND binding.destination_ref=$2 AND binding.generation=$3 AND authz.revoked_at IS NULL`,
|
|
1818
|
-
[scope.account_id, scope.destination_ref, scope.generation]
|
|
1819
|
-
);
|
|
1820
|
-
return result.rows[0] ?? null;
|
|
1821
|
-
}, context.signal);
|
|
1822
|
-
}
|
|
1823
|
-
async getObjectWritePlan(input, planId, context = {}) {
|
|
1824
|
-
const scope = {
|
|
1825
|
-
account_id: input.account_id,
|
|
1826
|
-
destination_ref: input.destination_ref,
|
|
1827
|
-
generation: input.generation
|
|
1828
|
-
};
|
|
1829
|
-
assertReportingObjectWriteScope(scope);
|
|
1830
|
-
if (typeof planId !== "string" || !/^[a-f0-9]{64}$/.test(planId))
|
|
1831
|
-
throw new Error("Invalid reporting object-write plan identity");
|
|
1832
|
-
return this.transaction(async (client) => {
|
|
1833
|
-
const selected = await client.query(
|
|
1834
|
-
`SELECT binding.bucket,binding.namespace_key,plan.fingerprint FROM adcp_reporting_object_write_plans plan
|
|
1835
|
-
JOIN adcp_reporting_object_write_bindings binding USING (account_id,destination_ref,generation)
|
|
1836
|
-
JOIN adcp_reporting_destination_authorizations authz USING (account_id,destination_ref,generation)
|
|
1837
|
-
WHERE plan.account_id=$1 AND plan.destination_ref=$2 AND plan.generation=$3 AND plan.plan_id=$4 AND authz.revoked_at IS NULL`,
|
|
1838
|
-
[scope.account_id, scope.destination_ref, scope.generation, planId]
|
|
1839
|
-
);
|
|
1840
|
-
if (!selected.rows[0]) return null;
|
|
1841
|
-
const selectedBinding = selected.rows[0];
|
|
1842
|
-
const objects = await client.query(
|
|
1843
|
-
`SELECT bucket,object_name,sha256,size_bytes::float8 AS size_bytes FROM adcp_reporting_object_writes
|
|
1844
|
-
WHERE account_id=$1 AND destination_ref=$2 AND generation=$3 AND plan_id=$4 ORDER BY object_index`,
|
|
1845
|
-
[scope.account_id, scope.destination_ref, scope.generation, planId]
|
|
1846
|
-
);
|
|
1847
|
-
const plan = {
|
|
1848
|
-
...scope,
|
|
1849
|
-
plan_id: planId,
|
|
1850
|
-
provider: { bucket: selectedBinding.bucket, namespace_key: selectedBinding.namespace_key },
|
|
1851
|
-
objects: objects.rows
|
|
1852
|
-
};
|
|
1853
|
-
if (reportingObjectWritePlanFingerprint(plan) !== selectedBinding.fingerprint)
|
|
1854
|
-
throw new ReportingObjectWriteConflictError();
|
|
1855
|
-
return plan;
|
|
1856
|
-
}, context.signal);
|
|
1857
|
-
}
|
|
1858
|
-
/**
|
|
1859
|
-
* Generations that carry no recorded pattern/transport, with why the
|
|
1860
|
-
* migration backfill could not infer them. These are routed to a router's
|
|
1861
|
-
* `legacyDefault` (or fail revocation with a typed error) until the host
|
|
1862
|
-
* supplies the contract. Read-only; it never edits a row.
|
|
1863
|
-
*
|
|
1864
|
-
* `no_binding`: nothing to infer from. `binding_without_transport`: at least
|
|
1865
|
-
* one binding names no transport. `bindings_disagree`: the bindings name
|
|
1866
|
-
* different methods or transports. `not_recorded`: the bindings agree, but
|
|
1867
|
-
* the generation was created without a contract after the one-time backfill
|
|
1868
|
-
* ran, so nothing records it.
|
|
1869
|
-
*/
|
|
1870
|
-
async listDestinationAuthorizationsWithoutContract(input = {}) {
|
|
1871
|
-
const limit = input.limit ?? 1e3;
|
|
1872
|
-
positiveInteger(limit, "limit");
|
|
1873
|
-
if (limit > MAX_PLAN) throw new RangeError(`limit must not exceed ${MAX_PLAN}`);
|
|
1874
|
-
const result = await this.query(
|
|
1875
|
-
`SELECT authz.account_id, authz.destination_ref, authz.generation::text AS generation,
|
|
1876
|
-
COUNT(binding.configuration_id)::text AS bindings,
|
|
1877
|
-
COUNT(DISTINCT binding.data->>'method')::text AS methods,
|
|
1878
|
-
COUNT(DISTINCT binding.data->>'transport')::text AS transports,
|
|
1879
|
-
COUNT(binding.data->>'transport')::text AS with_transport
|
|
1880
|
-
FROM adcp_reporting_destination_authorizations authz
|
|
1881
|
-
LEFT JOIN adcp_reporting_managed_bindings binding
|
|
1882
|
-
ON binding.account_id = authz.account_id
|
|
1883
|
-
AND binding.destination_ref = authz.destination_ref
|
|
1884
|
-
AND binding.authorization_generation = authz.generation
|
|
1885
|
-
WHERE (authz.pattern IS NULL OR authz.transport IS NULL)
|
|
1886
|
-
AND ($1::text IS NULL OR authz.account_id = $1)
|
|
1887
|
-
GROUP BY authz.account_id, authz.destination_ref, authz.generation
|
|
1888
|
-
ORDER BY authz.account_id, authz.destination_ref, authz.generation
|
|
1889
|
-
LIMIT $2`,
|
|
1890
|
-
[input.account_id ?? null, limit]
|
|
1891
|
-
);
|
|
1892
|
-
return result.rows.map((row) => {
|
|
1893
|
-
const bindings = Number(row.bindings);
|
|
1894
|
-
const reason = bindings === 0 ? "no_binding" : Number(row.with_transport) < bindings ? "binding_without_transport" : Number(row.methods) > 1 || Number(row.transports) > 1 ? "bindings_disagree" : "not_recorded";
|
|
1895
|
-
return {
|
|
1896
|
-
account_id: row.account_id,
|
|
1897
|
-
destination_ref: row.destination_ref,
|
|
1898
|
-
generation: Number(row.generation),
|
|
1899
|
-
reason
|
|
1900
|
-
};
|
|
1901
|
-
});
|
|
1902
|
-
}
|
|
1903
|
-
async isAuthorizationCurrent(input) {
|
|
1904
|
-
const result = await this.query(
|
|
1905
|
-
`SELECT 1 FROM adcp_reporting_destination_authorizations
|
|
1906
|
-
WHERE account_id = $1 AND destination_ref = $2 AND generation = $3 AND revoked_at IS NULL`,
|
|
1907
|
-
[input.account_id, input.destination_ref, input.generation]
|
|
1908
|
-
);
|
|
1909
|
-
return result.rowCount === 1;
|
|
1910
|
-
}
|
|
1911
|
-
async syncReceiptBatch(input) {
|
|
1912
|
-
const revisionCount = input.entries.filter((entry) => entry.kind === "revision").length;
|
|
1913
|
-
if (!/^[A-Za-z0-9_.:-]{16,255}$/.test(input.idempotency_key) || input.entries.length < 1 || input.entries.length > MAX_RECEIPT_RESULTS || revisionCount > MAX_RECEIPTS_PER_ARRAY || input.entries.length - revisionCount > MAX_RECEIPTS_PER_ARRAY || input.entries.some(
|
|
1914
|
-
(entry) => (entry.kind === "revision" ? !isReportingReceiptEvidence(entry.receipt) : !isReportingAdjustmentReceiptEvidence(entry.receipt)) || Buffer.byteLength(JSON.stringify(entry.receipt), "utf8") > 64 * 1024
|
|
1915
|
-
)) {
|
|
1916
|
-
throw new Error("Reporting receipt transaction failed");
|
|
1917
|
-
}
|
|
1918
|
-
return this.transaction(async (client) => {
|
|
1919
|
-
await advisoryLock(client, accountLock(input.account_id));
|
|
1920
|
-
await advisoryLock(client, `adcp-reporting-receipts:${input.account_id}:${input.consumer_id}`);
|
|
1921
|
-
const receivedAt = (await client.query(`SELECT ${rfc3339Micro("clock_timestamp()")} AS now`)).rows[0].now;
|
|
1922
|
-
const prior = await client.query(
|
|
1923
|
-
`SELECT request_fingerprint, results FROM adcp_reporting_receipt_batches
|
|
1924
|
-
WHERE account_id = $1 AND consumer_id = $2 AND idempotency_key = $3`,
|
|
1925
|
-
[input.account_id, input.consumer_id, input.idempotency_key]
|
|
1926
|
-
);
|
|
1927
|
-
if (prior.rows[0]) {
|
|
1928
|
-
if (prior.rows[0].request_fingerprint !== input.request_fingerprint) {
|
|
1929
|
-
return input.entries.map((entry) => idempotencyConflict(entry.receipt.reporting_receipt_id));
|
|
1930
|
-
}
|
|
1931
|
-
return this.replayReceiptBatch(client, input, prior.rows[0].results);
|
|
1932
|
-
}
|
|
1933
|
-
await client.query(
|
|
1934
|
-
`DELETE FROM adcp_reporting_receipt_batches
|
|
1935
|
-
WHERE account_id = $1 AND consumer_id = $2
|
|
1936
|
-
AND recorded_at < clock_timestamp() - ($3::bigint * INTERVAL '1 millisecond')`,
|
|
1937
|
-
[input.account_id, input.consumer_id, RECEIPT_BATCH_RETENTION_MS]
|
|
1938
|
-
);
|
|
1939
|
-
const capacity = await client.query(
|
|
1940
|
-
`SELECT
|
|
1941
|
-
(SELECT COUNT(*) FROM adcp_reporting_receipt_batches
|
|
1942
|
-
WHERE account_id = $1 AND consumer_id = $2)::text AS batches,
|
|
1943
|
-
(SELECT COUNT(*) FROM adcp_reporting_receipts
|
|
1944
|
-
WHERE account_id = $1 AND consumer_id = $2${this.activeScope("recorded_at")})::text AS receipts`,
|
|
1945
|
-
[input.account_id, input.consumer_id]
|
|
1946
|
-
);
|
|
1947
|
-
if (Number(capacity.rows[0]?.batches ?? 0) >= MAX_RECEIPT_BATCHES_PER_CONSUMER) {
|
|
1948
|
-
return input.entries.map((entry) => failed(entry.receipt.reporting_receipt_id));
|
|
1949
|
-
}
|
|
1950
|
-
let remainingReceipts = MAX_RECEIPTS_PER_CONSUMER - Number(capacity.rows[0]?.receipts ?? 0);
|
|
1951
|
-
const duplicateIds = duplicates(input.entries.map((entry) => entry.receipt.reporting_receipt_id));
|
|
1952
|
-
const alreadyStored = await client.query(
|
|
1953
|
-
`SELECT reporting_receipt_id, semantic_fingerprint FROM adcp_reporting_receipts
|
|
1954
|
-
WHERE account_id = $1 AND consumer_id = $2 AND reporting_receipt_id = ANY($3::text[])`,
|
|
1955
|
-
[input.account_id, input.consumer_id, input.entries.map((entry) => entry.receipt.reporting_receipt_id)]
|
|
1956
|
-
);
|
|
1957
|
-
const storedFingerprints = new Map(
|
|
1958
|
-
alreadyStored.rows.map((row) => [row.reporting_receipt_id, row.semantic_fingerprint])
|
|
1959
|
-
);
|
|
1960
|
-
const buried = await client.query(
|
|
1961
|
-
`SELECT reporting_receipt_id, semantic_fingerprint FROM adcp_reporting_receipt_tombstones
|
|
1962
|
-
WHERE account_id = $1 AND consumer_id = $2 AND reporting_receipt_id = ANY($3::text[])`,
|
|
1963
|
-
[input.account_id, input.consumer_id, input.entries.map((entry) => entry.receipt.reporting_receipt_id)]
|
|
1964
|
-
);
|
|
1965
|
-
for (const row of buried.rows) storedFingerprints.set(row.reporting_receipt_id, row.semantic_fingerprint);
|
|
1966
|
-
const resolvesToStored = input.entries.map(
|
|
1967
|
-
(entry) => storedFingerprints.get(entry.receipt.reporting_receipt_id) === digest(entry.receipt)
|
|
1968
|
-
);
|
|
1969
|
-
const duplicateSubjects = duplicates(
|
|
1970
|
-
input.entries.filter((_entry, index) => !resolvesToStored[index]).map((entry) => subjectKey(entry))
|
|
1971
|
-
);
|
|
1972
|
-
const results = [];
|
|
1973
|
-
for (const [index, entry] of input.entries.entries()) {
|
|
1974
|
-
if (duplicateIds.has(entry.receipt.reporting_receipt_id) || !resolvesToStored[index] && duplicateSubjects.has(subjectKey(entry))) {
|
|
1975
|
-
results.push(failed(entry.receipt.reporting_receipt_id));
|
|
1976
|
-
continue;
|
|
1977
|
-
}
|
|
1978
|
-
const result = await this.recordReceipt(client, input, entry, receivedAt, remainingReceipts);
|
|
1979
|
-
results.push(result);
|
|
1980
|
-
if (result.result === "recorded") remainingReceipts -= 1;
|
|
1981
|
-
}
|
|
1982
|
-
const stored = results.map((result, index) => ({
|
|
1983
|
-
kind: result.result,
|
|
1984
|
-
id: receiptResultId(result, input.entries[index]),
|
|
1985
|
-
entry: input.entries[index].kind,
|
|
1986
|
-
...result.result === "failed" ? { errorCode: "INVALID_REQUEST" } : {}
|
|
1987
|
-
}));
|
|
1988
|
-
const storedJson = JSON.stringify(stored);
|
|
1989
|
-
if (Buffer.byteLength(storedJson, "utf8") > MAX_RECEIPT_BATCH_RESULT_BYTES) {
|
|
1990
|
-
throw new Error("Reporting receipt batch replay record exceeds its byte budget");
|
|
1991
|
-
}
|
|
1992
|
-
await client.query(
|
|
1993
|
-
`INSERT INTO adcp_reporting_receipt_batches
|
|
1994
|
-
(account_id, consumer_id, idempotency_key, request_fingerprint, results)
|
|
1995
|
-
VALUES ($1,$2,$3,$4,$5::jsonb)`,
|
|
1996
|
-
[input.account_id, input.consumer_id, input.idempotency_key, input.request_fingerprint, storedJson]
|
|
1997
|
-
);
|
|
1998
|
-
return results;
|
|
1999
|
-
});
|
|
2000
|
-
}
|
|
2001
|
-
/**
|
|
2002
|
-
* Rebuilds a prior batch response from the compact replay record plus the
|
|
2003
|
-
* append-only receipt rows, re-checking authorization exactly as the first
|
|
2004
|
-
* call did so a revoked destination cannot be replayed back into disclosure.
|
|
2005
|
-
*/
|
|
2006
|
-
/**
|
|
2007
|
-
* Replays a prior batch response exactly.
|
|
2008
|
-
*
|
|
2009
|
-
* An exact same-key replay is side-effect free and returns the caller its own
|
|
2010
|
-
* previously recorded verdict, so later revocation of the destination does
|
|
2011
|
-
* not change it. Downgrading a replay to `failed` once authorization ended
|
|
2012
|
-
* broke the protocol-wide rule that a replayed idempotency key returns the
|
|
2013
|
-
* same response, and bought nothing: the body is the caller's own receipt
|
|
2014
|
-
* echoed back with the verdict it already received, so a revoked caller
|
|
2015
|
-
* learns nothing it did not already hold. Fail-closed still governs every
|
|
2016
|
-
* path that *accepts* new evidence — `loadReceiptEvidence` joins
|
|
2017
|
-
* `revoked_at IS NULL` — and nothing here reads another consumer's rows.
|
|
2018
|
-
*/
|
|
2019
|
-
async replayReceiptBatch(client, input, stored) {
|
|
2020
|
-
const replay = [];
|
|
2021
|
-
for (const [index, entry] of input.entries.entries()) {
|
|
2022
|
-
const result = stored[index];
|
|
2023
|
-
const legacy = legacyReceiptBatchResult(result);
|
|
2024
|
-
if (legacy) {
|
|
2025
|
-
replay.push(structuredClone(legacy));
|
|
2026
|
-
continue;
|
|
2027
|
-
}
|
|
2028
|
-
if (!result || result.kind === "failed") {
|
|
2029
|
-
replay.push(failed(entry.receipt.reporting_receipt_id));
|
|
2030
|
-
continue;
|
|
2031
|
-
}
|
|
2032
|
-
const row = await client.query(
|
|
2033
|
-
`SELECT data FROM adcp_reporting_receipts
|
|
2034
|
-
WHERE account_id = $1 AND consumer_id = $2 AND reporting_receipt_id = $3`,
|
|
2035
|
-
[input.account_id, input.consumer_id, result.id]
|
|
2036
|
-
);
|
|
2037
|
-
let data = row.rows[0]?.data;
|
|
2038
|
-
if (!data) {
|
|
2039
|
-
const tombstone = await client.query(
|
|
2040
|
-
`SELECT ${rfc3339Micro("COALESCE(subject_recorded_at, pruned_at)")} AS recorded_at, semantic_fingerprint
|
|
2041
|
-
FROM adcp_reporting_receipt_tombstones
|
|
2042
|
-
WHERE account_id = $1 AND consumer_id = $2 AND reporting_receipt_id = $3`,
|
|
2043
|
-
[input.account_id, input.consumer_id, result.id]
|
|
2044
|
-
);
|
|
2045
|
-
const buried = tombstone.rows[0];
|
|
2046
|
-
if (buried && result.id === entry.receipt.reporting_receipt_id && buried.semantic_fingerprint === digest(entry.receipt)) {
|
|
2047
|
-
data = { ...entry.receipt, received_at: buried.recorded_at };
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
if (!data) {
|
|
2051
|
-
replay.push(failed(entry.receipt.reporting_receipt_id));
|
|
2052
|
-
continue;
|
|
2053
|
-
}
|
|
2054
|
-
replay.push(
|
|
2055
|
-
result.kind === "recorded" ? recorded(result.entry, structuredClone(data)) : unchanged(result.entry, structuredClone(data))
|
|
2056
|
-
);
|
|
2057
|
-
}
|
|
2058
|
-
return replay;
|
|
2059
|
-
}
|
|
2060
|
-
async recordReceipt(client, batch, entry, receivedAt, remainingReceipts) {
|
|
2061
|
-
const fingerprint = digest(entry.receipt);
|
|
2062
|
-
const existing = await client.query(
|
|
2063
|
-
`SELECT semantic_fingerprint, data FROM adcp_reporting_receipts
|
|
2064
|
-
WHERE account_id = $1 AND consumer_id = $2 AND reporting_receipt_id = $3`,
|
|
2065
|
-
[batch.account_id, batch.consumer_id, entry.receipt.reporting_receipt_id]
|
|
2066
|
-
);
|
|
2067
|
-
if (existing.rows[0]) {
|
|
2068
|
-
if (existing.rows[0].semantic_fingerprint !== fingerprint) return failed(entry.receipt.reporting_receipt_id);
|
|
2069
|
-
return unchanged(entry.kind, existing.rows[0].data);
|
|
2070
|
-
}
|
|
2071
|
-
const tombstones = await client.query(
|
|
2072
|
-
`SELECT reporting_receipt_id, subject_id, status, was_current, semantic_fingerprint,
|
|
2073
|
-
${rfc3339Micro("COALESCE(subject_recorded_at, pruned_at)")} AS recorded_at
|
|
2074
|
-
FROM adcp_reporting_receipt_tombstones
|
|
2075
|
-
WHERE account_id = $1 AND consumer_id = $2
|
|
2076
|
-
AND (reporting_receipt_id = $3 OR (receipt_kind = $4 AND subject_id = $5))
|
|
2077
|
-
-- Newest leaf first. The demotion above keeps at most one current
|
|
2078
|
-
-- tombstone per subject, but rows written before it existed, and any
|
|
2079
|
-
-- future shape that tombstones two leaves at once, must still resolve
|
|
2080
|
-
-- to one deterministic answer rather than to whatever the planner
|
|
2081
|
-
-- emitted first.
|
|
2082
|
-
ORDER BY was_current DESC, pruned_at DESC, reporting_receipt_id DESC`,
|
|
2083
|
-
[batch.account_id, batch.consumer_id, entry.receipt.reporting_receipt_id, entry.kind, subjectIdFor(entry)]
|
|
2084
|
-
);
|
|
2085
|
-
const ownTombstone = tombstones.rows.find(
|
|
2086
|
-
(tombstone) => tombstone.reporting_receipt_id === entry.receipt.reporting_receipt_id
|
|
2087
|
-
);
|
|
2088
|
-
if (ownTombstone) {
|
|
2089
|
-
if (ownTombstone.semantic_fingerprint !== fingerprint) return failed(entry.receipt.reporting_receipt_id);
|
|
2090
|
-
return unchanged(entry.kind, { ...entry.receipt, received_at: ownTombstone.recorded_at });
|
|
2091
|
-
}
|
|
2092
|
-
for (const tombstone of tombstones.rows) {
|
|
2093
|
-
if (tombstone.status === "accepted" && tombstone.was_current) {
|
|
2094
|
-
return failed(entry.receipt.reporting_receipt_id);
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
const tombstonedLeafId = tombstones.rows.find(
|
|
2098
|
-
(tombstone) => tombstone.was_current && tombstone.subject_id === subjectIdFor(entry) && tombstone.reporting_receipt_id !== entry.receipt.reporting_receipt_id
|
|
2099
|
-
)?.reporting_receipt_id;
|
|
2100
|
-
const evidence = await this.loadReceiptEvidence(client, batch.account_id, entry);
|
|
2101
|
-
if (!evidence) return failed(entry.receipt.reporting_receipt_id);
|
|
2102
|
-
const matches = entry.kind === "revision" ? receiptEvidenceMatches(entry.receipt, evidence.materialization) : adjustmentReceiptEvidenceMatches(entry.receipt, evidence.adjustment);
|
|
2103
|
-
const wellFormed = entry.receipt.status === "accepted" ? matches : Boolean(entry.receipt.rejection_codes?.length);
|
|
2104
|
-
if (!wellFormed) {
|
|
2105
|
-
return failed(entry.receipt.reporting_receipt_id);
|
|
2106
|
-
}
|
|
2107
|
-
const current = await client.query(
|
|
2108
|
-
`SELECT reporting_receipt_id, data FROM adcp_reporting_receipts
|
|
2109
|
-
WHERE account_id = $1 AND consumer_id = $2 AND receipt_kind = $3 AND subject_id = $4 AND is_current`,
|
|
2110
|
-
[batch.account_id, batch.consumer_id, entry.kind, evidence.subjectId]
|
|
2111
|
-
);
|
|
2112
|
-
const leaf = current.rows[0];
|
|
2113
|
-
const supersedes = entry.receipt.supersedes_reporting_receipt_id;
|
|
2114
|
-
if (leaf && (leaf.data.status === "accepted" || supersedes !== leaf.reporting_receipt_id) || // The pruned leaf answers for the chain exactly as a live one would:
|
|
2115
|
-
// only its own successor may extend the subject. Re-presenting the
|
|
2116
|
-
// tombstoned leaf itself is not a succession and is handled above, so
|
|
2117
|
-
// it never reaches here.
|
|
2118
|
-
!leaf && tombstonedLeafId !== void 0 && supersedes !== tombstonedLeafId || !leaf && tombstonedLeafId === void 0 && supersedes !== void 0) {
|
|
2119
|
-
return failed(entry.receipt.reporting_receipt_id);
|
|
2120
|
-
}
|
|
2121
|
-
if (remainingReceipts <= 0) return failed(entry.receipt.reporting_receipt_id);
|
|
2122
|
-
if (leaf) {
|
|
2123
|
-
await client.query(
|
|
2124
|
-
`UPDATE adcp_reporting_receipts SET is_current = false
|
|
2125
|
-
WHERE account_id = $1 AND consumer_id = $2 AND reporting_receipt_id = $3 AND is_current`,
|
|
2126
|
-
[batch.account_id, batch.consumer_id, leaf.reporting_receipt_id]
|
|
2127
|
-
);
|
|
2128
|
-
}
|
|
2129
|
-
const stored = { ...entry.receipt, received_at: receivedAt };
|
|
2130
|
-
await client.query(
|
|
2131
|
-
`INSERT INTO adcp_reporting_receipts
|
|
2132
|
-
(account_id, consumer_id, reporting_receipt_id, receipt_kind, subject_id,
|
|
2133
|
-
supersedes_receipt_id, is_current, semantic_fingerprint, data, received_at, recorded_at)
|
|
2134
|
-
VALUES ($1,$2,$3,$4,$5,$6,true,$7,$8::jsonb,$9::timestamptz,$9::timestamptz)`,
|
|
2135
|
-
[
|
|
2136
|
-
batch.account_id,
|
|
2137
|
-
batch.consumer_id,
|
|
2138
|
-
entry.receipt.reporting_receipt_id,
|
|
2139
|
-
entry.kind,
|
|
2140
|
-
evidence.subjectId,
|
|
2141
|
-
supersedes ?? null,
|
|
2142
|
-
fingerprint,
|
|
2143
|
-
JSON.stringify(stored),
|
|
2144
|
-
receivedAt
|
|
2145
|
-
]
|
|
2146
|
-
);
|
|
2147
|
-
return recorded(entry.kind, stored);
|
|
2148
|
-
}
|
|
2149
|
-
async loadReceiptEvidence(client, accountId, entry) {
|
|
2150
|
-
if (entry.kind === "revision") {
|
|
2151
|
-
const receipt2 = entry.receipt;
|
|
2152
|
-
const result2 = await client.query(
|
|
2153
|
-
`SELECT materialization.data AS materialization
|
|
2154
|
-
FROM adcp_reporting_materializations materialization
|
|
2155
|
-
JOIN adcp_reporting_managed_bindings binding ON binding.configuration_id = materialization.configuration_id
|
|
2156
|
-
JOIN adcp_reporting_destination_authorizations authz
|
|
2157
|
-
ON authz.account_id = materialization.account_id
|
|
2158
|
-
AND authz.destination_ref = materialization.destination_ref
|
|
2159
|
-
AND authz.generation = materialization.authorization_generation
|
|
2160
|
-
AND authz.revoked_at IS NULL
|
|
2161
|
-
JOIN adcp_reporting_revisions revision ON revision.revision_id = materialization.revision_id
|
|
2162
|
-
JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = materialization.obligation_id
|
|
2163
|
-
WHERE materialization.account_id = $1 AND materialization.materialization_id = $2
|
|
2164
|
-
AND materialization.revision_id = $3 AND materialization.obligation_id = $4
|
|
2165
|
-
AND materialization.status IN ('available','delivered')
|
|
2166
|
-
-- RC3 ties the receiptable revision to the obligation's own
|
|
2167
|
-
-- required_finality, exactly as the read projection does when it
|
|
2168
|
-
-- picks the required revision. Hard-coding 'official' made every
|
|
2169
|
-
-- snapshot-finality consumer_receipt configuration unreconcilable:
|
|
2170
|
-
-- the projection asked for a receipt the store always refused.
|
|
2171
|
-
AND (obligation.data->>'requiredFinality' <> 'official' OR revision.finality = 'official')
|
|
2172
|
-
AND binding.data->>'reconciliation_mode' = 'consumer_receipt'`,
|
|
2173
|
-
[
|
|
2174
|
-
accountId,
|
|
2175
|
-
receipt2.reporting_materialization_id,
|
|
2176
|
-
receipt2.reporting_revision_id,
|
|
2177
|
-
receipt2.reporting_obligation_id
|
|
2178
|
-
]
|
|
2179
|
-
);
|
|
2180
|
-
return result2.rows[0] ? { subjectId: receipt2.reporting_revision_id, materialization: result2.rows[0].materialization } : null;
|
|
2181
|
-
}
|
|
2182
|
-
const receipt = entry.receipt;
|
|
2183
|
-
const result = await client.query(
|
|
2184
|
-
`SELECT adjustment.data AS adjustment
|
|
2185
|
-
FROM adcp_reporting_adjustments adjustment
|
|
2186
|
-
JOIN adcp_reporting_revisions revision ON revision.revision_id = adjustment.adjusts_revision_id
|
|
2187
|
-
JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = adjustment.obligation_id
|
|
2188
|
-
JOIN adcp_reporting_managed_bindings binding ON binding.configuration_id = obligation.configuration_id
|
|
2189
|
-
JOIN adcp_reporting_destination_authorizations authz
|
|
2190
|
-
ON authz.account_id = binding.account_id AND authz.destination_ref = binding.destination_ref
|
|
2191
|
-
AND authz.generation = binding.authorization_generation AND authz.revoked_at IS NULL
|
|
2192
|
-
WHERE binding.account_id = $1 AND adjustment.adjustment_id = $2
|
|
2193
|
-
AND adjustment.adjusts_revision_id = $3
|
|
2194
|
-
-- Unlike a revision receipt this arm stays pinned to 'official': an
|
|
2195
|
-
-- adjustment only ever corrects an already-official revision, which
|
|
2196
|
-
-- commitAdjustment enforces at write time.
|
|
2197
|
-
AND revision.finality = 'official'
|
|
2198
|
-
AND binding.data->>'reconciliation_mode' = 'consumer_receipt'`,
|
|
2199
|
-
[accountId, receipt.reporting_adjustment_id, receipt.adjusts_reporting_revision_id]
|
|
2200
|
-
);
|
|
2201
|
-
return result.rows[0] ? { subjectId: receipt.reporting_adjustment_id, adjustment: result.rows[0].adjustment } : null;
|
|
2202
|
-
}
|
|
2203
|
-
async transaction(body, signal) {
|
|
2204
|
-
signal?.throwIfAborted();
|
|
2205
|
-
let client;
|
|
2206
|
-
try {
|
|
2207
|
-
client = await this.pool.connect();
|
|
2208
|
-
} catch (cause) {
|
|
2209
|
-
throw new Error("PostgresReportingManagedDeliveryStore database connection failed", { cause });
|
|
2210
|
-
}
|
|
2211
|
-
let releaseError;
|
|
2212
|
-
let transactionStarted = false;
|
|
2213
|
-
try {
|
|
2214
|
-
signal?.throwIfAborted();
|
|
2215
|
-
await client.query("BEGIN");
|
|
2216
|
-
transactionStarted = true;
|
|
2217
|
-
if (signal) await client.query("SET LOCAL lock_timeout = '5s'; SET LOCAL statement_timeout = '10s'");
|
|
2218
|
-
const guarded = signal ? {
|
|
2219
|
-
query: async (sql, values) => {
|
|
2220
|
-
signal.throwIfAborted();
|
|
2221
|
-
const result = await client.query(sql, values);
|
|
2222
|
-
signal.throwIfAborted();
|
|
2223
|
-
return result;
|
|
2224
|
-
},
|
|
2225
|
-
release: () => {
|
|
2226
|
-
throw new Error("Transaction body cannot release connection");
|
|
2227
|
-
}
|
|
2228
|
-
} : client;
|
|
2229
|
-
const value = await body(guarded);
|
|
2230
|
-
signal?.throwIfAborted();
|
|
2231
|
-
await client.query("COMMIT");
|
|
2232
|
-
return value;
|
|
2233
|
-
} catch (cause) {
|
|
2234
|
-
if (transactionStarted) {
|
|
2235
|
-
try {
|
|
2236
|
-
await client.query("ROLLBACK");
|
|
2237
|
-
} catch (rollbackCause) {
|
|
2238
|
-
releaseError = rollbackCause instanceof Error ? rollbackCause : new Error("Managed reporting rollback failed");
|
|
2239
|
-
}
|
|
2240
|
-
}
|
|
2241
|
-
throw new Error("PostgresReportingManagedDeliveryStore transaction failed", { cause });
|
|
2242
|
-
} finally {
|
|
2243
|
-
client.release(releaseError);
|
|
2244
|
-
}
|
|
2245
|
-
}
|
|
2246
|
-
async query(sql, values) {
|
|
2247
|
-
try {
|
|
2248
|
-
return await this.pool.query(sql, values);
|
|
2249
|
-
} catch (cause) {
|
|
2250
|
-
throw new Error("PostgresReportingManagedDeliveryStore database operation failed", { cause });
|
|
2251
|
-
}
|
|
2252
|
-
}
|
|
2253
|
-
}
|
|
2254
|
-
function recorded(kind, data) {
|
|
2255
|
-
return kind === "revision" ? { result: "recorded", receipt: data } : { result: "recorded", adjustment_receipt: data };
|
|
2256
|
-
}
|
|
2257
|
-
function unchanged(kind, data) {
|
|
2258
|
-
return kind === "revision" ? { result: "unchanged", receipt: data } : { result: "unchanged", adjustment_receipt: data };
|
|
2259
|
-
}
|
|
2260
|
-
function failed(reporting_receipt_id) {
|
|
2261
|
-
return {
|
|
2262
|
-
result: "failed",
|
|
2263
|
-
reporting_receipt_id,
|
|
2264
|
-
errors: [{ code: "INVALID_REQUEST", message: GENERIC_RECEIPT_MESSAGE, recovery: "correctable" }]
|
|
2265
|
-
};
|
|
2266
|
-
}
|
|
2267
|
-
function idempotencyConflict(reporting_receipt_id) {
|
|
2268
|
-
return {
|
|
2269
|
-
result: "failed",
|
|
2270
|
-
reporting_receipt_id,
|
|
2271
|
-
errors: [
|
|
2272
|
-
{
|
|
2273
|
-
code: "IDEMPOTENCY_CONFLICT",
|
|
2274
|
-
message: "Idempotency key was reused with different receipt content",
|
|
2275
|
-
recovery: "correctable"
|
|
2276
|
-
}
|
|
2277
|
-
]
|
|
2278
|
-
};
|
|
2279
|
-
}
|
|
2280
|
-
function receiptResultId(result, entry) {
|
|
2281
|
-
if (result.result === "failed") return result.reporting_receipt_id;
|
|
2282
|
-
const body = "receipt" in result ? result.receipt : result.adjustment_receipt;
|
|
2283
|
-
return body?.reporting_receipt_id ?? entry.receipt.reporting_receipt_id;
|
|
2284
|
-
}
|
|
2285
|
-
function legacyReceiptBatchResult(value) {
|
|
2286
|
-
if (!value || typeof value !== "object" || !("result" in value)) return void 0;
|
|
2287
|
-
return value;
|
|
2288
|
-
}
|
|
2289
|
-
function subjectIdFor(entry) {
|
|
2290
|
-
return entry.kind === "revision" ? entry.receipt.reporting_revision_id : entry.receipt.reporting_adjustment_id;
|
|
2291
|
-
}
|
|
2292
|
-
function subjectKey(entry) {
|
|
2293
|
-
return entry.kind === "revision" ? `revision:${entry.receipt.reporting_revision_id}` : `adjustment:${entry.receipt.reporting_adjustment_id}`;
|
|
2294
|
-
}
|
|
2295
|
-
function duplicates(values) {
|
|
2296
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2297
|
-
const duplicate = /* @__PURE__ */ new Set();
|
|
2298
|
-
for (const value of values) (seen.has(value) ? duplicate : seen).add(value);
|
|
2299
|
-
return duplicate;
|
|
2300
|
-
}
|
|
2301
|
-
function digest(value) {
|
|
2302
|
-
return createHash("sha256").update(canonicalize(value)).digest("hex");
|
|
2303
|
-
}
|
|
2304
|
-
function managedBindingFingerprint(binding) {
|
|
2305
|
-
const { created_at: _createdAt, semantic_fingerprint: _fingerprint, ...semantic } = binding;
|
|
2306
|
-
return digest(semantic);
|
|
2307
|
-
}
|
|
2308
|
-
function nonnegativeSafeInteger(value, name) {
|
|
2309
|
-
if (!Number.isSafeInteger(value) || value < 0) throw new RangeError(`${name} must be a non-negative safe integer`);
|
|
2310
|
-
}
|
|
2311
|
-
function positiveInteger(value, name) {
|
|
2312
|
-
if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
|
|
2313
|
-
}
|
|
2314
|
-
function policyNumber(value) {
|
|
2315
|
-
return value === null || value === void 0 ? null : Number(value);
|
|
2316
|
-
}
|
|
2317
|
-
async function advisoryLock(client, key) {
|
|
2318
|
-
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [key]);
|
|
2319
|
-
}
|
|
2320
|
-
function authLock(accountId, destinationRef) {
|
|
2321
|
-
return `adcp-reporting-auth:${accountId}:${destinationRef}`;
|
|
2322
|
-
}
|
|
2323
|
-
function rfc3339Micro(expression) {
|
|
2324
|
-
return `to_char(${expression} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
|
|
2325
|
-
}
|
|
2326
|
-
function accountLock(accountId) {
|
|
2327
|
-
return `adcp-reporting-account:${accountId}`;
|
|
2328
|
-
}
|
|
2329
|
-
export {
|
|
2330
|
-
PostgresReportingManagedDeliveryStore,
|
|
2331
|
-
REPORTING_MANAGED_DELIVERY_MIGRATION
|
|
2332
|
-
};
|