@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,65 @@
|
|
|
1
|
+
-- BlueFields Phase C — crawl jobs.
|
|
2
|
+
--
|
|
3
|
+
-- A "crawl job" is a long-running BFS over a domain. The route returns
|
|
4
|
+
-- a job_id immediately; an Inngest function does the actual BFS,
|
|
5
|
+
-- writing per-page rows as it goes. Customers poll GET /crawl/:id or
|
|
6
|
+
-- subscribe to GET /crawl/:id/stream (SSE) for progress.
|
|
7
|
+
--
|
|
8
|
+
-- The same schema serves /batch/scrape + /extract in Phase D — both
|
|
9
|
+
-- are also "jobs with per-URL results."
|
|
10
|
+
|
|
11
|
+
CREATE TABLE IF NOT EXISTS crawl_jobs (
|
|
12
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
13
|
+
customer_id UUID NOT NULL REFERENCES customers(id),
|
|
14
|
+
-- Discriminator: 'crawl' | 'batch_scrape' | 'extract'. One table for
|
|
15
|
+
-- all three keeps the polling/stream/webhook paths uniform.
|
|
16
|
+
job_kind TEXT NOT NULL,
|
|
17
|
+
-- For crawl: the seed URL. For batch_scrape/extract: null (per-URL
|
|
18
|
+
-- list lives in `params.urls`).
|
|
19
|
+
start_url TEXT,
|
|
20
|
+
url_canonical TEXT,
|
|
21
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
22
|
+
-- All request-time params (maxDepth, maxPages, includes, excludes,
|
|
23
|
+
-- scrapeOptions, urls list, schema, ...) — kept opaque here so the
|
|
24
|
+
-- worker can evolve without schema migrations.
|
|
25
|
+
params JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
26
|
+
total_pages_discovered INT NOT NULL DEFAULT 0,
|
|
27
|
+
total_pages_scraped INT NOT NULL DEFAULT 0,
|
|
28
|
+
-- Optional: webhook to call when status transitions to terminal.
|
|
29
|
+
webhook_url TEXT,
|
|
30
|
+
webhook_secret TEXT,
|
|
31
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
32
|
+
started_at TIMESTAMPTZ,
|
|
33
|
+
completed_at TIMESTAMPTZ,
|
|
34
|
+
-- On failure: short human-readable reason.
|
|
35
|
+
error TEXT
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_crawl_jobs_customer_time
|
|
39
|
+
ON crawl_jobs (customer_id, created_at DESC);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_crawl_jobs_status_pending
|
|
41
|
+
ON crawl_jobs (status, created_at)
|
|
42
|
+
WHERE status IN ('pending', 'running');
|
|
43
|
+
|
|
44
|
+
-- Per-page results. Composite PK on (job_id, url_canonical) so a
|
|
45
|
+
-- job can't double-record the same URL (BFS dedup safety net).
|
|
46
|
+
CREATE TABLE IF NOT EXISTS crawl_job_pages (
|
|
47
|
+
job_id UUID NOT NULL REFERENCES crawl_jobs(id) ON DELETE CASCADE,
|
|
48
|
+
url_canonical TEXT NOT NULL,
|
|
49
|
+
-- BFS depth from start_url. 0 for the seed; null for batch_scrape/extract.
|
|
50
|
+
depth INT,
|
|
51
|
+
-- Result fields: subset of FetchResult + extraction metadata.
|
|
52
|
+
content_hash TEXT,
|
|
53
|
+
response_status INT,
|
|
54
|
+
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
55
|
+
extraction_id UUID,
|
|
56
|
+
-- 'ok' | 'fetch_failed' | 'extract_failed' | 'excluded' | 'depth_limit'
|
|
57
|
+
status TEXT NOT NULL DEFAULT 'ok',
|
|
58
|
+
status_detail TEXT,
|
|
59
|
+
PRIMARY KEY (job_id, url_canonical)
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_crawl_pages_job_time
|
|
63
|
+
ON crawl_job_pages (job_id, fetched_at DESC);
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_crawl_pages_job_status
|
|
65
|
+
ON crawl_job_pages (job_id, status);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- BlueFields Phase G — named sessions for cookie/header reuse across scrapes.
|
|
2
|
+
--
|
|
3
|
+
-- One row per (customer_id, name). The cookies + headers blobs are
|
|
4
|
+
-- AES-256-GCM ciphertext (key from SESSION_ENCRYPTION_KEY env). Each
|
|
5
|
+
-- payload is a versioned JSON envelope: `{ v: 1, iv, tag, ciphertext }`
|
|
6
|
+
-- — the encryption layer lives in @bluefields/control/sessions.ts.
|
|
7
|
+
--
|
|
8
|
+
-- Expiry: caller-supplied or default 30 days. A row past expires_at is
|
|
9
|
+
-- treated as missing by `loadSession`; a daily cleanup cron is wired in
|
|
10
|
+
-- chunk-22-style if/when needed (volume should be low — names are
|
|
11
|
+
-- intentional, not request-scoped).
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
14
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
15
|
+
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
16
|
+
-- Human-friendly identifier scoped per customer. NOT globally unique;
|
|
17
|
+
-- two customers can both have a session named 'github-prod'.
|
|
18
|
+
name TEXT NOT NULL,
|
|
19
|
+
-- AES-256-GCM ciphertext (versioned JSON envelope, stored as TEXT for
|
|
20
|
+
-- easy inspection in psql; not a BYTEA because Drizzle's text() type
|
|
21
|
+
-- works cleanly here).
|
|
22
|
+
cookies_ciphertext TEXT NOT NULL,
|
|
23
|
+
-- Optional default headers (Authorization, custom UA) — same envelope.
|
|
24
|
+
headers_ciphertext TEXT,
|
|
25
|
+
-- When this session stops being usable. NULL == no expiry (rare).
|
|
26
|
+
expires_at TIMESTAMPTZ,
|
|
27
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
28
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
29
|
+
-- Per-customer uniqueness so save/use lookups are O(1).
|
|
30
|
+
UNIQUE (customer_id, name)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_customer_name
|
|
34
|
+
ON sessions (customer_id, name);
|
|
35
|
+
-- Partial index for the cleanup job — only scan rows that have an expiry.
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at
|
|
37
|
+
ON sessions (expires_at)
|
|
38
|
+
WHERE expires_at IS NOT NULL;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- BlueFields Phase A.5 — per-customer scrape cache for /scrape maxAge.
|
|
2
|
+
--
|
|
3
|
+
-- One row per (customer_id, url_canonical). Holds enough to short-circuit
|
|
4
|
+
-- the next /scrape for the same URL without re-running the fetch +
|
|
5
|
+
-- extractor pipeline.
|
|
6
|
+
--
|
|
7
|
+
-- We store an extraction_id pointer rather than denormalising markdown /
|
|
8
|
+
-- structured_data — extractions can be 100KB+ and the cache is a small,
|
|
9
|
+
-- hot lookup. The actual data is one extra SELECT on the extractions
|
|
10
|
+
-- table when the cache hits.
|
|
11
|
+
--
|
|
12
|
+
-- Cross-customer sharing happens at the extractor layer (Review #6 cache
|
|
13
|
+
-- on content_hash + schema_hash + extractor_version). This table is a
|
|
14
|
+
-- per-customer URL-level layer on top of that.
|
|
15
|
+
|
|
16
|
+
CREATE TABLE IF NOT EXISTS scrape_cache (
|
|
17
|
+
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
18
|
+
url_canonical TEXT NOT NULL,
|
|
19
|
+
-- The fresh-fetched extraction. NULL only when the call was html-only
|
|
20
|
+
-- (no extractor invoked) — those cache rows can still answer html
|
|
21
|
+
-- requests when wired to a snapshot. v1: store NULL when no extractor
|
|
22
|
+
-- ran; serve only html-format requests from cache.
|
|
23
|
+
extraction_id UUID,
|
|
24
|
+
content_hash TEXT NOT NULL,
|
|
25
|
+
final_url TEXT NOT NULL,
|
|
26
|
+
response_status INT NOT NULL,
|
|
27
|
+
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
28
|
+
PRIMARY KEY (customer_id, url_canonical)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
-- Lookup by url across customers is rare — main read is the PK lookup
|
|
32
|
+
-- per /scrape call. A by-time index would help if we add a cleanup cron
|
|
33
|
+
-- later, but for v1 we keep the table lean and rely on customer-scoped
|
|
34
|
+
-- TTL behavior (rows are overwritten by upsert on each fresh scrape).
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
-- 0010 — Neon-friendly default partitions + missing usage_period seed.
|
|
2
|
+
--
|
|
3
|
+
-- Why:
|
|
4
|
+
-- Migration 0001 creates partitioned parent tables (snapshots, events,
|
|
5
|
+
-- cost_records, audit_log) and calls partman.create_parent() to spin
|
|
6
|
+
-- up the per-month child partitions. Neon doesn't have pg_partman, so
|
|
7
|
+
-- the partman call is wrapped in a DO-block that no-ops when the
|
|
8
|
+
-- extension is missing. Result: the parent tables exist, but no child
|
|
9
|
+
-- partitions — any INSERT errors out with:
|
|
10
|
+
-- ERROR: no partition of relation "cost_records" found for row
|
|
11
|
+
--
|
|
12
|
+
-- For Neon (and any pg-partman-less Postgres), add a single DEFAULT
|
|
13
|
+
-- partition per parent. New rows route there until/unless the
|
|
14
|
+
-- operator later layers in a real partitioning strategy.
|
|
15
|
+
--
|
|
16
|
+
-- Also seeds a usage_periods row for any customer that lacks one in
|
|
17
|
+
-- the current month — covers the bootstrap admin case where the
|
|
18
|
+
-- customer was inserted via raw SQL without going through the Stripe
|
|
19
|
+
-- checkout webhook that normally creates the period.
|
|
20
|
+
--
|
|
21
|
+
-- Safe to re-run: all statements are IF NOT EXISTS / ON CONFLICT.
|
|
22
|
+
|
|
23
|
+
-- ── Default partitions ─────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS snapshots_default
|
|
26
|
+
PARTITION OF snapshots DEFAULT;
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS events_default
|
|
29
|
+
PARTITION OF events DEFAULT;
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS cost_records_default
|
|
32
|
+
PARTITION OF cost_records DEFAULT;
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS audit_log_default
|
|
35
|
+
PARTITION OF audit_log DEFAULT;
|
|
36
|
+
|
|
37
|
+
-- ── Backfill usage_periods for customers without an active row ─────────
|
|
38
|
+
--
|
|
39
|
+
-- For each customer whose latest period_end is before today (or who has
|
|
40
|
+
-- no period at all), insert a period covering the current calendar
|
|
41
|
+
-- month with the customer's plan-derived quotas. Numbers below match
|
|
42
|
+
-- the application layer's free-tier plan defaults; customers
|
|
43
|
+
-- on higher tiers should still get a usable period (overstating their
|
|
44
|
+
-- quota slightly is harmless — the Stripe checkout flow re-snapshots
|
|
45
|
+
-- at the next month rollover).
|
|
46
|
+
|
|
47
|
+
INSERT INTO usage_periods (
|
|
48
|
+
customer_id, period_start, period_end, plan,
|
|
49
|
+
events_quota, scrapes_quota, active_subs_quota, overage_cap_events,
|
|
50
|
+
credits_quota
|
|
51
|
+
)
|
|
52
|
+
SELECT
|
|
53
|
+
c.id,
|
|
54
|
+
date_trunc('month', now())::date AS period_start,
|
|
55
|
+
(date_trunc('month', now()) + interval '1 month - 1 day')::date AS period_end,
|
|
56
|
+
c.plan,
|
|
57
|
+
CASE c.plan
|
|
58
|
+
WHEN 'free' THEN 100
|
|
59
|
+
WHEN 'starter' THEN 5000
|
|
60
|
+
WHEN 'growth' THEN 50000
|
|
61
|
+
WHEN 'scale' THEN 500000
|
|
62
|
+
ELSE 100
|
|
63
|
+
END AS events_quota,
|
|
64
|
+
CASE c.plan
|
|
65
|
+
WHEN 'free' THEN 100
|
|
66
|
+
WHEN 'starter' THEN 5000
|
|
67
|
+
WHEN 'growth' THEN 50000
|
|
68
|
+
WHEN 'scale' THEN 500000
|
|
69
|
+
ELSE 100
|
|
70
|
+
END AS scrapes_quota,
|
|
71
|
+
CASE c.plan
|
|
72
|
+
WHEN 'free' THEN 10
|
|
73
|
+
WHEN 'starter' THEN 50
|
|
74
|
+
WHEN 'growth' THEN 500
|
|
75
|
+
WHEN 'scale' THEN 5000
|
|
76
|
+
ELSE 10
|
|
77
|
+
END AS active_subs_quota,
|
|
78
|
+
CASE c.plan
|
|
79
|
+
WHEN 'free' THEN 100
|
|
80
|
+
WHEN 'starter' THEN 7500
|
|
81
|
+
WHEN 'growth' THEN 75000
|
|
82
|
+
WHEN 'scale' THEN 750000
|
|
83
|
+
ELSE 100
|
|
84
|
+
END AS overage_cap_events,
|
|
85
|
+
CASE c.plan
|
|
86
|
+
WHEN 'free' THEN 500
|
|
87
|
+
WHEN 'starter' THEN 25000
|
|
88
|
+
WHEN 'growth' THEN 100000
|
|
89
|
+
WHEN 'scale' THEN 500000
|
|
90
|
+
ELSE 500
|
|
91
|
+
END AS credits_quota
|
|
92
|
+
FROM customers c
|
|
93
|
+
WHERE NOT EXISTS (
|
|
94
|
+
SELECT 1 FROM usage_periods up
|
|
95
|
+
WHERE up.customer_id = c.id
|
|
96
|
+
AND up.period_start <= now()::date
|
|
97
|
+
AND up.period_end >= now()::date
|
|
98
|
+
)
|
|
99
|
+
ON CONFLICT (customer_id, period_start) DO NOTHING;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- 0011_credits_consumed_numeric.sql
|
|
2
|
+
--
|
|
3
|
+
-- Cache-hit fractional charge (0.2 credit — Option A pricing, 2026-06-16)
|
|
4
|
+
-- needs a sub-integer credits column. Widen usage_periods.credits_consumed from
|
|
5
|
+
-- INTEGER to NUMERIC(14,4): up to 10 integer digits (>> any plan's credit total)
|
|
6
|
+
-- plus 4 decimal places. Lossless for existing integer values; not destructive.
|
|
7
|
+
|
|
8
|
+
ALTER TABLE usage_periods
|
|
9
|
+
ALTER COLUMN credits_consumed TYPE NUMERIC(14, 4) USING credits_consumed::numeric(14, 4);
|
|
10
|
+
|
|
11
|
+
ALTER TABLE usage_periods
|
|
12
|
+
ALTER COLUMN credits_consumed SET DEFAULT 0;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- 0012_crawl_page_attestation.sql
|
|
2
|
+
--
|
|
3
|
+
-- Per-page attestation for crawl / batch / extract (2026-06-21).
|
|
4
|
+
-- Stores the signed v1 manifest ({ manifest, signature }) for each crawled page
|
|
5
|
+
-- when ATTEST_SYNC_ROUTES is enabled, so GET /crawl/:id can return it. Nullable
|
|
6
|
+
-- + additive: existing rows stay NULL and the default-off path is unaffected.
|
|
7
|
+
-- Idempotent (IF NOT EXISTS) so the runner can safely re-apply it.
|
|
8
|
+
|
|
9
|
+
ALTER TABLE crawl_job_pages
|
|
10
|
+
ADD COLUMN IF NOT EXISTS attestation jsonb;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- Account deletion (GDPR right to erasure): mark a customer as deleted/anonymized.
|
|
2
|
+
-- The row is kept (anonymized in place by @bluefields/control deleteAccount) for
|
|
3
|
+
-- billing/audit integrity; NULL = active. Additive + idempotent (the migrate
|
|
4
|
+
-- runner re-applies every .sql file, so this MUST stay re-runnable).
|
|
5
|
+
ALTER TABLE customers ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
|