@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.
- package/CHANGELOG.md +338 -0
- package/LICENSE +202 -0
- package/MIGRATIONS.md +328 -0
- package/NOTICE +37 -0
- package/README.md +343 -0
- package/SEMANTICS.md +729 -0
- package/dist/authz.d.ts +524 -0
- package/dist/authz.d.ts.map +1 -0
- package/dist/authz.js +889 -0
- package/dist/authz.js.map +1 -0
- package/dist/db.d.ts +145 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +217 -0
- package/dist/db.js.map +1 -0
- package/dist/delivery.d.ts +293 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +519 -0
- package/dist/delivery.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +21 -0
- package/dist/errors.js.map +1 -0
- package/dist/filelayer.d.ts +542 -0
- package/dist/filelayer.d.ts.map +1 -0
- package/dist/filelayer.js +1360 -0
- package/dist/filelayer.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/simple.d.ts +297 -0
- package/dist/simple.d.ts.map +1 -0
- package/dist/simple.js +492 -0
- package/dist/simple.js.map +1 -0
- package/dist/storage.d.ts +269 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +700 -0
- package/dist/storage.js.map +1 -0
- package/dist/store.d.ts +432 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +862 -0
- package/dist/store.js.map +1 -0
- package/package.json +77 -0
- package/schema.sql +1190 -0
- package/src/authz.ts +1398 -0
- package/src/db.ts +271 -0
- package/src/delivery.ts +737 -0
- package/src/errors.ts +24 -0
- package/src/filelayer.ts +1836 -0
- package/src/index.ts +7 -0
- package/src/simple.ts +666 -0
- package/src/storage.ts +917 -0
- package/src/store.ts +1072 -0
- package/test/delivery.test.ts +0 -0
- package/test/group-subjects.test.ts +1072 -0
- package/test/helpers.ts +65 -0
- package/test/listing.test.ts +689 -0
- package/test/local-s3.d.mts +33 -0
- package/test/local-s3.mjs +400 -0
- package/test/persistence.test.ts +953 -0
- package/test/regression.test.ts +619 -0
- package/test/s3-live.test.ts +322 -0
- package/test/security.test.ts +1652 -0
- package/test/semantics.test.ts +888 -0
- package/test/storage.test.ts +437 -0
- package/test/tiers.test.ts +432 -0
- package/test/vault-example.test.ts +302 -0
- package/tsconfig.build.json +29 -0
- 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
|
+
}
|