@filelayer/core 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +202 -0
  3. package/MIGRATIONS.md +328 -0
  4. package/NOTICE +37 -0
  5. package/README.md +343 -0
  6. package/SEMANTICS.md +729 -0
  7. package/dist/authz.d.ts +524 -0
  8. package/dist/authz.d.ts.map +1 -0
  9. package/dist/authz.js +889 -0
  10. package/dist/authz.js.map +1 -0
  11. package/dist/db.d.ts +145 -0
  12. package/dist/db.d.ts.map +1 -0
  13. package/dist/db.js +217 -0
  14. package/dist/db.js.map +1 -0
  15. package/dist/delivery.d.ts +293 -0
  16. package/dist/delivery.d.ts.map +1 -0
  17. package/dist/delivery.js +519 -0
  18. package/dist/delivery.js.map +1 -0
  19. package/dist/errors.d.ts +16 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +21 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/filelayer.d.ts +542 -0
  24. package/dist/filelayer.d.ts.map +1 -0
  25. package/dist/filelayer.js +1360 -0
  26. package/dist/filelayer.js.map +1 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/simple.d.ts +297 -0
  32. package/dist/simple.d.ts.map +1 -0
  33. package/dist/simple.js +492 -0
  34. package/dist/simple.js.map +1 -0
  35. package/dist/storage.d.ts +269 -0
  36. package/dist/storage.d.ts.map +1 -0
  37. package/dist/storage.js +700 -0
  38. package/dist/storage.js.map +1 -0
  39. package/dist/store.d.ts +432 -0
  40. package/dist/store.d.ts.map +1 -0
  41. package/dist/store.js +862 -0
  42. package/dist/store.js.map +1 -0
  43. package/package.json +77 -0
  44. package/schema.sql +1190 -0
  45. package/src/authz.ts +1398 -0
  46. package/src/db.ts +271 -0
  47. package/src/delivery.ts +737 -0
  48. package/src/errors.ts +24 -0
  49. package/src/filelayer.ts +1836 -0
  50. package/src/index.ts +7 -0
  51. package/src/simple.ts +666 -0
  52. package/src/storage.ts +917 -0
  53. package/src/store.ts +1072 -0
  54. package/test/delivery.test.ts +0 -0
  55. package/test/group-subjects.test.ts +1072 -0
  56. package/test/helpers.ts +65 -0
  57. package/test/listing.test.ts +689 -0
  58. package/test/local-s3.d.mts +33 -0
  59. package/test/local-s3.mjs +400 -0
  60. package/test/persistence.test.ts +953 -0
  61. package/test/regression.test.ts +619 -0
  62. package/test/s3-live.test.ts +322 -0
  63. package/test/security.test.ts +1652 -0
  64. package/test/semantics.test.ts +888 -0
  65. package/test/storage.test.ts +437 -0
  66. package/test/tiers.test.ts +432 -0
  67. package/test/vault-example.test.ts +302 -0
  68. package/tsconfig.build.json +29 -0
  69. package/tsconfig.json +19 -0
package/src/db.ts ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Database bootstrap and the transaction abstraction.
3
+ *
4
+ * `Queryable` is the whole surface the store needs. PGlite satisfies it, and so
5
+ * does `pg.Pool` / `pg.Client`, so the store layer is written once and runs
6
+ * against WASM Postgres in CI and real Postgres in production without a branch.
7
+ *
8
+ * -----------------------------------------------------------------------------
9
+ * WHY THERE IS NOW A TRANSACTION ABSTRACTION
10
+ * -----------------------------------------------------------------------------
11
+ *
12
+ * `Queryable` used to be a bare `query(sql, params)` and nothing else. Every
13
+ * write the library performed was therefore its own autocommit transaction, and
14
+ * on `pg.Pool` each one could land on a DIFFERENT CONNECTION. That produced two
15
+ * defects, one of them in the product's headline claim:
16
+ *
17
+ * 1. `put()` did storage write -> INSERT file -> audit -> recordUsage ->
18
+ * recordFileOwner as five independent statements. A failure after the
19
+ * second one leaves a file with no audit record. For a product whose
20
+ * selling point is a tamper-evident audit trail, the audit write not being
21
+ * in the same transaction as the thing it audits is a hole in the premise:
22
+ * the chain is intact and simply does not mention what happened.
23
+ *
24
+ * 2. `audit_append()` takes `pg_advisory_xact_lock`, which is held until the
25
+ * end of the TRANSACTION. Under autocommit that is the end of the single
26
+ * statement, so the lock did serialize the append itself -- but it could not
27
+ * serialize the append with respect to the mutation it describes, because
28
+ * the mutation was in a different transaction. The lock was doing the small
29
+ * half of its job. It now spans the mutation as well (see `audit_append` in
30
+ * schema.sql and the chain-lock test in test/semantics.test.ts).
31
+ *
32
+ * A consumer could not fix either one themselves, because there was no way to
33
+ * hand the library a transaction.
34
+ *
35
+ * -----------------------------------------------------------------------------
36
+ * WHAT THE STORAGE WRITE DOES ABOUT NOT BEING TRANSACTIONAL
37
+ * -----------------------------------------------------------------------------
38
+ *
39
+ * Object storage does not participate in a Postgres transaction and never will.
40
+ * There are exactly two orderings and one of them is wrong:
41
+ *
42
+ * (a) commit metadata, then write bytes -- a crash in between leaves a `file`
43
+ * row in state 'ready' whose object does not exist. Every read of that
44
+ * file 404s forever, `listFiles` shows it, and the failure is visible to
45
+ * the customer as data loss.
46
+ * (b) write bytes, then commit metadata -- a crash in between leaves an
47
+ * object no `file` row points at. It is unreachable (every read path
48
+ * starts from a `file` row and the key is a fresh UUID that is never
49
+ * reissued), so it costs storage and nothing else.
50
+ *
51
+ * We take (b). An orphan is a garbage-collection problem rather than a
52
+ * correctness one. `Filelayer.collectStorageOrphans()` implements the
53
+ * collection; it is a REQUIRED OPERATIONAL JOB, documented in SEMANTICS.md, not
54
+ * something that happens on its own.
55
+ *
56
+ * Deletion is the mirror image and takes the mirror ordering: commit the
57
+ * metadata delete FIRST, then delete the bytes. A crash in between leaves an
58
+ * orphan (collectable) rather than a live `file` row with no object (data
59
+ * loss).
60
+ */
61
+
62
+ import { readFile } from 'node:fs/promises';
63
+ import { fileURLToPath } from 'node:url';
64
+ import { dirname, join } from 'node:path';
65
+
66
+ export interface QueryResult<R = Record<string, unknown>> {
67
+ rows: R[];
68
+ affectedRows?: number;
69
+ }
70
+
71
+ export interface Queryable {
72
+ query<R = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<QueryResult<R>>;
73
+ exec?(sql: string): Promise<unknown>;
74
+ /**
75
+ * Optional. An implementation that provides this is used in preference to
76
+ * everything `withTransaction` would otherwise sniff for, which is the escape
77
+ * hatch for a driver we have never heard of.
78
+ */
79
+ withTransaction?<T>(fn: (tx: Queryable) => Promise<T>): Promise<T>;
80
+ }
81
+
82
+ /**
83
+ * A `Queryable` that is known to be inside a transaction.
84
+ *
85
+ * `savepoint()` exists because a statement that RAISES inside a Postgres
86
+ * transaction aborts the whole transaction: every subsequent statement fails
87
+ * with "current transaction is aborted". Any place that expects a constraint to
88
+ * fire and then wants to keep going -- `share()`, where the attenuation trigger
89
+ * is the schema-level backstop and the refusal must still be AUDITED -- has to
90
+ * wrap the failing statement in a savepoint or it cannot write the audit event
91
+ * it exists to write.
92
+ */
93
+ export interface Tx extends Queryable {
94
+ readonly inTransaction: true;
95
+ savepoint<T>(fn: () => Promise<T>): Promise<T>;
96
+ }
97
+
98
+ export function isTx(db: Queryable): db is Tx {
99
+ return (db as Partial<Tx>).inTransaction === true;
100
+ }
101
+
102
+ /**
103
+ * Commit the transaction, then throw.
104
+ *
105
+ * The audit log records DECISIONS, including the ones that ended in a refusal.
106
+ * A denial writes an audit event and then throws, so if a throw always rolled
107
+ * back we would lose precisely the events P5 exists to keep -- and, worse, we
108
+ * would lose them silently, since the caller still sees their 403.
109
+ *
110
+ * So the rule is explicit and narrow: a `FilelayerError` is a DECIDED outcome
111
+ * (see `Filelayer.transaction()`), and everything else -- a driver error, a
112
+ * constraint we did not anticipate, a bug -- rolls back. Wrapping in this class
113
+ * makes the intent survive a refactor that changes the error type.
114
+ */
115
+ export class CommitThenThrow extends Error {
116
+ readonly inner: unknown;
117
+ constructor(inner: unknown) {
118
+ super('commit_then_throw');
119
+ this.name = 'CommitThenThrow';
120
+ this.inner = inner;
121
+ }
122
+ }
123
+
124
+ type PoolLike = Queryable & {
125
+ connect(): Promise<
126
+ Queryable & { release(err?: unknown): void }
127
+ >;
128
+ };
129
+ type PgliteLike = Queryable & {
130
+ transaction<T>(fn: (tx: Queryable) => Promise<T>): Promise<T>;
131
+ };
132
+
133
+ function isPool(db: Queryable): db is PoolLike {
134
+ return typeof (db as Partial<PoolLike>).connect === 'function';
135
+ }
136
+ function isPglite(db: Queryable): db is PgliteLike {
137
+ return typeof (db as Partial<PgliteLike>).transaction === 'function';
138
+ }
139
+
140
+ let savepointCounter = 0;
141
+
142
+ /**
143
+ * Run `fn` inside one database transaction, on ONE connection.
144
+ *
145
+ * Supports, in this order of preference:
146
+ *
147
+ * 1. anything exposing `withTransaction` (bring your own);
148
+ * 2. PGlite, via its own `transaction()` -- which is what the test suite uses,
149
+ * and which is a real Postgres transaction, not an emulation;
150
+ * 3. `pg.Pool`, via `connect()` + BEGIN/COMMIT/ROLLBACK on the checked-out
151
+ * client, with `release()` in a finally. Taking a client is the whole
152
+ * point: `pool.query('BEGIN')` starts a transaction on an arbitrary
153
+ * connection and the next statement may not get the same one, which is a
154
+ * classic way to leave a connection wedged in an open transaction;
155
+ * 4. a single `pg.Client` (or anything else), via BEGIN/COMMIT/ROLLBACK
156
+ * directly.
157
+ *
158
+ * Nesting: if `db` is already a `Tx`, the inner call becomes a SAVEPOINT rather
159
+ * than a second BEGIN, so a helper that wants a transaction composes with a
160
+ * caller that already opened one.
161
+ */
162
+ export async function withTransaction<T>(
163
+ db: Queryable,
164
+ fn: (tx: Tx) => Promise<T>,
165
+ ): Promise<T> {
166
+ if (isTx(db)) return db.savepoint(() => fn(db));
167
+
168
+ if (typeof db.withTransaction === 'function') {
169
+ return db.withTransaction((raw) => fn(asTx(raw)));
170
+ }
171
+
172
+ if (isPglite(db)) {
173
+ // PGlite's `transaction()` rolls back on throw and commits otherwise, so
174
+ // CommitThenThrow has to be converted into a normal return and re-thrown
175
+ // outside. `settled` carries which of the two happened.
176
+ let settled: { commitThenThrow: unknown } | null = null;
177
+ const value = await db.transaction(async (raw) => {
178
+ try {
179
+ return await fn(asTx(raw));
180
+ } catch (err) {
181
+ if (err instanceof CommitThenThrow) {
182
+ settled = { commitThenThrow: err.inner };
183
+ return undefined as unknown as T;
184
+ }
185
+ throw err;
186
+ }
187
+ });
188
+ if (settled !== null) throw (settled as { commitThenThrow: unknown }).commitThenThrow;
189
+ return value as T;
190
+ }
191
+
192
+ if (isPool(db)) {
193
+ const client = await db.connect();
194
+ try {
195
+ return await runTx(client, fn);
196
+ } finally {
197
+ client.release();
198
+ }
199
+ }
200
+
201
+ return runTx(db, fn);
202
+ }
203
+
204
+ async function runTx<T>(conn: Queryable, fn: (tx: Tx) => Promise<T>): Promise<T> {
205
+ await conn.query('BEGIN');
206
+ let result: T;
207
+ try {
208
+ result = await fn(asTx(conn));
209
+ } catch (err) {
210
+ if (err instanceof CommitThenThrow) {
211
+ await conn.query('COMMIT');
212
+ throw err.inner;
213
+ }
214
+ // A rollback that itself fails must not mask the original error.
215
+ await conn.query('ROLLBACK').catch(() => {});
216
+ throw err;
217
+ }
218
+ await conn.query('COMMIT');
219
+ return result;
220
+ }
221
+
222
+ function asTx(conn: Queryable): Tx {
223
+ const existing = conn as Partial<Tx>;
224
+ if (existing.inTransaction === true) return conn as Tx;
225
+ const tx: Tx = {
226
+ inTransaction: true,
227
+ query: (sql, params) => conn.query(sql, params),
228
+ ...(conn.exec ? { exec: (sql: string) => conn.exec!(sql) } : {}),
229
+ async savepoint<T>(fn: () => Promise<T>): Promise<T> {
230
+ const name = `fl_sp_${++savepointCounter}`;
231
+ await conn.query(`SAVEPOINT ${name}`);
232
+ try {
233
+ const r = await fn();
234
+ await conn.query(`RELEASE SAVEPOINT ${name}`);
235
+ return r;
236
+ } catch (err) {
237
+ await conn.query(`ROLLBACK TO SAVEPOINT ${name}`);
238
+ await conn.query(`RELEASE SAVEPOINT ${name}`);
239
+ throw err;
240
+ }
241
+ },
242
+ };
243
+ return tx;
244
+ }
245
+
246
+ const HERE = dirname(fileURLToPath(import.meta.url));
247
+ export const SCHEMA_PATH = join(HERE, '..', 'schema.sql');
248
+
249
+ export async function loadSchemaSql(): Promise<string> {
250
+ return readFile(SCHEMA_PATH, 'utf8');
251
+ }
252
+
253
+ /**
254
+ * Create an in-process Postgres (PGlite) with the Filelayer schema applied.
255
+ *
256
+ * PGlite is PostgreSQL 17 compiled to WASM: real planner, real constraints,
257
+ * real enums, real arrays, real rules, real transactional semantics. The one
258
+ * thing it is NOT is multi-process, which matters for exactly one test; see
259
+ * test/security.test.ts, "atomic download cap", for what that weakens.
260
+ */
261
+ export async function createTestDb(): Promise<{
262
+ db: Queryable & { close(): Promise<void> };
263
+ raw: unknown;
264
+ }> {
265
+ const { PGlite } = await import('@electric-sql/pglite');
266
+ const { pgcrypto } = await import('@electric-sql/pglite/contrib/pgcrypto');
267
+ const pg = await PGlite.create({ extensions: { pgcrypto } });
268
+ const sql = await loadSchemaSql();
269
+ await pg.exec(sql);
270
+ return { db: pg as unknown as Queryable & { close(): Promise<void> }, raw: pg };
271
+ }