@venturekit/data 0.0.31 → 0.0.33
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 +23 -3
- package/src/sql/0000_vk_data_foundation.sql +305 -0
- 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
package/dist/query/secret.js
CHANGED
|
@@ -131,7 +131,7 @@ async function fetchAndApply(arn) {
|
|
|
131
131
|
`from Secrets Manager (secret: ${redactedArn}). If the Lambda runs in ` +
|
|
132
132
|
`a VPC, verify it has a route to Secrets Manager: a working NAT gateway/instance, ` +
|
|
133
133
|
`or a com.amazonaws.<region>.secretsmanager VPC interface endpoint. ` +
|
|
134
|
-
`Override the timeout via DB_SECRET_FETCH_TIMEOUT_MS
|
|
134
|
+
`Override the timeout via DB_SECRET_FETCH_TIMEOUT_MS.`, { cause: err });
|
|
135
135
|
}
|
|
136
136
|
throw err;
|
|
137
137
|
}
|
package/dist/query/secret.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secret.js","sourceRoot":"","sources":["../../src/query/secret.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"secret.js","sourceRoot":"","sources":["../../src/query/secret.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@venturekit/data",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.33",
|
|
4
4
|
"description": "Database and data layer for VentureKit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
8
8
|
"files": [
|
|
9
|
-
"dist"
|
|
9
|
+
"dist",
|
|
10
|
+
"src/sql/*.sql"
|
|
10
11
|
],
|
|
11
12
|
"repository": {
|
|
12
13
|
"type": "git",
|
|
@@ -22,15 +23,34 @@
|
|
|
22
23
|
"access": "public"
|
|
23
24
|
},
|
|
24
25
|
"license": "Apache-2.0",
|
|
26
|
+
"vk": {
|
|
27
|
+
"migrations": "src/sql"
|
|
28
|
+
},
|
|
25
29
|
"exports": {
|
|
26
30
|
".": {
|
|
27
31
|
"types": "./dist/index.d.ts",
|
|
28
32
|
"import": "./dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./idempotency": {
|
|
35
|
+
"types": "./dist/idempotency/index.d.ts",
|
|
36
|
+
"import": "./dist/idempotency/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./outbox": {
|
|
39
|
+
"types": "./dist/outbox/index.d.ts",
|
|
40
|
+
"import": "./dist/outbox/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./jobs": {
|
|
43
|
+
"types": "./dist/jobs/index.d.ts",
|
|
44
|
+
"import": "./dist/jobs/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./files": {
|
|
47
|
+
"types": "./dist/files/index.d.ts",
|
|
48
|
+
"import": "./dist/files/index.js"
|
|
29
49
|
}
|
|
30
50
|
},
|
|
31
51
|
"dependencies": {
|
|
32
52
|
"@aws-sdk/client-secrets-manager": "^3.1126.0",
|
|
33
|
-
"@venturekit/core": "0.0.
|
|
53
|
+
"@venturekit/core": "0.0.33",
|
|
34
54
|
"pg": "^8.12.0"
|
|
35
55
|
},
|
|
36
56
|
"devDependencies": {
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
-- @venturekit/data — multi-tenant Postgres foundation.
|
|
2
|
+
--
|
|
3
|
+
-- Functions created by this migration:
|
|
4
|
+
-- vk_uuid_generate_v7() — time-ordered primary keys
|
|
5
|
+
-- vk_tenant_scope() — every tenant this request may READ
|
|
6
|
+
-- vk_acting_tenant() — the single tenant it may WRITE as
|
|
7
|
+
-- vk_install_tenant_guards(schema, table, role) — forced RLS + policy + grants
|
|
8
|
+
-- vk_install_catalog_guards(schema, table, role) — read-only grants for shared catalogs
|
|
9
|
+
--
|
|
10
|
+
-- # Why this is the framework's job
|
|
11
|
+
--
|
|
12
|
+
-- `runWithTenantScope()` in this package publishes `app.tenant_id` and
|
|
13
|
+
-- `app.tenant_ids` as Postgres GUCs. That is only half a tenancy feature: the
|
|
14
|
+
-- GUCs do nothing until a policy reads them, and until now every consumer had
|
|
15
|
+
-- to write that policy — and the `ENABLE`/`FORCE`/`GRANT` around it — by hand,
|
|
16
|
+
-- once per table. A project with sixty tenant-scoped tables hand-writes ~300
|
|
17
|
+
-- lines in which a single omission is invisible: the table still works, queries
|
|
18
|
+
-- still return rows, and the only symptom is that one table has no isolation at
|
|
19
|
+
-- all. Shipping the reader and the installer next to the writer of the GUCs
|
|
20
|
+
-- makes that omission impossible to write by accident.
|
|
21
|
+
--
|
|
22
|
+
-- Row-level security is also the one guarantee that CANNOT move into
|
|
23
|
+
-- application code: Postgres enforces it against the connection, which is
|
|
24
|
+
-- exactly what makes it hold when a query forgets its WHERE clause.
|
|
25
|
+
--
|
|
26
|
+
-- # The role split RLS depends on
|
|
27
|
+
--
|
|
28
|
+
-- RLS is skipped for superusers, for roles with BYPASSRLS, and for a table's
|
|
29
|
+
-- owner unless FORCE ROW LEVEL SECURITY is set. So:
|
|
30
|
+
--
|
|
31
|
+
-- * migrations run as the OWNER (they must create and alter tables);
|
|
32
|
+
-- * the application connects as the tenant role — `DatabaseIntent.tenantRole`
|
|
33
|
+
-- in `vk.config.ts` — which owns nothing and has neither attribute, and is
|
|
34
|
+
-- granted only DML.
|
|
35
|
+
--
|
|
36
|
+
-- FORCE is set anyway, so even a mistaken connection as the owner is filtered.
|
|
37
|
+
-- Local development against a superuser bypasses all of it, which is why
|
|
38
|
+
-- isolation tests must connect as the tenant role explicitly or they pass while
|
|
39
|
+
-- proving nothing.
|
|
40
|
+
--
|
|
41
|
+
-- Names are `vk_`-prefixed and unqualified, matching `vk_tenants` and
|
|
42
|
+
-- `vk_notifications`: a package-owned object must not collide with a consumer's
|
|
43
|
+
-- own, and creating a schema in someone else's database is not this package's
|
|
44
|
+
-- call to make. A project that prefers its own namespace can wrap these —
|
|
45
|
+
-- `CREATE OR REPLACE FUNCTION sys.tenant_scope() ... SELECT <schema>.vk_tenant_scope()`
|
|
46
|
+
-- — which keeps one implementation while leaving existing call sites alone.
|
|
47
|
+
--
|
|
48
|
+
-- # Why the `0000_` filename prefix
|
|
49
|
+
--
|
|
50
|
+
-- The migration runner merges the project's own `.sql` files with every
|
|
51
|
+
-- installed package's and applies them in ALPHABETIC order, which puts the
|
|
52
|
+
-- conventional `vk_<pkg>_<NNN>_*` names *after* a project's `0xx_*` files. That
|
|
53
|
+
-- is right for tables a project only reads, and wrong for these: a consumer's
|
|
54
|
+
-- own migration calls `vk_install_tenant_guards(...)` as it creates each table,
|
|
55
|
+
-- so the functions must already exist. Worse, a `LANGUAGE sql` wrapper around
|
|
56
|
+
-- one of them is validated when it is CREATEd, so a project delegating to these
|
|
57
|
+
-- would fail at migrate time rather than at call time.
|
|
58
|
+
--
|
|
59
|
+
-- So this file takes the same one-character departure, for the same reason, as
|
|
60
|
+
-- `@venturekit-pro/tenancy`'s `0000_vk_tenancy_tenants.sql`: primordial-parent
|
|
61
|
+
-- status. Sorting before `0000_vk_tenancy_*` is incidental - neither depends on
|
|
62
|
+
-- the other.
|
|
63
|
+
--
|
|
64
|
+
-- # Effect on projects already in production
|
|
65
|
+
--
|
|
66
|
+
-- Additive, and safe to pick up late. The runner tracks applied files by name
|
|
67
|
+
-- and applies any it has not seen, so an existing database simply gains these
|
|
68
|
+
-- five functions on its next migrate; the `0000_` name does not imply
|
|
69
|
+
-- re-running anything, and nothing here depends on schema that a later
|
|
70
|
+
-- migration created. No existing file is touched, which is the thing that would
|
|
71
|
+
-- break - `vk migrate` hashes every applied file and raises
|
|
72
|
+
-- `MigrationHashMismatchError` if one changes. `pgcrypto` is already created by
|
|
73
|
+
-- `@venturekit-pro/tenancy`.
|
|
74
|
+
--
|
|
75
|
+
-- Idempotent throughout (`CREATE OR REPLACE`, `IF NOT EXISTS`), so re-applying
|
|
76
|
+
-- after a partial failure never breaks a deploy.
|
|
77
|
+
|
|
78
|
+
-- ─── Extensions ─────────────────────────────────────────────────────────
|
|
79
|
+
-- `pgcrypto` for gen_random_bytes, which the v7 generator below needs.
|
|
80
|
+
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
81
|
+
|
|
82
|
+
-- ─── Identifiers ────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Time-ordered uuid (RFC 9562 v7).
|
|
86
|
+
*
|
|
87
|
+
* v4 keys scatter inserts across the whole index, so a busy table's hot page
|
|
88
|
+
* set is the entire index rather than its right edge — measurable write
|
|
89
|
+
* amplification once a table is taking millions of rows a day. v7 puts a
|
|
90
|
+
* millisecond timestamp in the leading 48 bits, restoring insert locality while
|
|
91
|
+
* still not exposing a guessable sequence.
|
|
92
|
+
*
|
|
93
|
+
* `clock_timestamp()` rather than `now()`: `now()` is fixed for the whole
|
|
94
|
+
* transaction, so a bulk insert of 5 000 rows would emit 5 000 keys sharing one
|
|
95
|
+
* timestamp prefix and lose the ordering this exists to provide.
|
|
96
|
+
*/
|
|
97
|
+
CREATE OR REPLACE FUNCTION vk_uuid_generate_v7()
|
|
98
|
+
RETURNS uuid
|
|
99
|
+
LANGUAGE plpgsql
|
|
100
|
+
VOLATILE
|
|
101
|
+
PARALLEL SAFE
|
|
102
|
+
AS $$
|
|
103
|
+
DECLARE
|
|
104
|
+
ts_ms bigint := (extract(epoch FROM clock_timestamp()) * 1000)::bigint;
|
|
105
|
+
raw bytea;
|
|
106
|
+
BEGIN
|
|
107
|
+
-- 48-bit big-endian millisecond timestamp, then 80 random bits.
|
|
108
|
+
raw := substring(int8send(ts_ms) FROM 3 FOR 6) || gen_random_bytes(10);
|
|
109
|
+
-- Version 7 in the high nibble of octet 6.
|
|
110
|
+
raw := set_byte(raw, 6, (get_byte(raw, 6) & 15) | 112);
|
|
111
|
+
-- RFC 4122 variant (10xx) in the high bits of octet 8.
|
|
112
|
+
raw := set_byte(raw, 8, (get_byte(raw, 8) & 63) | 128);
|
|
113
|
+
RETURN encode(raw, 'hex')::uuid;
|
|
114
|
+
END;
|
|
115
|
+
$$;
|
|
116
|
+
|
|
117
|
+
COMMENT ON FUNCTION vk_uuid_generate_v7() IS
|
|
118
|
+
'Time-ordered uuid v7 (RFC 9562). Insert-local alternative to gen_random_uuid().';
|
|
119
|
+
|
|
120
|
+
-- ─── Tenancy ────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Every tenant the current request may READ, or NULL when un-scoped.
|
|
124
|
+
*
|
|
125
|
+
* Reads `app.tenant_ids`, published by this package's `runWithTenantScope()`.
|
|
126
|
+
* A parent tenant's request carries itself plus its descendants, so "a group
|
|
127
|
+
* owner sees their child tenants" is satisfied by this one policy instead of a
|
|
128
|
+
* branch in every query.
|
|
129
|
+
*
|
|
130
|
+
* NULL is the fail-closed sentinel: `x = ANY (NULL)` is NULL, so an un-scoped
|
|
131
|
+
* request sees nothing rather than everything. The `true` argument to
|
|
132
|
+
* `current_setting` makes an unset GUC return NULL instead of raising, which is
|
|
133
|
+
* what lets a migration or an admin connection run outside any request scope.
|
|
134
|
+
*
|
|
135
|
+
* STABLE, so the planner evaluates it once per statement rather than per row.
|
|
136
|
+
*/
|
|
137
|
+
CREATE OR REPLACE FUNCTION vk_tenant_scope()
|
|
138
|
+
RETURNS uuid[]
|
|
139
|
+
LANGUAGE sql
|
|
140
|
+
STABLE
|
|
141
|
+
PARALLEL SAFE
|
|
142
|
+
AS $$
|
|
143
|
+
SELECT CASE
|
|
144
|
+
WHEN coalesce(current_setting('app.tenant_ids', true), '') = '' THEN NULL::uuid[]
|
|
145
|
+
ELSE string_to_array(current_setting('app.tenant_ids', true), ',')::uuid[]
|
|
146
|
+
END;
|
|
147
|
+
$$;
|
|
148
|
+
|
|
149
|
+
COMMENT ON FUNCTION vk_tenant_scope() IS
|
|
150
|
+
'Tenants readable by this request (app.tenant_ids). NULL = un-scoped = no rows.';
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The single tenant this request ACTS as — `app.tenant_id`, the acting tenant
|
|
154
|
+
* of the scope.
|
|
155
|
+
*
|
|
156
|
+
* Writes are checked against this, not against the whole scope: a parent-tenant
|
|
157
|
+
* administrator reading twelve child tenants should not be able to create a row
|
|
158
|
+
* in one of them by accident. Widening a write to another tenant has to be a
|
|
159
|
+
* deliberate re-scope, which is visible in the code that does it.
|
|
160
|
+
*/
|
|
161
|
+
CREATE OR REPLACE FUNCTION vk_acting_tenant()
|
|
162
|
+
RETURNS uuid
|
|
163
|
+
LANGUAGE sql
|
|
164
|
+
STABLE
|
|
165
|
+
PARALLEL SAFE
|
|
166
|
+
AS $$
|
|
167
|
+
SELECT nullif(current_setting('app.tenant_id', true), '')::uuid;
|
|
168
|
+
$$;
|
|
169
|
+
|
|
170
|
+
COMMENT ON FUNCTION vk_acting_tenant() IS
|
|
171
|
+
'The tenant this request may WRITE as (app.tenant_id). NULL = un-scoped.';
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Attach the standard guards to a tenant-scoped table:
|
|
175
|
+
*
|
|
176
|
+
* 1. RLS, ENABLEd and FORCEd;
|
|
177
|
+
* 2. the isolation policy — read across the scope, write only as the acting
|
|
178
|
+
* tenant;
|
|
179
|
+
* 3. DML grants for `p_role`.
|
|
180
|
+
*
|
|
181
|
+
* This is DDL run at migration time, not runtime behaviour: a macro that
|
|
182
|
+
* expands to the statements every tenant-scoped table needs.
|
|
183
|
+
*
|
|
184
|
+
* `p_role` is a parameter rather than a constant because the application role is
|
|
185
|
+
* the consumer's (`DatabaseIntent.tenantRole`), and a framework that hard-coded
|
|
186
|
+
* one would work for exactly one project. It is validated against `pg_roles`
|
|
187
|
+
* and interpolated with `%I`, so it cannot carry SQL.
|
|
188
|
+
*
|
|
189
|
+
* Idempotent, so re-running a migration during development is safe. The table
|
|
190
|
+
* must have a `tenant_id` column, asserted rather than assumed because a table
|
|
191
|
+
* that silently skipped its policy is exactly the failure this prevents.
|
|
192
|
+
*/
|
|
193
|
+
CREATE OR REPLACE FUNCTION vk_install_tenant_guards(
|
|
194
|
+
p_schema text,
|
|
195
|
+
p_table text,
|
|
196
|
+
p_role text
|
|
197
|
+
)
|
|
198
|
+
RETURNS void
|
|
199
|
+
LANGUAGE plpgsql
|
|
200
|
+
AS $$
|
|
201
|
+
DECLARE
|
|
202
|
+
qualified text := format('%I.%I', p_schema, p_table);
|
|
203
|
+
fn_schema text;
|
|
204
|
+
BEGIN
|
|
205
|
+
IF NOT EXISTS (
|
|
206
|
+
SELECT 1 FROM information_schema.columns
|
|
207
|
+
WHERE table_schema = p_schema AND table_name = p_table AND column_name = 'tenant_id'
|
|
208
|
+
) THEN
|
|
209
|
+
RAISE EXCEPTION 'vk_install_tenant_guards: %.% has no tenant_id column', p_schema, p_table;
|
|
210
|
+
END IF;
|
|
211
|
+
|
|
212
|
+
-- A missing role is a misconfiguration, not something to paper over: the
|
|
213
|
+
-- grant would fail anyway, and failing here names the cause.
|
|
214
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = p_role) THEN
|
|
215
|
+
RAISE EXCEPTION 'vk_install_tenant_guards: role % does not exist', p_role;
|
|
216
|
+
END IF;
|
|
217
|
+
|
|
218
|
+
/* The policy must name the scope functions SCHEMA-QUALIFIED.
|
|
219
|
+
A policy expression is re-resolved against the *querying* session's
|
|
220
|
+
`search_path`, not the one in effect when the policy was created. Left
|
|
221
|
+
bare, `vk_tenant_scope()` would fail — or, far worse, resolve to a
|
|
222
|
+
same-named function in a schema earlier on some connection's path, which
|
|
223
|
+
is a tenant-isolation bypass that no test connecting normally would see.
|
|
224
|
+
So resolve where this migration actually put them, once, here. */
|
|
225
|
+
SELECT n.nspname INTO fn_schema
|
|
226
|
+
FROM pg_proc p
|
|
227
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
228
|
+
WHERE p.proname = 'vk_tenant_scope'
|
|
229
|
+
LIMIT 1;
|
|
230
|
+
|
|
231
|
+
IF fn_schema IS NULL THEN
|
|
232
|
+
RAISE EXCEPTION 'vk_install_tenant_guards: vk_tenant_scope() not found — is @venturekit/data''s migration applied?';
|
|
233
|
+
END IF;
|
|
234
|
+
|
|
235
|
+
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', qualified);
|
|
236
|
+
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', qualified);
|
|
237
|
+
|
|
238
|
+
EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %s', qualified);
|
|
239
|
+
EXECUTE format(
|
|
240
|
+
'CREATE POLICY tenant_isolation ON %s
|
|
241
|
+
USING (tenant_id = ANY (%I.vk_tenant_scope()))
|
|
242
|
+
WITH CHECK (tenant_id = %I.vk_acting_tenant())',
|
|
243
|
+
qualified, fn_schema, fn_schema);
|
|
244
|
+
|
|
245
|
+
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON %s TO %I', qualified, p_role);
|
|
246
|
+
END;
|
|
247
|
+
$$;
|
|
248
|
+
|
|
249
|
+
COMMENT ON FUNCTION vk_install_tenant_guards(text, text, text) IS
|
|
250
|
+
'Attach forced RLS, the isolation policy and DML grants. Call once per tenant-scoped table.';
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Grants for a table that is deliberately NOT tenant-scoped — the shared
|
|
254
|
+
* catalogs every tenant reads and none may edit.
|
|
255
|
+
*
|
|
256
|
+
* Read-only to the application on purpose: these rows are curated centrally,
|
|
257
|
+
* and a tenant-facing service editing a shared catalog is how one tenant's
|
|
258
|
+
* data-entry mistake becomes everyone's.
|
|
259
|
+
*/
|
|
260
|
+
CREATE OR REPLACE FUNCTION vk_install_catalog_guards(
|
|
261
|
+
p_schema text,
|
|
262
|
+
p_table text,
|
|
263
|
+
p_role text
|
|
264
|
+
)
|
|
265
|
+
RETURNS void
|
|
266
|
+
LANGUAGE plpgsql
|
|
267
|
+
AS $$
|
|
268
|
+
DECLARE
|
|
269
|
+
qualified text := format('%I.%I', p_schema, p_table);
|
|
270
|
+
BEGIN
|
|
271
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = p_role) THEN
|
|
272
|
+
RAISE EXCEPTION 'vk_install_catalog_guards: role % does not exist', p_role;
|
|
273
|
+
END IF;
|
|
274
|
+
|
|
275
|
+
EXECUTE format('GRANT SELECT ON %s TO %I', qualified, p_role);
|
|
276
|
+
END;
|
|
277
|
+
$$;
|
|
278
|
+
|
|
279
|
+
COMMENT ON FUNCTION vk_install_catalog_guards(text, text, text) IS
|
|
280
|
+
'Read-only grants for a shared, non-tenant-scoped catalog table.';
|
|
281
|
+
|
|
282
|
+
-- ─── Auditing the result ────────────────────────────────────────────────
|
|
283
|
+
--
|
|
284
|
+
-- "Which tenant-scoped tables are missing RLS?" is a query, not a view, so it
|
|
285
|
+
-- can live in the test that asserts it rather than in a schema object nobody
|
|
286
|
+
-- reads:
|
|
287
|
+
--
|
|
288
|
+
-- SELECT c.relname
|
|
289
|
+
-- FROM pg_class c
|
|
290
|
+
-- JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
291
|
+
-- WHERE n.nspname = ANY ($1::text[])
|
|
292
|
+
-- AND c.relkind = 'r'
|
|
293
|
+
-- AND EXISTS (SELECT 1 FROM information_schema.columns
|
|
294
|
+
-- WHERE table_schema = n.nspname AND table_name = c.relname
|
|
295
|
+
-- AND column_name = 'tenant_id')
|
|
296
|
+
-- AND NOT (c.relrowsecurity AND EXISTS (
|
|
297
|
+
-- SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid));
|
|
298
|
+
--
|
|
299
|
+
-- A table counts as protected when RLS is ENABLEd and at least one policy
|
|
300
|
+
-- exists. FORCE is deliberately not required by that check: it applies the
|
|
301
|
+
-- policy to the table owner too, and trusted processes that legitimately write
|
|
302
|
+
-- across tenants (an event projector, an ingest listener, catalog seeds) run as
|
|
303
|
+
-- the owner. What matters is that the tenant role — the only one serving user
|
|
304
|
+
-- requests, and which owns nothing — is filtered. ENABLE plus a policy
|
|
305
|
+
-- guarantees that.
|
|
@@ -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.';
|