@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.
Files changed (47) hide show
  1. package/dist/files/index.d.ts +9 -0
  2. package/dist/files/index.d.ts.map +1 -0
  3. package/dist/files/index.js +8 -0
  4. package/dist/files/index.js.map +1 -0
  5. package/dist/files/postgres.d.ts +150 -0
  6. package/dist/files/postgres.d.ts.map +1 -0
  7. package/dist/files/postgres.js +194 -0
  8. package/dist/files/postgres.js.map +1 -0
  9. package/dist/idempotency/index.d.ts +9 -0
  10. package/dist/idempotency/index.d.ts.map +1 -0
  11. package/dist/idempotency/index.js +8 -0
  12. package/dist/idempotency/index.js.map +1 -0
  13. package/dist/idempotency/postgres.d.ts +107 -0
  14. package/dist/idempotency/postgres.d.ts.map +1 -0
  15. package/dist/idempotency/postgres.js +145 -0
  16. package/dist/idempotency/postgres.js.map +1 -0
  17. package/dist/internal/identifier.d.ts +16 -0
  18. package/dist/internal/identifier.d.ts.map +1 -0
  19. package/dist/internal/identifier.js +23 -0
  20. package/dist/internal/identifier.js.map +1 -0
  21. package/dist/jobs/index.d.ts +9 -0
  22. package/dist/jobs/index.d.ts.map +1 -0
  23. package/dist/jobs/index.js +8 -0
  24. package/dist/jobs/index.js.map +1 -0
  25. package/dist/jobs/postgres.d.ts +197 -0
  26. package/dist/jobs/postgres.d.ts.map +1 -0
  27. package/dist/jobs/postgres.js +270 -0
  28. package/dist/jobs/postgres.js.map +1 -0
  29. package/dist/outbox/index.d.ts +9 -0
  30. package/dist/outbox/index.d.ts.map +1 -0
  31. package/dist/outbox/index.js +8 -0
  32. package/dist/outbox/index.js.map +1 -0
  33. package/dist/outbox/postgres.d.ts +124 -0
  34. package/dist/outbox/postgres.d.ts.map +1 -0
  35. package/dist/outbox/postgres.js +177 -0
  36. package/dist/outbox/postgres.js.map +1 -0
  37. package/dist/query/index.d.ts.map +1 -1
  38. package/dist/query/index.js.map +1 -1
  39. package/dist/query/secret.d.ts.map +1 -1
  40. package/dist/query/secret.js +1 -1
  41. package/dist/query/secret.js.map +1 -1
  42. package/package.json +18 -2
  43. package/src/sql/{vk_data_001_tenancy_foundation.sql → 0000_vk_data_foundation.sql} +305 -278
  44. package/src/sql/vk_data_001_idempotency.sql +48 -0
  45. package/src/sql/vk_data_002_outbox.sql +99 -0
  46. package/src/sql/vk_data_003_jobs.sql +114 -0
  47. 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.';