@venturekit/data 0.0.32 → 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.
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,9 @@
1
+ /**
2
+ * @venturekit/data — uploaded-object metadata.
3
+ *
4
+ * The record of what was uploaded; `@venturekit/storage` holds the bytes. See
5
+ * `./postgres.ts`.
6
+ */
7
+ export { recordFileObject, getFileObject, listFileObjects, findFileObjectByChecksum, archiveFileObject, sweepArchivedFiles, DEFAULT_FILE_TABLE, } from './postgres.js';
8
+ export type { FileObject, RecordFileObjectInput, ListFileObjectsOptions, SweepArchivedFilesOptions, SweepResult, ObjectDeleter, FileTableOption, FileQuerierOption, } from './postgres.js';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/files/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,eAAe,CAAC;AAEvB,YAAY,EACV,UAAU,EACV,qBAAqB,EACrB,sBAAsB,EACtB,yBAAyB,EACzB,WAAW,EACX,aAAa,EACb,eAAe,EACf,iBAAiB,GAClB,MAAM,eAAe,CAAC"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @venturekit/data — uploaded-object metadata.
3
+ *
4
+ * The record of what was uploaded; `@venturekit/storage` holds the bytes. See
5
+ * `./postgres.ts`.
6
+ */
7
+ export { recordFileObject, getFileObject, listFileObjects, findFileObjectByChecksum, archiveFileObject, sweepArchivedFiles, DEFAULT_FILE_TABLE, } from './postgres.js';
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/files/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,eAAe,CAAC"}
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @venturekit/data — uploaded-object metadata.
3
+ *
4
+ * `@venturekit/storage` is complete on the object itself: put, get, head,
5
+ * presign, copy, image optimisation. What a bucket cannot tell you is anything a
6
+ * product needs to know about an upload — which tenant owns it, what it is for,
7
+ * who put it there, whether these exact bytes are already stored, and when it
8
+ * may be deleted. `ListObjectsV2` plus a key-naming convention is the usual
9
+ * substitute and it fails at the first "every document for this tenant, newest
10
+ * first".
11
+ *
12
+ * This module is the record; that package is the bytes. They are deliberately
13
+ * not coupled: `@venturekit/storage` has no `pg` dependency and this has no
14
+ * `@aws-sdk/client-s3` one, so {@link sweepArchivedFiles} takes the object
15
+ * deletion as a port and the consumer wires the two together:
16
+ *
17
+ * ```ts
18
+ * await sweepArchivedFiles({
19
+ * deleteObjects: (keys) => storage.deleteMany(keys).then(() => undefined),
20
+ * });
21
+ * ```
22
+ */
23
+ import type { Querier } from '../query/index.js';
24
+ /** Default table name, created by `vk_data_004_file_object.sql`. */
25
+ export declare const DEFAULT_FILE_TABLE = "vk_file_object";
26
+ export interface FileObject {
27
+ id: string;
28
+ tenantId: string | null;
29
+ purpose: string;
30
+ bucket: string;
31
+ objectKey: string;
32
+ contentType: string;
33
+ sizeBytes: number;
34
+ checksum: string | null;
35
+ originalName: string | null;
36
+ uploadedBy: string | null;
37
+ createdAt: string;
38
+ archivedAt: string | null;
39
+ }
40
+ export interface RecordFileObjectInput {
41
+ /** What the file is for. Decides retention and access in your product. */
42
+ purpose: string;
43
+ bucket: string;
44
+ objectKey: string;
45
+ contentType: string;
46
+ sizeBytes: number;
47
+ /** `null` for platform-wide artefacts. */
48
+ tenantId?: string | null;
49
+ /** SHA-256 hex, so a re-upload of identical bytes can be recognised. */
50
+ checksum?: string | null;
51
+ /** The name the user's file had — never the storage key. */
52
+ originalName?: string | null;
53
+ uploadedBy?: string | null;
54
+ }
55
+ export interface FileTableOption {
56
+ /** Table name. Validated as an identifier, since it is interpolated. */
57
+ tableName?: string;
58
+ }
59
+ export interface FileQuerierOption extends FileTableOption {
60
+ /** Runs the statements. Defaults to the pooled `query`. */
61
+ querier?: Querier;
62
+ }
63
+ /**
64
+ * Record an uploaded object.
65
+ *
66
+ * Call it in the transaction that also stores whatever references the file, so a
67
+ * rolled-back request cannot leave a row pointing at an object nothing uses —
68
+ * which is why `querier` is a required first argument here as in
69
+ * `appendToOutbox` and `enqueueJob`.
70
+ *
71
+ * **Record after the upload succeeds, not before.** The reverse ordering (row,
72
+ * then upload) produces a row whose object never arrived, and nothing
73
+ * distinguishes that from an object deleted later.
74
+ */
75
+ export declare function recordFileObject(querier: Querier, input: RecordFileObjectInput, options?: FileTableOption): Promise<string>;
76
+ /** One file by id. Archived rows are returned — callers may need to show them. */
77
+ export declare function getFileObject(id: string, options?: FileQuerierOption): Promise<FileObject | null>;
78
+ export interface ListFileObjectsOptions extends FileQuerierOption {
79
+ purpose?: string;
80
+ limit?: number;
81
+ /** Include archived rows. Default `false`. */
82
+ includeArchived?: boolean;
83
+ /**
84
+ * Confine to the request's tenant scope (`vk_tenant_scope()`). Default
85
+ * `true`: this table carries no RLS policy by default, because the installer
86
+ * needs the consumer's role name, so the predicate is applied here instead.
87
+ */
88
+ tenantScoped?: boolean;
89
+ }
90
+ /** Live files for the current scope, newest first. */
91
+ export declare function listFileObjects(options?: ListFileObjectsOptions): Promise<FileObject[]>;
92
+ /**
93
+ * Find a live file with these exact bytes, for de-duplication on re-upload.
94
+ *
95
+ * Scoped to the tenant by default and deliberately so: matching a checksum
96
+ * across tenants would let one tenant's upload resolve to another's object,
97
+ * which is a cross-tenant read dressed up as an optimisation.
98
+ */
99
+ export declare function findFileObjectByChecksum(checksum: string, options?: ListFileObjectsOptions): Promise<FileObject | null>;
100
+ /**
101
+ * Mark a file deleted without touching the object.
102
+ *
103
+ * This is what the request path calls. Returns `false` when the row is already
104
+ * archived or absent, so a double-click is not an error.
105
+ */
106
+ export declare function archiveFileObject(id: string, options?: FileQuerierOption): Promise<boolean>;
107
+ /** Deletes objects from wherever they live. `@venturekit/storage`'s
108
+ * `deleteMany` satisfies this. */
109
+ export type ObjectDeleter = (keys: string[]) => Promise<void>;
110
+ export interface SweepArchivedFilesOptions extends FileQuerierOption {
111
+ /** Removes the bytes. Called before the rows are deleted. */
112
+ deleteObjects: ObjectDeleter;
113
+ /**
114
+ * Grace period before an archived file is really removed. Default 604800 (7
115
+ * days) — long enough that "undo" is possible and that a bad bulk-archive can
116
+ * be caught before the bytes are gone.
117
+ */
118
+ retainForSeconds?: number;
119
+ /** Rows per pass. Default 100. */
120
+ limit?: number;
121
+ }
122
+ export interface SweepResult {
123
+ /** Rows whose object and record were both removed. */
124
+ deleted: number;
125
+ /** Rows considered but left alone because the object deletion threw. */
126
+ remaining: number;
127
+ }
128
+ /**
129
+ * Delete the objects and rows of files archived longer ago than the grace
130
+ * period.
131
+ *
132
+ * **Objects first, then rows**, and the order is the whole design. There is no
133
+ * transaction spanning S3 and Postgres, so one of two failure modes has to be
134
+ * chosen:
135
+ *
136
+ * - rows first → an orphaned object nobody can account for, discoverable only
137
+ * by diffing the entire bucket against this table;
138
+ * - objects first → a row naming a key that 404s, discoverable by walking
139
+ * rows, and invisible to users because the row is already archived.
140
+ *
141
+ * The second is recoverable and the first is silent, so the sweep takes the
142
+ * second. It is re-runnable for the same reason: a crash after the delete
143
+ * re-deletes keys that are already gone, which S3 treats as success.
144
+ *
145
+ * If `deleteObjects` throws, the rows are left for the next pass rather than
146
+ * deleted anyway — which would produce exactly the orphan this ordering exists
147
+ * to avoid.
148
+ */
149
+ export declare function sweepArchivedFiles(options: SweepArchivedFilesOptions): Promise<SweepResult>;
150
+ //# sourceMappingURL=postgres.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.d.ts","sourceRoot":"","sources":["../../src/files/postgres.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,194 @@
1
+ /**
2
+ * @venturekit/data — uploaded-object metadata.
3
+ *
4
+ * `@venturekit/storage` is complete on the object itself: put, get, head,
5
+ * presign, copy, image optimisation. What a bucket cannot tell you is anything a
6
+ * product needs to know about an upload — which tenant owns it, what it is for,
7
+ * who put it there, whether these exact bytes are already stored, and when it
8
+ * may be deleted. `ListObjectsV2` plus a key-naming convention is the usual
9
+ * substitute and it fails at the first "every document for this tenant, newest
10
+ * first".
11
+ *
12
+ * This module is the record; that package is the bytes. They are deliberately
13
+ * not coupled: `@venturekit/storage` has no `pg` dependency and this has no
14
+ * `@aws-sdk/client-s3` one, so {@link sweepArchivedFiles} takes the object
15
+ * deletion as a port and the consumer wires the two together:
16
+ *
17
+ * ```ts
18
+ * await sweepArchivedFiles({
19
+ * deleteObjects: (keys) => storage.deleteMany(keys).then(() => undefined),
20
+ * });
21
+ * ```
22
+ */
23
+ import { query } from '../query/index.js';
24
+ import { assertSqlIdentifier } from '../internal/identifier.js';
25
+ /** Default table name, created by `vk_data_004_file_object.sql`. */
26
+ export const DEFAULT_FILE_TABLE = 'vk_file_object';
27
+ const iso = (v) => v instanceof Date ? v.toISOString() : v;
28
+ function mapRow(r) {
29
+ return {
30
+ id: r.id,
31
+ tenantId: r.tenant_id,
32
+ purpose: r.purpose,
33
+ bucket: r.bucket,
34
+ objectKey: r.object_key,
35
+ contentType: r.content_type,
36
+ /* `bigint` arrives as a string from node-postgres. A file over 9 PB would
37
+ lose precision as a Number; a file over 9 PB is not the problem here. */
38
+ sizeBytes: typeof r.size_bytes === 'string' ? Number(r.size_bytes) : r.size_bytes,
39
+ checksum: r.checksum,
40
+ originalName: r.original_name,
41
+ uploadedBy: r.uploaded_by,
42
+ createdAt: iso(r.created_at),
43
+ archivedAt: r.archived_at === null ? null : iso(r.archived_at),
44
+ };
45
+ }
46
+ const COLUMNS = `id, tenant_id, purpose, bucket, object_key, content_type,
47
+ size_bytes, checksum, original_name, uploaded_by, created_at,
48
+ archived_at`;
49
+ function tableOf(options, context) {
50
+ const table = options.tableName ?? DEFAULT_FILE_TABLE;
51
+ assertSqlIdentifier(table, context);
52
+ return table;
53
+ }
54
+ /**
55
+ * Record an uploaded object.
56
+ *
57
+ * Call it in the transaction that also stores whatever references the file, so a
58
+ * rolled-back request cannot leave a row pointing at an object nothing uses —
59
+ * which is why `querier` is a required first argument here as in
60
+ * `appendToOutbox` and `enqueueJob`.
61
+ *
62
+ * **Record after the upload succeeds, not before.** The reverse ordering (row,
63
+ * then upload) produces a row whose object never arrived, and nothing
64
+ * distinguishes that from an object deleted later.
65
+ */
66
+ export async function recordFileObject(querier, input, options = {}) {
67
+ const table = tableOf(options, 'recordFileObject.tableName');
68
+ const rows = await querier(`INSERT INTO ${table}
69
+ (tenant_id, purpose, bucket, object_key, content_type, size_bytes,
70
+ checksum, original_name, uploaded_by)
71
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
72
+ RETURNING id`, [
73
+ input.tenantId ?? null,
74
+ input.purpose,
75
+ input.bucket,
76
+ input.objectKey,
77
+ input.contentType,
78
+ input.sizeBytes,
79
+ input.checksum ?? null,
80
+ input.originalName ?? null,
81
+ input.uploadedBy ?? null,
82
+ ]);
83
+ const id = rows[0]?.id;
84
+ if (!id) {
85
+ throw new Error('[venturekit/data] recordFileObject: insert returned no id');
86
+ }
87
+ return id;
88
+ }
89
+ /** One file by id. Archived rows are returned — callers may need to show them. */
90
+ export async function getFileObject(id, options = {}) {
91
+ const table = tableOf(options, 'getFileObject.tableName');
92
+ const run = options.querier ?? query;
93
+ const rows = await run(`SELECT ${COLUMNS} FROM ${table} WHERE id = $1`, [id]);
94
+ const row = rows[0];
95
+ return row ? mapRow(row) : null;
96
+ }
97
+ /** Live files for the current scope, newest first. */
98
+ export async function listFileObjects(options = {}) {
99
+ const table = tableOf(options, 'listFileObjects.tableName');
100
+ const run = options.querier ?? query;
101
+ const rows = await run(`SELECT ${COLUMNS}
102
+ FROM ${table}
103
+ WHERE ($1::text IS NULL OR purpose = $1)
104
+ AND ($3::boolean OR archived_at IS NULL)
105
+ AND (NOT $4::boolean OR tenant_id = ANY (vk_tenant_scope()))
106
+ ORDER BY created_at DESC
107
+ LIMIT $2`, [
108
+ options.purpose ?? null,
109
+ options.limit ?? 50,
110
+ options.includeArchived === true,
111
+ options.tenantScoped !== false,
112
+ ]);
113
+ return rows.map(mapRow);
114
+ }
115
+ /**
116
+ * Find a live file with these exact bytes, for de-duplication on re-upload.
117
+ *
118
+ * Scoped to the tenant by default and deliberately so: matching a checksum
119
+ * across tenants would let one tenant's upload resolve to another's object,
120
+ * which is a cross-tenant read dressed up as an optimisation.
121
+ */
122
+ export async function findFileObjectByChecksum(checksum, options = {}) {
123
+ const table = tableOf(options, 'findFileObjectByChecksum.tableName');
124
+ const run = options.querier ?? query;
125
+ const rows = await run(`SELECT ${COLUMNS}
126
+ FROM ${table}
127
+ WHERE checksum = $1
128
+ AND archived_at IS NULL
129
+ AND (NOT $2::boolean OR tenant_id = ANY (vk_tenant_scope()))
130
+ ORDER BY created_at
131
+ LIMIT 1`, [checksum, options.tenantScoped !== false]);
132
+ const row = rows[0];
133
+ return row ? mapRow(row) : null;
134
+ }
135
+ /**
136
+ * Mark a file deleted without touching the object.
137
+ *
138
+ * This is what the request path calls. Returns `false` when the row is already
139
+ * archived or absent, so a double-click is not an error.
140
+ */
141
+ export async function archiveFileObject(id, options = {}) {
142
+ const table = tableOf(options, 'archiveFileObject.tableName');
143
+ const run = options.querier ?? query;
144
+ const rows = await run(`UPDATE ${table}
145
+ SET archived_at = now(), updated_at = now()
146
+ WHERE id = $1 AND archived_at IS NULL
147
+ RETURNING id`, [id]);
148
+ return rows.length > 0;
149
+ }
150
+ /**
151
+ * Delete the objects and rows of files archived longer ago than the grace
152
+ * period.
153
+ *
154
+ * **Objects first, then rows**, and the order is the whole design. There is no
155
+ * transaction spanning S3 and Postgres, so one of two failure modes has to be
156
+ * chosen:
157
+ *
158
+ * - rows first → an orphaned object nobody can account for, discoverable only
159
+ * by diffing the entire bucket against this table;
160
+ * - objects first → a row naming a key that 404s, discoverable by walking
161
+ * rows, and invisible to users because the row is already archived.
162
+ *
163
+ * The second is recoverable and the first is silent, so the sweep takes the
164
+ * second. It is re-runnable for the same reason: a crash after the delete
165
+ * re-deletes keys that are already gone, which S3 treats as success.
166
+ *
167
+ * If `deleteObjects` throws, the rows are left for the next pass rather than
168
+ * deleted anyway — which would produce exactly the orphan this ordering exists
169
+ * to avoid.
170
+ */
171
+ export async function sweepArchivedFiles(options) {
172
+ const table = tableOf(options, 'sweepArchivedFiles.tableName');
173
+ const run = options.querier ?? query;
174
+ const retain = options.retainForSeconds ?? 604_800;
175
+ const rows = await run(`SELECT id, object_key
176
+ FROM ${table}
177
+ WHERE archived_at IS NOT NULL
178
+ AND archived_at < now() - make_interval(secs => $1::double precision)
179
+ ORDER BY archived_at
180
+ LIMIT $2`, [retain, options.limit ?? 100]);
181
+ if (rows.length === 0)
182
+ return { deleted: 0, remaining: 0 };
183
+ try {
184
+ await options.deleteObjects(rows.map((r) => r.object_key));
185
+ }
186
+ catch {
187
+ /* Leave every row of this batch. Deleting them now is what creates an
188
+ object nobody can account for. */
189
+ return { deleted: 0, remaining: rows.length };
190
+ }
191
+ await run(`DELETE FROM ${table} WHERE id = ANY($1::uuid[])`, [rows.map((r) => r.id)]);
192
+ return { deleted: rows.length, remaining: 0 };
193
+ }
194
+ //# sourceMappingURL=postgres.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.js","sourceRoot":"","sources":["../../src/files/postgres.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @venturekit/data — Postgres idempotency store.
3
+ *
4
+ * Plugs into `@venturekit/runtime`'s `idempotencyMiddleware` for projects whose
5
+ * only datastore is Postgres. See `./postgres.ts`.
6
+ */
7
+ export { createPostgresIdempotencyStore, purgeExpiredIdempotencyRecords, DEFAULT_IDEMPOTENCY_TABLE, } from './postgres.js';
8
+ export type { IdempotencyRecord, PostgresIdempotencyStore, PostgresIdempotencyStoreOptions, } from './postgres.js';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/idempotency/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,8BAA8B,EAC9B,8BAA8B,EAC9B,yBAAyB,GAC1B,MAAM,eAAe,CAAC;AAEvB,YAAY,EACV,iBAAiB,EACjB,wBAAwB,EACxB,+BAA+B,GAChC,MAAM,eAAe,CAAC"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @venturekit/data — Postgres idempotency store.
3
+ *
4
+ * Plugs into `@venturekit/runtime`'s `idempotencyMiddleware` for projects whose
5
+ * only datastore is Postgres. See `./postgres.ts`.
6
+ */
7
+ export { createPostgresIdempotencyStore, purgeExpiredIdempotencyRecords, DEFAULT_IDEMPOTENCY_TABLE, } from './postgres.js';
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/idempotency/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,8BAA8B,EAC9B,8BAA8B,EAC9B,yBAAyB,GAC1B,MAAM,eAAe,CAAC"}
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @venturekit/data — Postgres idempotency store.
3
+ *
4
+ * `@venturekit/runtime`'s `idempotencyMiddleware` takes a pluggable
5
+ * `IdempotencyStore`; the package ships an in-memory one (per-process, so wrong
6
+ * for Lambda) and a DynamoDB one, and its own docblock has always advertised
7
+ * "DynamoDB, Postgres, in-memory, etc." without the middle one existing. A
8
+ * project whose only datastore is Postgres therefore had to add a DynamoDB
9
+ * table for four columns, or write this adapter itself.
10
+ *
11
+ * This is that adapter. It is deliberately typed structurally rather than
12
+ * against `@venturekit/runtime`'s interface: `runtime` depends on `data`, so
13
+ * importing the type here would invert the dependency. The shape is asserted by
14
+ * the tests instead, and `createPostgresIdempotencyStore()` is assignable to
15
+ * `IdempotencyStore` wherever the middleware wants one.
16
+ *
17
+ * ### Schema
18
+ *
19
+ * `vk_idempotency_record`, from this package's `vk_data_001_idempotency.sql`.
20
+ *
21
+ * ### One thing Postgres does not do for you
22
+ *
23
+ * DynamoDB expires rows itself via its TTL attribute. Postgres has no
24
+ * equivalent, so expired rows accumulate until something deletes them:
25
+ * {@link purgeExpiredIdempotencyRecords} is that something, and it wants a
26
+ * schedule. Reads are already correct without it — `get()` filters on
27
+ * `expires_at` — so a project that forgets pays in disk, not in behaviour.
28
+ */
29
+ import type { Querier } from '../query/index.js';
30
+ /** Default table name. Overridable for projects that prefix or schema-qualify. */
31
+ export declare const DEFAULT_IDEMPOTENCY_TABLE = "vk_idempotency_record";
32
+ /**
33
+ * One stored request result. Mirrors `@venturekit/runtime`'s
34
+ * `IdempotencyRecord` field for field, including `expiresAt` as epoch
35
+ * milliseconds — the column is `timestamptz`, converted on both edges, because
36
+ * a bigint of millis is unreadable in `psql` and a timestamp is not.
37
+ */
38
+ export interface IdempotencyRecord {
39
+ key: string;
40
+ response: string;
41
+ status: 'pending' | 'completed';
42
+ expiresAt: number;
43
+ }
44
+ export interface PostgresIdempotencyStoreOptions {
45
+ /**
46
+ * Runs the statements. Defaults to this package's pooled `query`. Pass a
47
+ * transaction's `query` to make the record commit with the work it guards —
48
+ * which is the only way "the response was stored" and "the side effect
49
+ * happened" can be made to agree.
50
+ */
51
+ querier?: Querier;
52
+ /** Table name. Validated as an identifier, since it is interpolated. */
53
+ tableName?: string;
54
+ /**
55
+ * Namespace folded into every key.
56
+ *
57
+ * **Read this if the API is multi-tenant.** The middleware's default
58
+ * `headerKeyExtractor('idempotency-key')` takes the key from a client-supplied
59
+ * header, and the store is a flat key→response map. So two callers who happen
60
+ * to send the same key — `"1"`, `"retry"`, a uuid a client reuses — collide,
61
+ * and the second one is served the first one's cached response body. Across
62
+ * tenants that is a cross-tenant read.
63
+ *
64
+ * Supplying a scope makes the stored key `<scope>:<key>`, so collisions are
65
+ * confined to whatever the scope names. Pass the acting tenant id, or the
66
+ * principal, for anything a client can reach:
67
+ *
68
+ * ```ts
69
+ * createPostgresIdempotencyStore({ scope: () => getCurrentTenantId() })
70
+ * ```
71
+ *
72
+ * Left unset the behaviour matches the existing stores — unscoped — which is
73
+ * safe only when the key is server-generated or the API is single-tenant.
74
+ */
75
+ scope?: () => string | null | undefined;
76
+ }
77
+ /** The store's four methods, as the middleware expects them. */
78
+ export interface PostgresIdempotencyStore {
79
+ get(key: string): Promise<IdempotencyRecord | null>;
80
+ save(record: IdempotencyRecord): Promise<void>;
81
+ update(key: string, updates: Partial<IdempotencyRecord>): Promise<void>;
82
+ delete(key: string): Promise<void>;
83
+ }
84
+ /**
85
+ * Create a Postgres-backed idempotency store.
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * import { idempotencyMiddleware } from '@venturekit/runtime';
90
+ * import { createPostgresIdempotencyStore } from '@venturekit/data/idempotency';
91
+ *
92
+ * const idempotency = idempotencyMiddleware({
93
+ * store: createPostgresIdempotencyStore({ scope: () => getCurrentTenantId() }),
94
+ * });
95
+ * ```
96
+ */
97
+ export declare function createPostgresIdempotencyStore(options?: PostgresIdempotencyStoreOptions): PostgresIdempotencyStore;
98
+ /**
99
+ * Delete expired records. Returns how many went.
100
+ *
101
+ * Postgres has no TTL, so this is the counterpart to DynamoDB's automatic
102
+ * expiry and it needs a caller — a `schedules` intent running nightly is
103
+ * plenty. Nothing reads an expired row before it is deleted (`get()` filters
104
+ * on `expires_at`), so the only cost of never calling this is disk.
105
+ */
106
+ export declare function purgeExpiredIdempotencyRecords(options?: Pick<PostgresIdempotencyStoreOptions, 'querier' | 'tableName'>): Promise<number>;
107
+ //# sourceMappingURL=postgres.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.d.ts","sourceRoot":"","sources":["../../src/idempotency/postgres.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAGjD,kFAAkF;AAClF,eAAO,MAAM,yBAAyB,0BAA0B,CAAC;AAEjE;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,SAAS,GAAG,WAAW,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,+BAA+B;IAC9C;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,KAAK,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACzC;AAWD,gEAAgE;AAChE,MAAM,WAAW,wBAAwB;IACvC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAC;IACpD,IAAI,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,iBAAiB,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAYD;;;;;;;;;;;;GAYG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,GAAE,+BAAoC,GAC5C,wBAAwB,CAqF1B;AAED;;;;;;;GAOG;AACH,wBAAsB,8BAA8B,CAClD,OAAO,GAAE,IAAI,CAAC,+BAA+B,EAAE,SAAS,GAAG,WAAW,CAAM,GAC3E,OAAO,CAAC,MAAM,CAAC,CAQjB"}