@bluefields/db 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/client.d.ts +6 -0
- package/dist/client.js +13 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/migrate.d.ts +11 -0
- package/dist/migrate.js +48 -0
- package/dist/migrate.js.map +1 -0
- package/dist/schema/api-keys.d.ts +145 -0
- package/dist/schema/api-keys.js +18 -0
- package/dist/schema/api-keys.js.map +1 -0
- package/dist/schema/audit-log.d.ts +228 -0
- package/dist/schema/audit-log.js +49 -0
- package/dist/schema/audit-log.js.map +1 -0
- package/dist/schema/cost-records.d.ts +190 -0
- package/dist/schema/cost-records.js +55 -0
- package/dist/schema/cost-records.js.map +1 -0
- package/dist/schema/crawl-job-pages.d.ts +186 -0
- package/dist/schema/crawl-job-pages.js +31 -0
- package/dist/schema/crawl-job-pages.js.map +1 -0
- package/dist/schema/crawl-jobs.d.ts +271 -0
- package/dist/schema/crawl-jobs.js +40 -0
- package/dist/schema/crawl-jobs.js.map +1 -0
- package/dist/schema/customers.d.ts +145 -0
- package/dist/schema/customers.js +19 -0
- package/dist/schema/customers.js.map +1 -0
- package/dist/schema/event-feedback.d.ts +170 -0
- package/dist/schema/event-feedback.js +35 -0
- package/dist/schema/event-feedback.js.map +1 -0
- package/dist/schema/events.d.ts +291 -0
- package/dist/schema/events.js +58 -0
- package/dist/schema/events.js.map +1 -0
- package/dist/schema/extractions.d.ts +327 -0
- package/dist/schema/extractions.js +56 -0
- package/dist/schema/extractions.js.map +1 -0
- package/dist/schema/host-extractors.d.ts +131 -0
- package/dist/schema/host-extractors.js +18 -0
- package/dist/schema/host-extractors.js.map +1 -0
- package/dist/schema/host-records.d.ts +237 -0
- package/dist/schema/host-records.js +42 -0
- package/dist/schema/host-records.js.map +1 -0
- package/dist/schema/notification-preferences.d.ts +195 -0
- package/dist/schema/notification-preferences.js +49 -0
- package/dist/schema/notification-preferences.js.map +1 -0
- package/dist/schema/scrape-cache.d.ts +136 -0
- package/dist/schema/scrape-cache.js +23 -0
- package/dist/schema/scrape-cache.js.map +1 -0
- package/dist/schema/sessions.d.ts +153 -0
- package/dist/schema/sessions.js +27 -0
- package/dist/schema/sessions.js.map +1 -0
- package/dist/schema/snapshots.d.ts +382 -0
- package/dist/schema/snapshots.js +60 -0
- package/dist/schema/snapshots.js.map +1 -0
- package/dist/schema/subscriptions.d.ts +604 -0
- package/dist/schema/subscriptions.js +79 -0
- package/dist/schema/subscriptions.js.map +1 -0
- package/dist/schema/usage-periods.d.ts +446 -0
- package/dist/schema/usage-periods.js +71 -0
- package/dist/schema/usage-periods.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +54 -0
- package/src/migrations/0001_initial.sql +398 -0
- package/src/migrations/0002_pg_cron.sql +109 -0
- package/src/migrations/0003_webhook_secret_cleanup.sql +55 -0
- package/src/migrations/0004_events_pg_notify.sql +39 -0
- package/src/migrations/0005_subscription_consecutive_failures.sql +19 -0
- package/src/migrations/0006_notification_preferences.sql +27 -0
- package/src/migrations/0007_crawl_jobs.sql +65 -0
- package/src/migrations/0008_sessions.sql +38 -0
- package/src/migrations/0009_scrape_cache.sql +34 -0
- package/src/migrations/0010_neon_defaults.sql +99 -0
- package/src/migrations/0011_credits_consumed_numeric.sql +12 -0
- package/src/migrations/0012_crawl_page_attestation.sql +10 -0
- package/src/migrations/0013_customer_deleted_at.sql +5 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
-- BlueFields v1 initial schema.
|
|
2
|
+
--
|
|
3
|
+
-- Source: docs/03_implementation_plan_detailed.md "The data model" section
|
|
4
|
+
-- (lines ~100-480) + the `host_records` block at line 1271. Per-column
|
|
5
|
+
-- decisions are traced to Reviews #2-#11 in docs/05_decision_log.md.
|
|
6
|
+
--
|
|
7
|
+
-- SPEC DEVIATIONS (intentional, required for migration to actually run):
|
|
8
|
+
-- - `snapshots` and `events` use composite primary keys (id, fetched_at)
|
|
9
|
+
-- and (id, created_at). The spec wrote `id UUID PRIMARY KEY` for these,
|
|
10
|
+
-- but Postgres requires the partition column in every unique constraint
|
|
11
|
+
-- on a partitioned table. The composite-PK pattern matches what
|
|
12
|
+
-- `cost_records` and `audit_log` already use in the spec.
|
|
13
|
+
-- - FOREIGN KEY constraints from non-partitioned tables to
|
|
14
|
+
-- `snapshots(id)` and `events(id)` are removed: a DB-level FK to a
|
|
15
|
+
-- partitioned table's `id` alone is not expressible. Affected:
|
|
16
|
+
-- * `extractions.snapshot_id` — column kept, FK dropped
|
|
17
|
+
-- * `event_feedback.event_id` — column kept, FK dropped
|
|
18
|
+
-- Referential integrity is enforced by application code only.
|
|
19
|
+
|
|
20
|
+
-- ============================================================================
|
|
21
|
+
-- Extensions
|
|
22
|
+
-- ============================================================================
|
|
23
|
+
|
|
24
|
+
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
25
|
+
|
|
26
|
+
-- pg_partman + pg_cron are OPTIONAL. They give us automatic partition
|
|
27
|
+
-- pre-creation and hourly maintenance on a self-managed Postgres. Managed
|
|
28
|
+
-- Postgres providers (Neon, etc.) often don't ship them. We wrap the
|
|
29
|
+
-- extension creation and all calls into DO blocks that warn-and-skip on
|
|
30
|
+
-- absence — partitioned tables themselves still work, you just have to
|
|
31
|
+
-- manage partitions externally (e.g. a cron job that calls partman from a
|
|
32
|
+
-- self-managed Postgres, or pre-create partitions manually).
|
|
33
|
+
DO $$
|
|
34
|
+
BEGIN
|
|
35
|
+
EXECUTE 'CREATE EXTENSION IF NOT EXISTS pg_partman';
|
|
36
|
+
EXCEPTION WHEN OTHERS THEN
|
|
37
|
+
RAISE NOTICE 'pg_partman not available on this Postgres — partition pre-creation skipped';
|
|
38
|
+
END $$;
|
|
39
|
+
|
|
40
|
+
DO $$
|
|
41
|
+
BEGIN
|
|
42
|
+
EXECUTE 'CREATE EXTENSION IF NOT EXISTS pg_cron';
|
|
43
|
+
EXCEPTION WHEN OTHERS THEN
|
|
44
|
+
RAISE NOTICE 'pg_cron not available on this Postgres — partman maintenance must be scheduled externally';
|
|
45
|
+
END $$;
|
|
46
|
+
|
|
47
|
+
-- ============================================================================
|
|
48
|
+
-- Tables
|
|
49
|
+
-- ============================================================================
|
|
50
|
+
|
|
51
|
+
-- Customers and auth ---------------------------------------------------------
|
|
52
|
+
CREATE TABLE customers (
|
|
53
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
54
|
+
email TEXT UNIQUE NOT NULL,
|
|
55
|
+
org_name TEXT,
|
|
56
|
+
plan TEXT NOT NULL DEFAULT 'free',
|
|
57
|
+
stripe_customer_id TEXT,
|
|
58
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
59
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE api_keys (
|
|
63
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
64
|
+
customer_id UUID NOT NULL REFERENCES customers(id),
|
|
65
|
+
key_hash TEXT NOT NULL UNIQUE,
|
|
66
|
+
key_prefix TEXT NOT NULL,
|
|
67
|
+
name TEXT,
|
|
68
|
+
last_used_at TIMESTAMPTZ,
|
|
69
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
70
|
+
revoked_at TIMESTAMPTZ
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
-- Subscriptions (watch mode) -------------------------------------------------
|
|
74
|
+
CREATE TABLE subscriptions (
|
|
75
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
76
|
+
customer_id UUID NOT NULL REFERENCES customers(id),
|
|
77
|
+
url TEXT NOT NULL,
|
|
78
|
+
url_canonical TEXT NOT NULL,
|
|
79
|
+
canon_version SMALLINT NOT NULL DEFAULT 1,
|
|
80
|
+
schema JSONB,
|
|
81
|
+
webhook_url TEXT,
|
|
82
|
+
-- Dual-key webhook secret scheme (Review #7)
|
|
83
|
+
webhook_secret_current TEXT,
|
|
84
|
+
webhook_secret_previous TEXT,
|
|
85
|
+
webhook_secret_rotated_at TIMESTAMPTZ,
|
|
86
|
+
webhook_url_state TEXT NOT NULL DEFAULT 'unverified',
|
|
87
|
+
webhook_consecutive_failures INT NOT NULL DEFAULT 0,
|
|
88
|
+
min_interval_seconds INT NOT NULL DEFAULT 3600,
|
|
89
|
+
max_interval_seconds INT NOT NULL DEFAULT 86400,
|
|
90
|
+
current_interval_seconds INT NOT NULL DEFAULT 3600,
|
|
91
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
92
|
+
next_poll_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
93
|
+
inngest_event_id TEXT,
|
|
94
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
95
|
+
last_polled_at TIMESTAMPTZ,
|
|
96
|
+
last_change_at TIMESTAMPTZ,
|
|
97
|
+
poll_count INT NOT NULL DEFAULT 0,
|
|
98
|
+
change_count INT NOT NULL DEFAULT 0,
|
|
99
|
+
intent TEXT,
|
|
100
|
+
diff_rules JSONB NOT NULL DEFAULT '{"rules":[]}'::jsonb,
|
|
101
|
+
last_meaningful_extraction_id UUID,
|
|
102
|
+
-- Adaptive polling (Review #9)
|
|
103
|
+
detection_latency_target_seconds INT,
|
|
104
|
+
vertical_hint TEXT,
|
|
105
|
+
ewma_change_interval_seconds INT,
|
|
106
|
+
last_change_observed_at TIMESTAMPTZ,
|
|
107
|
+
consecutive_no_change_polls INT NOT NULL DEFAULT 0,
|
|
108
|
+
average_observed_interval_seconds INT,
|
|
109
|
+
http_validator JSONB,
|
|
110
|
+
cyclic_pattern_hint JSONB
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
CREATE INDEX idx_subs_customer ON subscriptions(customer_id);
|
|
114
|
+
CREATE INDEX idx_subs_canonical ON subscriptions(url_canonical) WHERE status = 'active';
|
|
115
|
+
CREATE INDEX idx_subs_status ON subscriptions(status) WHERE status != 'expired';
|
|
116
|
+
-- Critical: pg_cron dispatcher hits this every minute (Review #1)
|
|
117
|
+
CREATE INDEX idx_subs_due ON subscriptions(next_poll_at) WHERE status = 'active';
|
|
118
|
+
|
|
119
|
+
-- Snapshots (immutable history) — partitioned by month on fetched_at ---------
|
|
120
|
+
CREATE TABLE snapshots (
|
|
121
|
+
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
122
|
+
url_canonical TEXT NOT NULL,
|
|
123
|
+
url_surt TEXT NOT NULL,
|
|
124
|
+
canon_version SMALLINT NOT NULL,
|
|
125
|
+
content_hash TEXT NOT NULL,
|
|
126
|
+
fetched_at TIMESTAMPTZ NOT NULL,
|
|
127
|
+
fetcher_id TEXT NOT NULL,
|
|
128
|
+
proxy_egress_ip INET NOT NULL,
|
|
129
|
+
user_agent TEXT NOT NULL,
|
|
130
|
+
response_status INT NOT NULL,
|
|
131
|
+
response_headers JSONB NOT NULL,
|
|
132
|
+
raw_html_r2_key TEXT NOT NULL,
|
|
133
|
+
raw_html_size_bytes BIGINT NOT NULL,
|
|
134
|
+
fetch_duration_ms INT NOT NULL,
|
|
135
|
+
attestation_signature TEXT NOT NULL,
|
|
136
|
+
attestation_key_id TEXT NOT NULL,
|
|
137
|
+
attestation_algorithm TEXT NOT NULL DEFAULT 'EdDSA',
|
|
138
|
+
manifest_version SMALLINT NOT NULL DEFAULT 1,
|
|
139
|
+
encryption_key_id TEXT,
|
|
140
|
+
cost_micro_usd INT NOT NULL,
|
|
141
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
142
|
+
-- Composite PK required for partitioned tables (see header note).
|
|
143
|
+
PRIMARY KEY (id, fetched_at)
|
|
144
|
+
) PARTITION BY RANGE (fetched_at);
|
|
145
|
+
|
|
146
|
+
CREATE INDEX idx_snap_url_time ON snapshots(url_canonical, fetched_at DESC);
|
|
147
|
+
CREATE INDEX idx_snap_surt_time ON snapshots(url_surt, fetched_at DESC);
|
|
148
|
+
CREATE INDEX idx_snap_hash ON snapshots(content_hash);
|
|
149
|
+
|
|
150
|
+
-- Host-specific extractor overrides (Review #6; v2-reserved, empty in v1) ---
|
|
151
|
+
CREATE TABLE host_extractors (
|
|
152
|
+
host TEXT PRIMARY KEY,
|
|
153
|
+
extractor_id TEXT NOT NULL,
|
|
154
|
+
enabled BOOLEAN NOT NULL DEFAULT true,
|
|
155
|
+
schema_compatibility TEXT[],
|
|
156
|
+
notes TEXT,
|
|
157
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
-- Extractions (interpreted layer) -------------------------------------------
|
|
161
|
+
CREATE TABLE extractions (
|
|
162
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
163
|
+
-- snapshot_id: column kept, FK dropped (snapshots is partitioned; see header).
|
|
164
|
+
snapshot_id UUID NOT NULL,
|
|
165
|
+
subscription_id UUID REFERENCES subscriptions(id),
|
|
166
|
+
customer_id UUID NOT NULL REFERENCES customers(id),
|
|
167
|
+
content_hash TEXT NOT NULL,
|
|
168
|
+
schema_hash TEXT,
|
|
169
|
+
extractor_version TEXT NOT NULL,
|
|
170
|
+
markdown TEXT,
|
|
171
|
+
structured_data JSONB,
|
|
172
|
+
extraction_status TEXT NOT NULL,
|
|
173
|
+
status_detail TEXT,
|
|
174
|
+
encryption_key_id TEXT,
|
|
175
|
+
llm_model TEXT,
|
|
176
|
+
llm_input_tokens INT,
|
|
177
|
+
llm_output_tokens INT,
|
|
178
|
+
cost_micro_usd INT NOT NULL,
|
|
179
|
+
duration_ms INT NOT NULL DEFAULT 0,
|
|
180
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
CREATE INDEX idx_ext_sub_time ON extractions(subscription_id, created_at DESC);
|
|
184
|
+
CREATE INDEX idx_ext_customer ON extractions(customer_id);
|
|
185
|
+
CREATE INDEX idx_ext_snap ON extractions(snapshot_id);
|
|
186
|
+
-- Cache lookup index: hot path. Cross-snapshot dedup keys on content (Review #6).
|
|
187
|
+
CREATE UNIQUE INDEX idx_ext_cache
|
|
188
|
+
ON extractions(content_hash, schema_hash, extractor_version);
|
|
189
|
+
|
|
190
|
+
-- Events — partitioned by month on created_at -------------------------------
|
|
191
|
+
CREATE TABLE events (
|
|
192
|
+
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
193
|
+
subscription_id UUID NOT NULL REFERENCES subscriptions(id),
|
|
194
|
+
customer_id UUID NOT NULL REFERENCES customers(id),
|
|
195
|
+
event_type TEXT NOT NULL,
|
|
196
|
+
previous_extraction_id UUID REFERENCES extractions(id),
|
|
197
|
+
current_extraction_id UUID REFERENCES extractions(id),
|
|
198
|
+
diff JSONB,
|
|
199
|
+
semantic_classification TEXT,
|
|
200
|
+
-- Delivery state machine (Review #7)
|
|
201
|
+
delivery_state TEXT NOT NULL DEFAULT 'pending',
|
|
202
|
+
delivery_attempts INT NOT NULL DEFAULT 0,
|
|
203
|
+
next_retry_at TIMESTAMPTZ,
|
|
204
|
+
last_attempt_at TIMESTAMPTZ,
|
|
205
|
+
last_response_status INT,
|
|
206
|
+
last_response_body_excerpt TEXT,
|
|
207
|
+
delivered_at TIMESTAMPTZ,
|
|
208
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
209
|
+
-- Composite PK required for partitioned tables.
|
|
210
|
+
PRIMARY KEY (id, created_at)
|
|
211
|
+
) PARTITION BY RANGE (created_at);
|
|
212
|
+
|
|
213
|
+
CREATE INDEX idx_events_sub_time ON events(subscription_id, created_at DESC);
|
|
214
|
+
CREATE INDEX idx_events_customer_time ON events(customer_id, created_at DESC);
|
|
215
|
+
CREATE INDEX idx_events_undelivered ON events(created_at) WHERE delivered_at IS NULL;
|
|
216
|
+
-- Retry-due index: hot path for delivery loop (Review #7)
|
|
217
|
+
CREATE INDEX idx_events_due_for_retry ON events(next_retry_at)
|
|
218
|
+
WHERE delivery_state = 'pending' AND next_retry_at IS NOT NULL;
|
|
219
|
+
CREATE INDEX idx_events_dead_lettered ON events(customer_id, created_at DESC)
|
|
220
|
+
WHERE delivery_state = 'dead_lettered';
|
|
221
|
+
|
|
222
|
+
-- Customer feedback on event classification (Review #5) ----------------------
|
|
223
|
+
CREATE TABLE event_feedback (
|
|
224
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
225
|
+
-- event_id: column kept, FK dropped (events is partitioned; see header).
|
|
226
|
+
event_id UUID,
|
|
227
|
+
subscription_id UUID NOT NULL REFERENCES subscriptions(id),
|
|
228
|
+
customer_id UUID NOT NULL REFERENCES customers(id),
|
|
229
|
+
verdict TEXT NOT NULL,
|
|
230
|
+
notes TEXT,
|
|
231
|
+
reviewed_by_us BOOLEAN NOT NULL DEFAULT false,
|
|
232
|
+
follow_up_action TEXT,
|
|
233
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
CREATE INDEX idx_feedback_customer ON event_feedback(customer_id);
|
|
237
|
+
CREATE INDEX idx_feedback_unreviewed ON event_feedback(created_at) WHERE reviewed_by_us = false;
|
|
238
|
+
|
|
239
|
+
-- Cost tracking — partitioned by month on recorded_at (Review #11) ----------
|
|
240
|
+
CREATE TABLE cost_records (
|
|
241
|
+
id BIGSERIAL,
|
|
242
|
+
customer_id UUID NOT NULL REFERENCES customers(id),
|
|
243
|
+
subscription_id UUID REFERENCES subscriptions(id),
|
|
244
|
+
url_canonical TEXT,
|
|
245
|
+
cost_type TEXT NOT NULL,
|
|
246
|
+
cost_micro_usd INT NOT NULL,
|
|
247
|
+
units NUMERIC,
|
|
248
|
+
cost_subtype TEXT,
|
|
249
|
+
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
250
|
+
PRIMARY KEY (id, recorded_at)
|
|
251
|
+
) PARTITION BY RANGE (recorded_at);
|
|
252
|
+
|
|
253
|
+
CREATE INDEX idx_cost_customer_time ON cost_records(customer_id, recorded_at);
|
|
254
|
+
CREATE INDEX idx_cost_url ON cost_records(url_canonical, recorded_at);
|
|
255
|
+
CREATE INDEX idx_cost_type_time ON cost_records(cost_type, recorded_at);
|
|
256
|
+
|
|
257
|
+
-- Unified audit log — partitioned by month on recorded_at (Review #11) ------
|
|
258
|
+
CREATE TABLE audit_log (
|
|
259
|
+
id BIGSERIAL,
|
|
260
|
+
customer_id UUID REFERENCES customers(id),
|
|
261
|
+
actor_type TEXT NOT NULL,
|
|
262
|
+
actor_id TEXT,
|
|
263
|
+
category TEXT NOT NULL,
|
|
264
|
+
action TEXT NOT NULL,
|
|
265
|
+
resource_type TEXT,
|
|
266
|
+
resource_id TEXT,
|
|
267
|
+
metadata JSONB,
|
|
268
|
+
ip_address INET,
|
|
269
|
+
user_agent TEXT,
|
|
270
|
+
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
271
|
+
PRIMARY KEY (id, recorded_at)
|
|
272
|
+
) PARTITION BY RANGE (recorded_at);
|
|
273
|
+
|
|
274
|
+
CREATE INDEX idx_audit_customer ON audit_log(customer_id, recorded_at DESC);
|
|
275
|
+
CREATE INDEX idx_audit_security ON audit_log(category, recorded_at DESC)
|
|
276
|
+
WHERE category IN ('security', 'auth');
|
|
277
|
+
CREATE INDEX idx_audit_actor ON audit_log(actor_type, actor_id, recorded_at DESC);
|
|
278
|
+
|
|
279
|
+
-- Per-customer per-billing-period usage counters (Reviews #8, #10) ----------
|
|
280
|
+
CREATE TABLE usage_periods (
|
|
281
|
+
customer_id UUID NOT NULL REFERENCES customers(id),
|
|
282
|
+
period_start DATE NOT NULL,
|
|
283
|
+
period_end DATE NOT NULL,
|
|
284
|
+
plan TEXT NOT NULL,
|
|
285
|
+
-- Quotas (snapshot at period start)
|
|
286
|
+
events_quota INT NOT NULL,
|
|
287
|
+
scrapes_quota INT NOT NULL,
|
|
288
|
+
active_subs_quota INT NOT NULL,
|
|
289
|
+
overage_cap_events INT NOT NULL,
|
|
290
|
+
-- Counters
|
|
291
|
+
events_billable INT NOT NULL DEFAULT 0,
|
|
292
|
+
events_nonbillable INT NOT NULL DEFAULT 0,
|
|
293
|
+
scrapes_consumed INT NOT NULL DEFAULT 0,
|
|
294
|
+
active_subs_max INT NOT NULL DEFAULT 0,
|
|
295
|
+
-- MCP credits (Review #10)
|
|
296
|
+
credits_quota INT NOT NULL DEFAULT 0,
|
|
297
|
+
credits_consumed INT NOT NULL DEFAULT 0,
|
|
298
|
+
-- Alert state
|
|
299
|
+
quota_alert_50_sent BOOLEAN NOT NULL DEFAULT false,
|
|
300
|
+
quota_alert_75_sent BOOLEAN NOT NULL DEFAULT false,
|
|
301
|
+
quota_alert_90_sent BOOLEAN NOT NULL DEFAULT false,
|
|
302
|
+
quota_alert_100_sent BOOLEAN NOT NULL DEFAULT false,
|
|
303
|
+
credits_alert_50_sent BOOLEAN NOT NULL DEFAULT false,
|
|
304
|
+
credits_alert_75_sent BOOLEAN NOT NULL DEFAULT false,
|
|
305
|
+
credits_alert_90_sent BOOLEAN NOT NULL DEFAULT false,
|
|
306
|
+
credits_alert_100_sent BOOLEAN NOT NULL DEFAULT false,
|
|
307
|
+
polling_paused_at TIMESTAMPTZ,
|
|
308
|
+
mcp_paused_at TIMESTAMPTZ,
|
|
309
|
+
spend_cap_micro_usd BIGINT,
|
|
310
|
+
PRIMARY KEY (customer_id, period_start)
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
CREATE INDEX idx_usage_unalerted ON usage_periods(customer_id)
|
|
314
|
+
WHERE quota_alert_100_sent = false;
|
|
315
|
+
|
|
316
|
+
-- Per-host config (Review #4) -----------------------------------------------
|
|
317
|
+
CREATE TABLE host_records (
|
|
318
|
+
host TEXT PRIMARY KEY,
|
|
319
|
+
fetch_tier SMALLINT NOT NULL DEFAULT 1,
|
|
320
|
+
fetch_tier_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
321
|
+
preferred_proxy_provider TEXT,
|
|
322
|
+
max_concurrent_fetches SMALLINT NOT NULL DEFAULT 2,
|
|
323
|
+
observed_anti_bot TEXT[],
|
|
324
|
+
robots_status TEXT NOT NULL DEFAULT 'unknown',
|
|
325
|
+
takedown_status TEXT NOT NULL DEFAULT 'active',
|
|
326
|
+
country_hint CHAR(2),
|
|
327
|
+
notes TEXT,
|
|
328
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
329
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
CREATE INDEX idx_host_tier ON host_records(fetch_tier);
|
|
333
|
+
CREATE INDEX idx_host_takedown ON host_records(takedown_status) WHERE takedown_status != 'active';
|
|
334
|
+
|
|
335
|
+
-- ============================================================================
|
|
336
|
+
-- pg_partman bootstrap (skipped if pg_partman not installed)
|
|
337
|
+
-- ============================================================================
|
|
338
|
+
-- 1-month range partitions, 4 months pre-created. Each block is independent
|
|
339
|
+
-- so a partial install (extension partially loaded, one CALL fails) still
|
|
340
|
+
-- lets the others succeed.
|
|
341
|
+
|
|
342
|
+
DO $$
|
|
343
|
+
BEGIN
|
|
344
|
+
-- Check for the partman schema (not just pg_extension) because some managed
|
|
345
|
+
-- Postgres providers (Neon) register a pg_extension row without actually
|
|
346
|
+
-- creating the schema, which would make CALL partman.* fail mid-transaction.
|
|
347
|
+
IF EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'partman') THEN
|
|
348
|
+
CALL partman.create_parent(
|
|
349
|
+
p_parent_table => 'public.snapshots',
|
|
350
|
+
p_control => 'fetched_at',
|
|
351
|
+
p_type => 'range',
|
|
352
|
+
p_interval => '1 month',
|
|
353
|
+
p_premake => 4
|
|
354
|
+
);
|
|
355
|
+
CALL partman.create_parent(
|
|
356
|
+
p_parent_table => 'public.events',
|
|
357
|
+
p_control => 'created_at',
|
|
358
|
+
p_type => 'range',
|
|
359
|
+
p_interval => '1 month',
|
|
360
|
+
p_premake => 4
|
|
361
|
+
);
|
|
362
|
+
CALL partman.create_parent(
|
|
363
|
+
p_parent_table => 'public.cost_records',
|
|
364
|
+
p_control => 'recorded_at',
|
|
365
|
+
p_type => 'range',
|
|
366
|
+
p_interval => '1 month',
|
|
367
|
+
p_premake => 4
|
|
368
|
+
);
|
|
369
|
+
CALL partman.create_parent(
|
|
370
|
+
p_parent_table => 'public.audit_log',
|
|
371
|
+
p_control => 'recorded_at',
|
|
372
|
+
p_type => 'range',
|
|
373
|
+
p_interval => '1 month',
|
|
374
|
+
p_premake => 4
|
|
375
|
+
);
|
|
376
|
+
ELSE
|
|
377
|
+
RAISE NOTICE 'partman schema not present — skipping partition pre-creation. Tables exist and accept rows; partitions must be created manually or after migrating to a partman-capable Postgres.';
|
|
378
|
+
END IF;
|
|
379
|
+
END $$;
|
|
380
|
+
|
|
381
|
+
-- ============================================================================
|
|
382
|
+
-- pg_cron — hourly partman maintenance (skipped if pg_cron not installed)
|
|
383
|
+
-- ============================================================================
|
|
384
|
+
|
|
385
|
+
DO $$
|
|
386
|
+
BEGIN
|
|
387
|
+
-- Same schema-presence check pattern as the partman block above.
|
|
388
|
+
IF EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'cron')
|
|
389
|
+
AND EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'partman') THEN
|
|
390
|
+
PERFORM cron.schedule(
|
|
391
|
+
'partman-maintenance',
|
|
392
|
+
'0 * * * *',
|
|
393
|
+
$cron$CALL partman.run_maintenance_proc()$cron$
|
|
394
|
+
);
|
|
395
|
+
ELSE
|
|
396
|
+
RAISE NOTICE 'cron and/or partman schema not present — partman maintenance not scheduled.';
|
|
397
|
+
END IF;
|
|
398
|
+
END $$;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
-- BlueFields chunk 6 — dispatch infrastructure.
|
|
2
|
+
--
|
|
3
|
+
-- Per Review #1, the architecture splits "when to poll" (pg_cron + a
|
|
4
|
+
-- claim-then-enqueue SQL function) from "what happens during a poll"
|
|
5
|
+
-- (Inngest step functions). This migration creates the SQL function
|
|
6
|
+
-- and the queue table that the API worker drains.
|
|
7
|
+
--
|
|
8
|
+
-- The cron.schedule() call is wrapped in a DO block because not every
|
|
9
|
+
-- Postgres host has pg_cron (Neon free tier doesn't). When pg_cron is
|
|
10
|
+
-- missing, the operator must trigger dispatch_due_subscriptions()
|
|
11
|
+
-- externally — Inngest's own cron, a Fly scheduled machine, or a manual
|
|
12
|
+
-- SELECT in psql. The function itself works regardless.
|
|
13
|
+
|
|
14
|
+
-- ============================================================================
|
|
15
|
+
-- Queue table — API worker drains this
|
|
16
|
+
-- ============================================================================
|
|
17
|
+
|
|
18
|
+
CREATE TABLE IF NOT EXISTS inngest_dispatch_queue (
|
|
19
|
+
id BIGSERIAL PRIMARY KEY,
|
|
20
|
+
subscription_id UUID NOT NULL REFERENCES subscriptions(id),
|
|
21
|
+
-- The Inngest event name to send (lets the dispatch function discriminate
|
|
22
|
+
-- between subscription.poll / webhook.ping / webhook.deliver later)
|
|
23
|
+
event_name TEXT NOT NULL,
|
|
24
|
+
-- Payload to ship to Inngest as the event data
|
|
25
|
+
event_data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
26
|
+
-- When this row was created (the API worker SLO is to drain within ~5s)
|
|
27
|
+
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
28
|
+
-- Optimistic lock — workers SET claimed_at and claimed_by to mark in-flight rows
|
|
29
|
+
claimed_at TIMESTAMPTZ,
|
|
30
|
+
claimed_by TEXT
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_dispatch_queue_unclaimed
|
|
34
|
+
ON inngest_dispatch_queue (enqueued_at)
|
|
35
|
+
WHERE claimed_at IS NULL;
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_dispatch_queue_subscription
|
|
37
|
+
ON inngest_dispatch_queue (subscription_id);
|
|
38
|
+
|
|
39
|
+
-- ============================================================================
|
|
40
|
+
-- dispatch_due_subscriptions(): SELECT + claim-sentinel + enqueue
|
|
41
|
+
-- ============================================================================
|
|
42
|
+
--
|
|
43
|
+
-- Runs every minute (via pg_cron OR an external scheduler). Selects all
|
|
44
|
+
-- subscriptions where next_poll_at is due AND polling isn't paused AND
|
|
45
|
+
-- the webhook isn't disabled. For each, advances next_poll_at by a 5-minute
|
|
46
|
+
-- claim sentinel (so this same row can't be re-claimed by a concurrent
|
|
47
|
+
-- dispatch), and enqueues a `subscription.poll` event for the API worker
|
|
48
|
+
-- to relay to Inngest.
|
|
49
|
+
--
|
|
50
|
+
-- Returns the count of subscriptions enqueued for observability.
|
|
51
|
+
|
|
52
|
+
CREATE OR REPLACE FUNCTION dispatch_due_subscriptions()
|
|
53
|
+
RETURNS INTEGER
|
|
54
|
+
LANGUAGE plpgsql
|
|
55
|
+
AS $$
|
|
56
|
+
DECLARE
|
|
57
|
+
claimed_count INTEGER := 0;
|
|
58
|
+
BEGIN
|
|
59
|
+
WITH due AS (
|
|
60
|
+
SELECT s.id
|
|
61
|
+
FROM subscriptions s
|
|
62
|
+
WHERE s.next_poll_at <= now()
|
|
63
|
+
AND s.status = 'active'
|
|
64
|
+
AND (s.webhook_url_state IS DISTINCT FROM 'disabled')
|
|
65
|
+
-- Don't redispatch rows already mid-claim. The 5-minute sentinel
|
|
66
|
+
-- (set below) is the lock; this guard is belt-and-suspenders.
|
|
67
|
+
FOR UPDATE SKIP LOCKED
|
|
68
|
+
),
|
|
69
|
+
-- Advance next_poll_at by the claim-sentinel duration so the row
|
|
70
|
+
-- doesn't get re-selected by the next dispatch run before the worker
|
|
71
|
+
-- has had a chance to update it.
|
|
72
|
+
claimed AS (
|
|
73
|
+
UPDATE subscriptions
|
|
74
|
+
SET next_poll_at = now() + INTERVAL '5 minutes'
|
|
75
|
+
WHERE id IN (SELECT id FROM due)
|
|
76
|
+
RETURNING id
|
|
77
|
+
)
|
|
78
|
+
INSERT INTO inngest_dispatch_queue (subscription_id, event_name, event_data)
|
|
79
|
+
SELECT c.id, 'subscription.poll', jsonb_build_object('subscription_id', c.id)
|
|
80
|
+
FROM claimed c;
|
|
81
|
+
|
|
82
|
+
GET DIAGNOSTICS claimed_count = ROW_COUNT;
|
|
83
|
+
RETURN claimed_count;
|
|
84
|
+
END;
|
|
85
|
+
$$;
|
|
86
|
+
|
|
87
|
+
-- ============================================================================
|
|
88
|
+
-- pg_cron — schedule dispatch every minute (skipped if pg_cron absent)
|
|
89
|
+
-- ============================================================================
|
|
90
|
+
|
|
91
|
+
DO $$
|
|
92
|
+
BEGIN
|
|
93
|
+
IF EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'cron') THEN
|
|
94
|
+
-- Unschedule the previous version if any (idempotent re-runs).
|
|
95
|
+
BEGIN
|
|
96
|
+
PERFORM cron.unschedule('dispatch-subscriptions');
|
|
97
|
+
EXCEPTION WHEN OTHERS THEN
|
|
98
|
+
-- First run — nothing to unschedule.
|
|
99
|
+
NULL;
|
|
100
|
+
END;
|
|
101
|
+
PERFORM cron.schedule(
|
|
102
|
+
'dispatch-subscriptions',
|
|
103
|
+
'* * * * *',
|
|
104
|
+
$cron$SELECT dispatch_due_subscriptions()$cron$
|
|
105
|
+
);
|
|
106
|
+
ELSE
|
|
107
|
+
RAISE NOTICE 'pg_cron not installed — schedule dispatch_due_subscriptions() externally (Inngest cron or Fly scheduled machine).';
|
|
108
|
+
END IF;
|
|
109
|
+
END $$;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
-- BlueFields chunk 16 — webhook secret rotation cleanup.
|
|
2
|
+
--
|
|
3
|
+
-- Per Review #7, the previous webhook secret is kept for 24 hours after
|
|
4
|
+
-- rotation so the customer has time to deploy the new secret. This
|
|
5
|
+
-- migration creates a SQL function that nulls expired _previous values
|
|
6
|
+
-- and schedules it nightly via pg_cron.
|
|
7
|
+
--
|
|
8
|
+
-- The function is the same shape as the JS-side `pruneExpiredPreviousSecrets`
|
|
9
|
+
-- in the application layer — either one works in
|
|
10
|
+
-- isolation. The SQL version lets pg_cron drive it without a worker; the
|
|
11
|
+
-- JS version is the fallback when pg_cron is not installed (Neon free tier).
|
|
12
|
+
|
|
13
|
+
-- ============================================================================
|
|
14
|
+
-- prune_expired_previous_webhook_secrets(): scrub the previous-secret column
|
|
15
|
+
-- ============================================================================
|
|
16
|
+
|
|
17
|
+
CREATE OR REPLACE FUNCTION prune_expired_previous_webhook_secrets()
|
|
18
|
+
RETURNS INTEGER
|
|
19
|
+
LANGUAGE plpgsql
|
|
20
|
+
AS $$
|
|
21
|
+
DECLARE
|
|
22
|
+
affected INTEGER := 0;
|
|
23
|
+
BEGIN
|
|
24
|
+
UPDATE subscriptions
|
|
25
|
+
SET webhook_secret_previous = NULL
|
|
26
|
+
WHERE webhook_secret_previous IS NOT NULL
|
|
27
|
+
AND webhook_secret_rotated_at IS NOT NULL
|
|
28
|
+
AND webhook_secret_rotated_at + INTERVAL '24 hours' < now();
|
|
29
|
+
|
|
30
|
+
GET DIAGNOSTICS affected = ROW_COUNT;
|
|
31
|
+
RETURN affected;
|
|
32
|
+
END;
|
|
33
|
+
$$;
|
|
34
|
+
|
|
35
|
+
-- ============================================================================
|
|
36
|
+
-- pg_cron — run nightly at 03:17 UTC (off-peak; staggered from other jobs)
|
|
37
|
+
-- ============================================================================
|
|
38
|
+
|
|
39
|
+
DO $$
|
|
40
|
+
BEGIN
|
|
41
|
+
IF EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'cron') THEN
|
|
42
|
+
BEGIN
|
|
43
|
+
PERFORM cron.unschedule('prune-expired-previous-webhook-secrets');
|
|
44
|
+
EXCEPTION WHEN OTHERS THEN
|
|
45
|
+
NULL;
|
|
46
|
+
END;
|
|
47
|
+
PERFORM cron.schedule(
|
|
48
|
+
'prune-expired-previous-webhook-secrets',
|
|
49
|
+
'17 3 * * *',
|
|
50
|
+
$cron$SELECT prune_expired_previous_webhook_secrets()$cron$
|
|
51
|
+
);
|
|
52
|
+
ELSE
|
|
53
|
+
RAISE NOTICE 'pg_cron not installed — drive prune_expired_previous_webhook_secrets() externally (Inngest scheduled function or worker).';
|
|
54
|
+
END IF;
|
|
55
|
+
END $$;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
-- BlueFields chunk 20 — pg_notify on events INSERT for SSE streaming.
|
|
2
|
+
--
|
|
3
|
+
-- Per Review #7, customers can subscribe to events via Server-Sent Events
|
|
4
|
+
-- in addition to webhooks. The SSE endpoint listens on a per-subscription
|
|
5
|
+
-- channel (`subscription_event_<subscription_id>`) via Postgres LISTEN.
|
|
6
|
+
-- This migration installs the trigger that NOTIFY's whenever a row hits
|
|
7
|
+
-- the events table — regardless of which code path inserted it
|
|
8
|
+
-- (auto-disable's subscription.error, chunk 21's subscription.change, etc.).
|
|
9
|
+
--
|
|
10
|
+
-- The payload is minimal — just the event id + type + creation timestamp.
|
|
11
|
+
-- The SSE handler fetches the full row when it dispatches; this keeps the
|
|
12
|
+
-- NOTIFY payload under the 8KB Postgres limit even on huge events.
|
|
13
|
+
|
|
14
|
+
CREATE OR REPLACE FUNCTION events_notify_on_insert()
|
|
15
|
+
RETURNS TRIGGER
|
|
16
|
+
LANGUAGE plpgsql
|
|
17
|
+
AS $$
|
|
18
|
+
DECLARE
|
|
19
|
+
channel TEXT;
|
|
20
|
+
payload JSONB;
|
|
21
|
+
BEGIN
|
|
22
|
+
channel := 'subscription_event_' || NEW.subscription_id::text;
|
|
23
|
+
payload := jsonb_build_object(
|
|
24
|
+
'event_id', NEW.id,
|
|
25
|
+
'event_type', NEW.event_type,
|
|
26
|
+
'customer_id', NEW.customer_id,
|
|
27
|
+
'created_at', to_char(NEW.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
|
|
28
|
+
);
|
|
29
|
+
PERFORM pg_notify(channel, payload::text);
|
|
30
|
+
RETURN NEW;
|
|
31
|
+
END;
|
|
32
|
+
$$;
|
|
33
|
+
|
|
34
|
+
-- DROP first so re-runs are idempotent. The trigger fires on every INSERT.
|
|
35
|
+
DROP TRIGGER IF EXISTS events_notify_trigger ON events;
|
|
36
|
+
CREATE TRIGGER events_notify_trigger
|
|
37
|
+
AFTER INSERT ON events
|
|
38
|
+
FOR EACH ROW
|
|
39
|
+
EXECUTE FUNCTION events_notify_on_insert();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- BlueFields chunk-21 follow-up — subscription poll auto-pause column.
|
|
2
|
+
--
|
|
3
|
+
-- Per chunk 21 spec: after N consecutive subscription failures (fetch
|
|
4
|
+
-- exceptions, extractor exceptions, etc. — NOT webhook deliveries, which
|
|
5
|
+
-- already have webhook_consecutive_failures), the subscription is
|
|
6
|
+
-- auto-paused. The customer must reactivate via PATCH /subscriptions/:id
|
|
7
|
+
-- with status='active'.
|
|
8
|
+
--
|
|
9
|
+
-- This column is SEPARATE from webhook_consecutive_failures (Review #7)
|
|
10
|
+
-- — webhook failures and poll failures have different meanings and
|
|
11
|
+
-- different recovery semantics.
|
|
12
|
+
|
|
13
|
+
ALTER TABLE subscriptions
|
|
14
|
+
ADD COLUMN IF NOT EXISTS consecutive_failures INTEGER NOT NULL DEFAULT 0;
|
|
15
|
+
|
|
16
|
+
-- Index supports the dashboard "subscriptions with active alerts" view.
|
|
17
|
+
CREATE INDEX IF NOT EXISTS idx_subs_consecutive_failures
|
|
18
|
+
ON subscriptions (consecutive_failures)
|
|
19
|
+
WHERE consecutive_failures > 0;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- BlueFields — notification preferences (billing-trust alerts).
|
|
2
|
+
--
|
|
3
|
+
-- One row per customer. Absence of a row = "default opt-in" — the
|
|
4
|
+
-- notification-check queries LEFT JOIN this table and COALESCE the
|
|
5
|
+
-- enabled flags to true. No backfill needed for existing customers.
|
|
6
|
+
|
|
7
|
+
CREATE TABLE IF NOT EXISTS notification_preferences (
|
|
8
|
+
customer_id UUID PRIMARY KEY REFERENCES customers(id),
|
|
9
|
+
cost_anomaly_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
10
|
+
cost_anomaly_threshold_multiplier NUMERIC(4,2) NOT NULL DEFAULT 2.00,
|
|
11
|
+
delivery_health_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
12
|
+
delivery_health_threshold_pct SMALLINT NOT NULL DEFAULT 25,
|
|
13
|
+
email_override TEXT,
|
|
14
|
+
last_cost_alert_at TIMESTAMPTZ,
|
|
15
|
+
last_delivery_alert_at TIMESTAMPTZ,
|
|
16
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
17
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
-- Partial indices for the cron's "who's eligible" scan.
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_notif_prefs_cost_eligible
|
|
22
|
+
ON notification_preferences (customer_id)
|
|
23
|
+
WHERE cost_anomaly_enabled = TRUE;
|
|
24
|
+
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_notif_prefs_delivery_eligible
|
|
26
|
+
ON notification_preferences (customer_id)
|
|
27
|
+
WHERE delivery_health_enabled = TRUE;
|