@venturekit/data 0.0.32 → 0.0.34
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/files/index.d.ts +9 -0
- package/dist/files/index.d.ts.map +1 -0
- package/dist/files/index.js +8 -0
- package/dist/files/index.js.map +1 -0
- package/dist/files/postgres.d.ts +150 -0
- package/dist/files/postgres.d.ts.map +1 -0
- package/dist/files/postgres.js +194 -0
- package/dist/files/postgres.js.map +1 -0
- package/dist/idempotency/index.d.ts +9 -0
- package/dist/idempotency/index.d.ts.map +1 -0
- package/dist/idempotency/index.js +8 -0
- package/dist/idempotency/index.js.map +1 -0
- package/dist/idempotency/postgres.d.ts +107 -0
- package/dist/idempotency/postgres.d.ts.map +1 -0
- package/dist/idempotency/postgres.js +145 -0
- package/dist/idempotency/postgres.js.map +1 -0
- package/dist/internal/identifier.d.ts +16 -0
- package/dist/internal/identifier.d.ts.map +1 -0
- package/dist/internal/identifier.js +23 -0
- package/dist/internal/identifier.js.map +1 -0
- package/dist/jobs/index.d.ts +9 -0
- package/dist/jobs/index.d.ts.map +1 -0
- package/dist/jobs/index.js +8 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/jobs/postgres.d.ts +197 -0
- package/dist/jobs/postgres.d.ts.map +1 -0
- package/dist/jobs/postgres.js +270 -0
- package/dist/jobs/postgres.js.map +1 -0
- package/dist/outbox/index.d.ts +9 -0
- package/dist/outbox/index.d.ts.map +1 -0
- package/dist/outbox/index.js +8 -0
- package/dist/outbox/index.js.map +1 -0
- package/dist/outbox/postgres.d.ts +124 -0
- package/dist/outbox/postgres.d.ts.map +1 -0
- package/dist/outbox/postgres.js +177 -0
- package/dist/outbox/postgres.js.map +1 -0
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js.map +1 -1
- package/dist/query/secret.d.ts.map +1 -1
- package/dist/query/secret.js +1 -1
- package/dist/query/secret.js.map +1 -1
- package/package.json +18 -2
- package/src/sql/{vk_data_001_tenancy_foundation.sql → 0000_vk_data_foundation.sql} +305 -278
- package/src/sql/vk_data_001_idempotency.sql +48 -0
- package/src/sql/vk_data_002_outbox.sql +99 -0
- package/src/sql/vk_data_003_jobs.sql +114 -0
- package/src/sql/vk_data_004_file_object.sql +112 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
-- @venturekit/data — idempotency records.
|
|
2
|
+
--
|
|
3
|
+
-- Table created by this migration:
|
|
4
|
+
-- vk_idempotency_record — stored responses for replayed unsafe requests
|
|
5
|
+
--
|
|
6
|
+
-- Backs `createPostgresIdempotencyStore()` (`src/idempotency/postgres.ts`),
|
|
7
|
+
-- which plugs into `@venturekit/runtime`'s `idempotencyMiddleware`. That
|
|
8
|
+
-- middleware has always taken a pluggable store and shipped two — in-memory
|
|
9
|
+
-- (per-process, so wrong for Lambda) and DynamoDB — leaving a Postgres-only
|
|
10
|
+
-- project to stand up a DynamoDB table for four columns.
|
|
11
|
+
--
|
|
12
|
+
-- Unlike `0000_vk_data_foundation.sql` this file keeps the
|
|
13
|
+
-- conventional `vk_data_001_` name: nothing in a consumer's own migration
|
|
14
|
+
-- references the table, so sorting after the project's `0xx_*` files is fine.
|
|
15
|
+
--
|
|
16
|
+
-- Additive for existing projects: one new table, no existing file edited, and
|
|
17
|
+
-- nothing writes to it until a project wires the middleware.
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS vk_idempotency_record (
|
|
20
|
+
-- The idempotency key, optionally namespaced by the store's `scope` as
|
|
21
|
+
-- `<scope>:<key>`. Scoping matters: the middleware's default extractor reads
|
|
22
|
+
-- a CLIENT-supplied header, so without it two callers sending the same key
|
|
23
|
+
-- collide and the second is served the first's cached response — across
|
|
24
|
+
-- tenants, a cross-tenant read. See the `scope` option.
|
|
25
|
+
key text PRIMARY KEY,
|
|
26
|
+
-- The serialized handler response, replayed verbatim on a duplicate.
|
|
27
|
+
-- Empty while status is 'pending'.
|
|
28
|
+
response text NOT NULL DEFAULT '',
|
|
29
|
+
-- 'pending' is written before the handler runs and is what makes a
|
|
30
|
+
-- concurrent duplicate detectable; 'completed' once the response is stored.
|
|
31
|
+
-- A CHECK rather than an enum: enums are a migration to extend, and these
|
|
32
|
+
-- two values are the middleware's whole vocabulary.
|
|
33
|
+
status text NOT NULL CHECK (status IN ('pending', 'completed')),
|
|
34
|
+
-- Absolute expiry. Reads filter on this, so an expired row is already
|
|
35
|
+
-- invisible whether or not anything has deleted it.
|
|
36
|
+
expires_at timestamptz NOT NULL,
|
|
37
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
38
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
-- Postgres has no TTL, so expired rows are deleted by
|
|
42
|
+
-- `purgeExpiredIdempotencyRecords()` on a schedule. This index is what keeps
|
|
43
|
+
-- that sweep from scanning the table.
|
|
44
|
+
CREATE INDEX IF NOT EXISTS vk_idempotency_record_expires_at_idx
|
|
45
|
+
ON vk_idempotency_record (expires_at);
|
|
46
|
+
|
|
47
|
+
COMMENT ON TABLE vk_idempotency_record IS
|
|
48
|
+
'Replay protection for unsafe requests. Backs @venturekit/data''s createPostgresIdempotencyStore().';
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
-- @venturekit/data — transactional outbox.
|
|
2
|
+
--
|
|
3
|
+
-- Table created by this migration:
|
|
4
|
+
-- vk_outbox — domain events awaiting publication
|
|
5
|
+
--
|
|
6
|
+
-- Backs `createPostgresOutboxStore()` (`src/outbox/postgres.ts`), the storage
|
|
7
|
+
-- half of `@venturekit/runtime/patterns`' outbox relay. `publishEvent()` puts an
|
|
8
|
+
-- event on EventBridge now; this table is how an event becomes part of the
|
|
9
|
+
-- transaction that caused it, so the two can never disagree about whether the
|
|
10
|
+
-- state change happened.
|
|
11
|
+
--
|
|
12
|
+
-- Additive for existing projects: one new table, no existing file edited, and
|
|
13
|
+
-- nothing writes to it until a project calls `appendToOutbox()`.
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS vk_outbox (
|
|
16
|
+
-- Supplied by the writer, not defaulted, and expected to be a time-ordered
|
|
17
|
+
-- uuid v7 (`newEventId()` in the runtime, `vk_uuid_generate_v7()` in SQL).
|
|
18
|
+
-- This is the primary key of an append-only table AND every consumer's dedupe
|
|
19
|
+
-- key: a v4 scatters the index it is clustered on as the table grows, and an
|
|
20
|
+
-- append-only table only grows. No DEFAULT because an id the writer already
|
|
21
|
+
-- put on the wire must be the id stored here.
|
|
22
|
+
id uuid PRIMARY KEY,
|
|
23
|
+
|
|
24
|
+
-- The ordering key. A sequence, so ordering is assigned at insert by the
|
|
25
|
+
-- database rather than by a clock — two events a millisecond apart on
|
|
26
|
+
-- different connections still get a total order, which `occurred_at` cannot
|
|
27
|
+
-- promise. The relay sorts by this and the consumer keeps it as the source
|
|
28
|
+
-- version.
|
|
29
|
+
seq bigserial NOT NULL UNIQUE,
|
|
30
|
+
|
|
31
|
+
type text NOT NULL,
|
|
32
|
+
|
|
33
|
+
-- Ordering is guaranteed PER AGGREGATE, so these two columns are what the
|
|
34
|
+
-- relay's blocking is keyed on, not decoration.
|
|
35
|
+
aggregate_type text NOT NULL,
|
|
36
|
+
aggregate_id text NOT NULL,
|
|
37
|
+
|
|
38
|
+
-- NULL for platform-global facts that belong to no tenant.
|
|
39
|
+
tenant_id uuid,
|
|
40
|
+
|
|
41
|
+
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
42
|
+
-- Payload schema version. The envelope calls it `version`; the column is
|
|
43
|
+
-- `event_version` because `version` is a word Postgres tooling likes to use.
|
|
44
|
+
event_version integer NOT NULL DEFAULT 1,
|
|
45
|
+
-- When the business fact occurred, which is not when the row was inserted.
|
|
46
|
+
occurred_at timestamptz NOT NULL DEFAULT now(),
|
|
47
|
+
-- `{ type: 'user' | 'system', id }`. jsonb rather than two columns so the
|
|
48
|
+
-- envelope round-trips without the adapter reassembling it.
|
|
49
|
+
actor jsonb NOT NULL,
|
|
50
|
+
|
|
51
|
+
correlation_id text,
|
|
52
|
+
causation_id text,
|
|
53
|
+
|
|
54
|
+
-- Publication state. NULL means pending; the relay sets it AFTER the bus has
|
|
55
|
+
-- accepted the event, never before — marking first turns a crash into an
|
|
56
|
+
-- event that no longer exists anywhere.
|
|
57
|
+
published_at timestamptz,
|
|
58
|
+
|
|
59
|
+
-- Retry accounting. `attempts >= maxAttempts` is the quarantine, and the
|
|
60
|
+
-- relay's claim excludes both the quarantined row and anything queued behind
|
|
61
|
+
-- it for the same aggregate.
|
|
62
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
63
|
+
last_error text,
|
|
64
|
+
failed_at timestamptz,
|
|
65
|
+
|
|
66
|
+
-- An operator deciding the event will never publish. Separate from `attempts`
|
|
67
|
+
-- on purpose: collapsing the two means raising the quarantine bound later
|
|
68
|
+
-- drags retired events back onto the bus. A discard also RELEASES the
|
|
69
|
+
-- successors held behind it, which is what makes quarantine a state an
|
|
70
|
+
-- aggregate can leave.
|
|
71
|
+
discarded_at timestamptz,
|
|
72
|
+
discard_reason text,
|
|
73
|
+
|
|
74
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
-- The relay's claim: pending, not discarded, not quarantined, oldest first.
|
|
78
|
+
-- Partial, so it indexes only the rows that are actually claimable — which in a
|
|
79
|
+
-- healthy system is a handful out of however many million have been published.
|
|
80
|
+
CREATE INDEX IF NOT EXISTS vk_outbox_pending_seq_idx
|
|
81
|
+
ON vk_outbox (seq)
|
|
82
|
+
WHERE published_at IS NULL AND discarded_at IS NULL;
|
|
83
|
+
|
|
84
|
+
-- The `blocked` CTE in `claim()`: which aggregates have an abandoned event.
|
|
85
|
+
CREATE INDEX IF NOT EXISTS vk_outbox_blocked_idx
|
|
86
|
+
ON vk_outbox (aggregate_type, aggregate_id)
|
|
87
|
+
WHERE published_at IS NULL AND discarded_at IS NULL;
|
|
88
|
+
|
|
89
|
+
-- "What is stuck, and why" — the operator's query, and the one a dashboard runs.
|
|
90
|
+
CREATE INDEX IF NOT EXISTS vk_outbox_quarantined_idx
|
|
91
|
+
ON vk_outbox (failed_at DESC)
|
|
92
|
+
WHERE published_at IS NULL AND discarded_at IS NULL AND attempts > 0;
|
|
93
|
+
|
|
94
|
+
COMMENT ON TABLE vk_outbox IS
|
|
95
|
+
'Transactional outbox. Events are appended in the caller''s transaction and relayed by @venturekit/runtime''s relayOnce().';
|
|
96
|
+
COMMENT ON COLUMN vk_outbox.seq IS
|
|
97
|
+
'Ordering key assigned at insert. The relay guarantees per-aggregate order by this, and never publishes an event while an earlier one for the same aggregate is unpublished.';
|
|
98
|
+
COMMENT ON COLUMN vk_outbox.discarded_at IS
|
|
99
|
+
'Operator retired this event. Leaves attempts untouched and releases successors of the same aggregate.';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
-- @venturekit/data — durable background jobs.
|
|
2
|
+
--
|
|
3
|
+
-- Table created by this migration:
|
|
4
|
+
-- vk_job — queued work, in Postgres rather than SQS
|
|
5
|
+
--
|
|
6
|
+
-- Backs `@venturekit/data/jobs`.
|
|
7
|
+
--
|
|
8
|
+
-- # Why this exists next to the SQS `queues` intent rather than instead of it
|
|
9
|
+
--
|
|
10
|
+
-- SQS is the right tool for fire-and-forget fan-out and it stays the default.
|
|
11
|
+
-- What it cannot do is answer a question about work in flight. "Which imports
|
|
12
|
+
-- are still geocoding for this tenant" is a screen in the product, and a queue
|
|
13
|
+
-- has no query surface: messages are invisible until received, and received
|
|
14
|
+
-- messages are invisible to everyone else. Rebuilding that visibility on top of
|
|
15
|
+
-- SQS means a second table tracking what you put in the queue — at which point
|
|
16
|
+
-- the queue is the redundant half, because the table can be claimed from
|
|
17
|
+
-- directly.
|
|
18
|
+
--
|
|
19
|
+
-- So: reach for `queues` when the work is opaque and throughput matters, and for
|
|
20
|
+
-- this when the work is a thing the product talks about — an import, a export, a
|
|
21
|
+
-- recompute someone is waiting on.
|
|
22
|
+
--
|
|
23
|
+
-- Claiming uses FOR UPDATE SKIP LOCKED, which lets several workers share the
|
|
24
|
+
-- table without a lock convoy: each transaction takes rows nobody else holds and
|
|
25
|
+
-- skips the rest rather than queueing behind them.
|
|
26
|
+
--
|
|
27
|
+
-- Additive for existing projects: one new table, no existing file edited, and
|
|
28
|
+
-- nothing writes to it until a project calls `enqueueJob()`.
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS vk_job (
|
|
31
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
32
|
+
|
|
33
|
+
-- NULL for platform-wide work (partition maintenance, a global recompute).
|
|
34
|
+
-- Deliberately no foreign key: the table a tenant lives in is the consumer's
|
|
35
|
+
-- (`vk_tenants` only exists if @venturekit-pro/tenancy is installed), and a
|
|
36
|
+
-- framework-owned FK into an optional package's table would make this
|
|
37
|
+
-- migration fail for everyone who does not use it.
|
|
38
|
+
tenant_id uuid,
|
|
39
|
+
|
|
40
|
+
-- Dispatch key. `runJobsOnce()` maps this to a handler, so it is the contract
|
|
41
|
+
-- between whoever enqueues and whoever runs.
|
|
42
|
+
kind text NOT NULL,
|
|
43
|
+
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
44
|
+
|
|
45
|
+
-- text + CHECK rather than an enum: an enum is a new type in the consumer's
|
|
46
|
+
-- schema and a migration to extend, and this vocabulary is closed anyway.
|
|
47
|
+
status text NOT NULL DEFAULT 'queued'
|
|
48
|
+
CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
|
|
49
|
+
|
|
50
|
+
-- Scheduling and retry are the SAME mechanism: a failed job is re-queued with
|
|
51
|
+
-- a later `run_after`, and a job scheduled for tomorrow is just one with a
|
|
52
|
+
-- distant `run_after`. One column, so there is no way for the two to disagree.
|
|
53
|
+
run_after timestamptz NOT NULL DEFAULT now(),
|
|
54
|
+
|
|
55
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
56
|
+
max_attempts integer NOT NULL DEFAULT 5,
|
|
57
|
+
|
|
58
|
+
-- Held while a worker owns the row. A worker that crashes leaves these set
|
|
59
|
+
-- and the row in 'running' forever, which is what `reclaimStuckJobs()` looks
|
|
60
|
+
-- for: 'running' plus an old `locked_at` is indistinguishable from a dead
|
|
61
|
+
-- worker, and treating it as one is the only way the work resumes.
|
|
62
|
+
locked_at timestamptz,
|
|
63
|
+
locked_by text,
|
|
64
|
+
|
|
65
|
+
last_error text,
|
|
66
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
67
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
68
|
+
finished_at timestamptz,
|
|
69
|
+
|
|
70
|
+
CONSTRAINT vk_job_attempts_within_max CHECK (attempts <= max_attempts),
|
|
71
|
+
-- At least one, because the claim increments `attempts` before the handler
|
|
72
|
+
-- runs: with a bound of zero the first claim would violate the check above
|
|
73
|
+
-- and the job could never be taken at all.
|
|
74
|
+
CONSTRAINT vk_job_max_attempts_positive CHECK (max_attempts >= 1)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
-- The claim: due, runnable, oldest first. Partial, so it indexes only the rows
|
|
78
|
+
-- a worker can take rather than every job ever run.
|
|
79
|
+
CREATE INDEX IF NOT EXISTS vk_job_runnable_idx
|
|
80
|
+
ON vk_job (run_after)
|
|
81
|
+
WHERE status = 'queued';
|
|
82
|
+
|
|
83
|
+
-- The product's per-tenant job list — the query this table exists to make
|
|
84
|
+
-- possible.
|
|
85
|
+
CREATE INDEX IF NOT EXISTS vk_job_tenant_status_idx
|
|
86
|
+
ON vk_job (tenant_id, status, created_at DESC);
|
|
87
|
+
|
|
88
|
+
-- Finding work abandoned by a dead worker.
|
|
89
|
+
CREATE INDEX IF NOT EXISTS vk_job_stuck_idx
|
|
90
|
+
ON vk_job (locked_at)
|
|
91
|
+
WHERE status = 'running';
|
|
92
|
+
|
|
93
|
+
-- # On row-level security
|
|
94
|
+
--
|
|
95
|
+
-- `vk_install_tenant_guards()` is deliberately NOT applied here, for two
|
|
96
|
+
-- reasons that are both about the worker rather than the reader:
|
|
97
|
+
--
|
|
98
|
+
-- 1. `tenant_id` is nullable, and RLS would make platform-wide rows invisible
|
|
99
|
+
-- to the very process that has to run them;
|
|
100
|
+
-- 2. a worker runs outside any request, so there is no acting tenant to scope
|
|
101
|
+
-- to — it processes every tenant's work by design.
|
|
102
|
+
--
|
|
103
|
+
-- Reads that serve a REQUEST must therefore carry the scope themselves, which
|
|
104
|
+
-- `listJobs()` does (`tenant_id = ANY (vk_tenant_scope())` when a scope is
|
|
105
|
+
-- engaged). That is the same predicate the policy would have applied, so a
|
|
106
|
+
-- caller who goes through the helper cannot see another tenant's work; a caller
|
|
107
|
+
-- who writes their own SQL against this table is responsible for the clause.
|
|
108
|
+
|
|
109
|
+
COMMENT ON TABLE vk_job IS
|
|
110
|
+
'Durable background work, claimable with FOR UPDATE SKIP LOCKED. In Postgres so that work in flight is queryable; see @venturekit/data/jobs.';
|
|
111
|
+
COMMENT ON COLUMN vk_job.run_after IS
|
|
112
|
+
'Earliest time this job may run. Retry backoff and future scheduling are the same mechanism.';
|
|
113
|
+
COMMENT ON COLUMN vk_job.locked_at IS
|
|
114
|
+
'Set while a worker holds the row. Old + status running = a dead worker; reclaimStuckJobs() re-queues it.';
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
-- @venturekit/data — uploaded-object metadata.
|
|
2
|
+
--
|
|
3
|
+
-- Table created by this migration:
|
|
4
|
+
-- vk_file_object — the row that makes an S3 object findable and deletable
|
|
5
|
+
--
|
|
6
|
+
-- Backs `@venturekit/data/files`.
|
|
7
|
+
--
|
|
8
|
+
-- # Why a table when @venturekit/storage already talks to S3
|
|
9
|
+
--
|
|
10
|
+
-- That package is complete on the object: put, get, head, presign, copy, list,
|
|
11
|
+
-- image optimisation. What a bucket cannot tell you is anything a product needs
|
|
12
|
+
-- to know about an upload — which tenant owns it, what it is for, who put it
|
|
13
|
+
-- there, whether the same bytes are already stored, and when it may be deleted.
|
|
14
|
+
-- `ListObjectsV2` plus a key-naming convention is the usual substitute, and it
|
|
15
|
+
-- fails the first time somebody needs "every document for this tenant, newest
|
|
16
|
+
-- first" or a retention rule that differs per purpose.
|
|
17
|
+
--
|
|
18
|
+
-- # The deletion order is the point
|
|
19
|
+
--
|
|
20
|
+
-- Rows are never hard-deleted on the request path. `archived_at` is set, and a
|
|
21
|
+
-- retention sweep removes the object and the row together — but "together" is
|
|
22
|
+
-- not available: there is no transaction spanning S3 and Postgres, so one of the
|
|
23
|
+
-- two failure modes has to be chosen deliberately.
|
|
24
|
+
--
|
|
25
|
+
-- * row first, then object → an orphaned object nobody can account for. Only
|
|
26
|
+
-- discoverable by diffing the entire bucket against this table.
|
|
27
|
+
-- * object first, then row → a row naming a key that 404s. Discoverable by
|
|
28
|
+
-- walking rows, which is cheap, and invisible to users anyway because the
|
|
29
|
+
-- row is already archived.
|
|
30
|
+
--
|
|
31
|
+
-- So the sweep deletes objects first and rows second, and a crash in between
|
|
32
|
+
-- leaves the recoverable state rather than the silent one. `sweepArchivedFiles`
|
|
33
|
+
-- is re-runnable for exactly this reason.
|
|
34
|
+
--
|
|
35
|
+
-- Additive for existing projects: one new table, no existing file edited, and
|
|
36
|
+
-- nothing writes to it until a project calls `recordFileObject()`.
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS vk_file_object (
|
|
39
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
40
|
+
|
|
41
|
+
-- NULL for platform-wide artefacts belonging to no tenant. No foreign key:
|
|
42
|
+
-- the table a tenant lives in is the consumer's, and `vk_tenants` only exists
|
|
43
|
+
-- with @venturekit-pro/tenancy installed.
|
|
44
|
+
tenant_id uuid,
|
|
45
|
+
|
|
46
|
+
-- What the file is FOR, which is what decides its retention and who may read
|
|
47
|
+
-- it. Free text rather than an enum because the vocabulary is the product's —
|
|
48
|
+
-- 'student_photo' carries obligations 'report_artefact' does not, and only the
|
|
49
|
+
-- consumer knows which of theirs is which.
|
|
50
|
+
purpose text NOT NULL,
|
|
51
|
+
|
|
52
|
+
-- Stored, not derived: the bucket changes per stage and per region, and a row
|
|
53
|
+
-- written in one stage must stay resolvable after a restore into another.
|
|
54
|
+
bucket text NOT NULL,
|
|
55
|
+
object_key text NOT NULL,
|
|
56
|
+
|
|
57
|
+
content_type text NOT NULL,
|
|
58
|
+
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
|
|
59
|
+
|
|
60
|
+
-- SHA-256 hex. Lets a re-upload of identical bytes be recognised instead of
|
|
61
|
+
-- duplicated, and lets a restore be verified.
|
|
62
|
+
checksum char(64),
|
|
63
|
+
|
|
64
|
+
-- The name the user's file had. Never the storage key: a key built from user
|
|
65
|
+
-- input is a path-traversal question and a collision waiting to happen.
|
|
66
|
+
original_name text,
|
|
67
|
+
uploaded_by uuid,
|
|
68
|
+
|
|
69
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
70
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
71
|
+
|
|
72
|
+
-- Soft delete. Set on the request path; the retention sweep is what actually
|
|
73
|
+
-- removes the object and then this row.
|
|
74
|
+
archived_at timestamptz,
|
|
75
|
+
|
|
76
|
+
-- One row per object. A second row for the same key would mean two records
|
|
77
|
+
-- claiming one set of bytes, and archiving either would delete the object out
|
|
78
|
+
-- from under the other.
|
|
79
|
+
CONSTRAINT vk_file_object_bucket_key_uniq UNIQUE (bucket, object_key)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
-- "Every live file for this tenant, of this purpose, newest first" — the query
|
|
83
|
+
-- the table exists for. Partial, so archived rows do not bloat it.
|
|
84
|
+
CREATE INDEX IF NOT EXISTS vk_file_object_tenant_purpose_idx
|
|
85
|
+
ON vk_file_object (tenant_id, purpose, created_at DESC)
|
|
86
|
+
WHERE archived_at IS NULL;
|
|
87
|
+
|
|
88
|
+
-- De-duplication on re-upload: same bytes, same tenant, already stored.
|
|
89
|
+
CREATE INDEX IF NOT EXISTS vk_file_object_checksum_idx
|
|
90
|
+
ON vk_file_object (tenant_id, checksum)
|
|
91
|
+
WHERE archived_at IS NULL AND checksum IS NOT NULL;
|
|
92
|
+
|
|
93
|
+
-- The retention sweep's claim.
|
|
94
|
+
CREATE INDEX IF NOT EXISTS vk_file_object_archived_idx
|
|
95
|
+
ON vk_file_object (archived_at)
|
|
96
|
+
WHERE archived_at IS NOT NULL;
|
|
97
|
+
|
|
98
|
+
-- # On row-level security
|
|
99
|
+
--
|
|
100
|
+
-- No policy is installed here, because `vk_install_tenant_guards()` needs the
|
|
101
|
+
-- name of the consumer's application role and this migration cannot know it. A
|
|
102
|
+
-- project that wants RLS on this table should call the installer itself:
|
|
103
|
+
--
|
|
104
|
+
-- SELECT vk_install_tenant_guards('public', 'vk_file_object', 'app_user');
|
|
105
|
+
--
|
|
106
|
+
-- Until then, reads that serve a request must carry the scope predicate, which
|
|
107
|
+
-- `listFileObjects()` and `findFileObjectByChecksum()` do by default.
|
|
108
|
+
|
|
109
|
+
COMMENT ON TABLE vk_file_object IS
|
|
110
|
+
'Metadata for an object in S3. The bytes live in the bucket; this row is what makes them findable, attributable and deletable. See @venturekit/data/files.';
|
|
111
|
+
COMMENT ON COLUMN vk_file_object.archived_at IS
|
|
112
|
+
'Soft delete. The retention sweep deletes the OBJECT first and then this row, so a crash leaves a recoverable dangling row rather than an unaccountable orphaned object.';
|