@fabrika/db 0.8.2 → 0.9.0-cloudflare.2
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/package.json +44 -12
- package/src/admissions.ts +78 -12
- package/src/application-rows.ts +108 -0
- package/src/capability.ts +5 -0
- package/src/codegen-sqlite.ts +154 -0
- package/src/codegen.ts +59 -9
- package/src/database.ts +208 -56
- package/src/errors.ts +1 -0
- package/src/file-columns.ts +4 -1
- package/src/framework-tables.ts +28 -8
- package/src/framework.ts +10 -2
- package/src/guard.ts +8 -0
- package/src/index.ts +9 -1
- package/src/job-leases.ts +157 -37
- package/src/links.ts +8 -1
- package/src/migrations-sqlite.ts +132 -0
- package/src/nodes.ts +45 -9
- package/src/orm.ts +25 -15
- package/src/policy.ts +8 -2
- package/src/postgres.ts +26 -0
- package/src/registry.ts +8 -2
- package/src/sql.ts +15 -7
- package/src/sqlite-bun.ts +122 -0
- package/src/sqlite-migrate.ts +242 -0
- package/src/sqlite.ts +224 -0
- package/src/store.ts +336 -62
- package/src/stored-files.ts +149 -46
- package/src/tables.ts +16 -0
- package/src/temporal.ts +115 -9
- package/src/testing-shared.ts +733 -0
- package/src/testing-sqlite.ts +513 -0
- package/src/testing.ts +170 -482
- package/types/src/admissions.d.ts +21 -0
- package/types/src/application-rows.d.ts +58 -0
- package/types/src/builtins.d.ts +42 -0
- package/types/src/capability.d.ts +119 -0
- package/types/src/codegen-sqlite.d.ts +6 -0
- package/types/src/codegen.d.ts +82 -0
- package/types/src/database.d.ts +179 -0
- package/types/src/description.d.ts +109 -0
- package/types/src/errors.d.ts +98 -0
- package/types/src/file-columns.d.ts +37 -0
- package/types/src/file-references.d.ts +27 -0
- package/types/src/framework-tables.d.ts +59 -0
- package/types/src/framework.d.ts +37 -0
- package/types/src/guard.d.ts +23 -0
- package/types/src/identifier.d.ts +4 -0
- package/types/src/index.d.ts +10 -0
- package/types/src/job-leases.d.ts +93 -0
- package/types/src/links.d.ts +13 -0
- package/types/src/migrations-sqlite.d.ts +4 -0
- package/types/src/migrations.d.ts +25 -0
- package/types/src/nodes.d.ts +63 -0
- package/types/src/orm-types.d.ts +638 -0
- package/types/src/orm.d.ts +77 -0
- package/types/src/policy.d.ts +354 -0
- package/types/src/postgres.d.ts +12 -0
- package/types/src/production.d.ts +5 -0
- package/types/src/registry.d.ts +40 -0
- package/types/src/sql.d.ts +59 -0
- package/types/src/sqlite-bun.d.ts +13 -0
- package/types/src/sqlite-migrate.d.ts +28 -0
- package/types/src/sqlite.d.ts +24 -0
- package/types/src/store.d.ts +345 -0
- package/types/src/stored-files.d.ts +98 -0
- package/types/src/tables.d.ts +21 -0
- package/types/src/temporal.d.ts +33 -0
- package/types/src/testing-shared.d.ts +201 -0
- package/types/src/testing-sqlite.d.ts +22 -0
- package/types/src/testing.d.ts +35 -0
- package/types/src/vocabulary-tables.d.ts +225 -0
package/src/stored-files.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { freeze } from './builtins'
|
|
|
9
9
|
import { type FixedParameter, runFixedStatement, type Session, sessionOf } from './database'
|
|
10
10
|
import { DatabaseError, QueryRefusedError } from './errors'
|
|
11
11
|
import { isPlainIdentifier } from './identifier'
|
|
12
|
-
import { fencedTransaction } from './job-leases'
|
|
12
|
+
import { type DialectTexts, fencedTransaction, SQLITE_NOW, sqliteNowPlus, sqliteTimestamp } from './job-leases'
|
|
13
13
|
import { FILE_DELETIONS_TABLE, FILES_TABLE } from './migrations'
|
|
14
14
|
import type { DataLayerRuntime } from './store'
|
|
15
15
|
|
|
@@ -112,55 +112,136 @@ export interface StoredFiles {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
115
|
-
/** Postgres' code of a foreign key violation: the delete of a file
|
|
115
|
+
/** Postgres' code of a foreign key violation, which the SQLite engine maps its own to: the delete of a referenced file. */
|
|
116
116
|
const FOREIGN_KEY_VIOLATION = '23503'
|
|
117
117
|
|
|
118
118
|
const SERVED_COLUMNS = `id::text as id, kind, name, content_type, size, status, object_key, object_version`
|
|
119
|
+
const SQLITE_SERVED_COLUMNS = `id, kind, name, content_type, size, status, object_key, object_version`
|
|
120
|
+
const N = SQLITE_NOW
|
|
121
|
+
|
|
122
|
+
const SQL_STAGE: DialectTexts = {
|
|
123
|
+
postgres: `insert into ${F} (id, kind, status, object_key, content_type, name, request, created_at)
|
|
124
|
+
values ($1::uuid, $2, 'pending', $3, $4, $5, $6, now())`,
|
|
125
|
+
sqlite: `insert into ${F} (id, kind, status, object_key, content_type, name, request, created_at)
|
|
126
|
+
values (?1, ?2, 'pending', ?3, ?4, ?5, ?6, ${N})`,
|
|
127
|
+
}
|
|
119
128
|
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
129
|
+
const SQL_WRITTEN: DialectTexts = {
|
|
130
|
+
postgres: `update ${F} set size = $2::bigint, sha256 = $3 where id = $1::uuid and status = 'pending' returning id`,
|
|
131
|
+
sqlite: `update ${F} set size = cast(?2 as integer), sha256 = ?3 where id = ?1 and status = 'pending' returning id`,
|
|
132
|
+
}
|
|
124
133
|
|
|
125
134
|
const SQL_ABANDON = `with gone as (delete from ${F} where id = $1::uuid and status = 'pending' and request = $2 returning object_key)
|
|
126
135
|
insert into ${D} (object_key, recorded_at) select object_key, now() from gone on conflict (object_key) do nothing returning object_key`
|
|
127
136
|
|
|
128
|
-
|
|
137
|
+
// SQLite has no data-modifying CTE: the delete and the record of its object are two statements in one transaction.
|
|
138
|
+
const SQLITE_ABANDON = `delete from ${F} where id = ?1 and status = 'pending' and request = ?2 returning object_key`
|
|
139
|
+
const SQLITE_RECORD_ABANDONED =
|
|
140
|
+
`insert into ${D} (object_key, recorded_at) values (?1, ${N}) on conflict (object_key) do nothing returning object_key`
|
|
129
141
|
|
|
130
|
-
const
|
|
131
|
-
where id = $1::uuid
|
|
142
|
+
const SQL_FIND: DialectTexts = {
|
|
143
|
+
postgres: `select ${SERVED_COLUMNS} from ${F} where id = $1::uuid`,
|
|
144
|
+
sqlite: `select ${SQLITE_SERVED_COLUMNS} from ${F} where id = ?1`,
|
|
145
|
+
}
|
|
132
146
|
|
|
133
|
-
const
|
|
134
|
-
|
|
147
|
+
const SQL_REQUARANTINE: DialectTexts = {
|
|
148
|
+
postgres: `update ${F} set status = 'quarantined', object_version = null, scan_attempts = 0, scan_after = now()
|
|
149
|
+
where id = $1::uuid and status = 'live' and object_version is not distinct from $2`,
|
|
150
|
+
sqlite: `update ${F} set status = 'quarantined', object_version = null, scan_attempts = 0, scan_after = ${N}
|
|
151
|
+
where id = ?1 and status = 'live' and object_version is ?2`,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The ids are one parameter: a comma-separated list on Postgres, a JSON array on SQLite.
|
|
155
|
+
const SQL_METADATA: DialectTexts = {
|
|
156
|
+
postgres: `select id::text as id, kind, name, content_type, size, status from ${F}
|
|
157
|
+
where id = any(string_to_array($1, ',')::uuid[])`,
|
|
158
|
+
sqlite: `select id, kind, name, content_type, size, status from ${F}
|
|
159
|
+
where id in (select value from json_each(?1))`,
|
|
160
|
+
}
|
|
135
161
|
|
|
136
|
-
const SQL_QUARANTINED =
|
|
162
|
+
const SQL_QUARANTINED: DialectTexts = {
|
|
163
|
+
postgres: `select id::text as id, object_key, size, sha256, content_type, scan_attempts, coalesce(scan_after, created_at) as due_since
|
|
137
164
|
from ${F} where status = 'quarantined' and kind = any(string_to_array($1, ',')) and (scan_after is null or scan_after < $2::timestamptz)
|
|
138
|
-
order by scan_after nulls first, created_at limit $3::int
|
|
165
|
+
order by scan_after nulls first, created_at limit $3::int`,
|
|
166
|
+
sqlite: `select id, object_key, size, sha256, content_type, scan_attempts, coalesce(scan_after, created_at) as due_since
|
|
167
|
+
from ${F} where status = 'quarantined' and kind in (select value from json_each(?1)) and (scan_after is null or scan_after < ?2)
|
|
168
|
+
order by scan_after nulls first, created_at limit ?3`,
|
|
169
|
+
}
|
|
139
170
|
|
|
140
|
-
const SQL_PROMOTE =
|
|
171
|
+
const SQL_PROMOTE: DialectTexts = {
|
|
172
|
+
postgres: `update ${F} set status = 'live', object_version = $3 where id = $1::uuid and status = 'quarantined' and sha256 = $2 returning id`,
|
|
173
|
+
sqlite: `update ${F} set status = 'live', object_version = ?3 where id = ?1 and status = 'quarantined' and sha256 = ?2 returning id`,
|
|
174
|
+
}
|
|
141
175
|
|
|
142
|
-
const SQL_DEFER =
|
|
143
|
-
|
|
176
|
+
const SQL_DEFER: DialectTexts = {
|
|
177
|
+
postgres: `update ${F} set scan_attempts = $2::int, scan_after = $3::timestamptz
|
|
178
|
+
where id = $1::uuid and status = 'quarantined' and scan_attempts = $2::int - 1 returning id`,
|
|
179
|
+
sqlite: `update ${F} set scan_attempts = ?2, scan_after = ?3
|
|
180
|
+
where id = ?1 and status = 'quarantined' and scan_attempts = ?2 - 1 returning id`,
|
|
181
|
+
}
|
|
144
182
|
|
|
145
|
-
const SQL_REJECT =
|
|
183
|
+
const SQL_REJECT: DialectTexts = {
|
|
184
|
+
postgres: `update ${F} set status = 'rejected' where id = $1::uuid and status = 'quarantined' returning id`,
|
|
185
|
+
sqlite: `update ${F} set status = 'rejected' where id = ?1 and status = 'quarantined' returning id`,
|
|
186
|
+
}
|
|
146
187
|
|
|
147
|
-
const SQL_UNFINISHED =
|
|
188
|
+
const SQL_UNFINISHED: DialectTexts = {
|
|
189
|
+
postgres: `select object_key from ${D} order by recorded_at limit $1::int`,
|
|
190
|
+
sqlite: `select object_key from ${D} order by recorded_at limit ?1`,
|
|
191
|
+
}
|
|
148
192
|
|
|
149
|
-
const SQL_FORGET =
|
|
193
|
+
const SQL_FORGET: DialectTexts = {
|
|
194
|
+
postgres: `delete from ${D} where object_key = $1`,
|
|
195
|
+
sqlite: `delete from ${D} where object_key = ?1`,
|
|
196
|
+
}
|
|
150
197
|
|
|
151
198
|
const SQL_REMOVE = `with gone as (
|
|
152
199
|
delete from ${F} where id = $1::uuid and legacy is null and created_at < now() - $2::bigint * interval '1 millisecond' returning object_key
|
|
153
200
|
)
|
|
154
201
|
insert into ${D} (object_key, recorded_at) select object_key, now() from gone returning object_key`
|
|
155
202
|
|
|
203
|
+
const SQLITE_REMOVE = `delete from ${F} where id = ?1 and legacy is null and created_at < ${sqliteNowPlus('-?2')} returning object_key`
|
|
204
|
+
const SQLITE_RECORD_REMOVED = `insert into ${D} (object_key, recorded_at) values (?1, ${N}) returning object_key`
|
|
205
|
+
|
|
156
206
|
/** The lock a reference write takes: a file being deleted cannot gain a reference, and a referenced one is not deleted. */
|
|
157
|
-
const SQL_LOCK =
|
|
158
|
-
|
|
207
|
+
const SQL_LOCK: DialectTexts = {
|
|
208
|
+
postgres: `select kind, status, request, scope, created_at > now() - $2::bigint * interval '1 millisecond' as fresh
|
|
209
|
+
from ${F} where id = $1::uuid for share`,
|
|
210
|
+
// No lock: while the reference write's transaction is open, the Durable Object runs no sweep.
|
|
211
|
+
sqlite: `select kind, status, request, scope, created_at > ${sqliteNowPlus('-?2')} as fresh
|
|
212
|
+
from ${F} where id = ?1`,
|
|
213
|
+
}
|
|
159
214
|
|
|
160
|
-
const SQL_CLAIM =
|
|
215
|
+
const SQL_CLAIM: DialectTexts = {
|
|
216
|
+
postgres: `update ${F} set status = $2 where id = $1::uuid and status = 'pending' returning id`,
|
|
217
|
+
sqlite: `update ${F} set status = ?2 where id = ?1 and status = 'pending' returning id`,
|
|
218
|
+
}
|
|
161
219
|
|
|
162
220
|
/** The first dimensional reference stamps the scope; one from another top-level scope matches nothing. */
|
|
163
|
-
const SQL_STAMP =
|
|
221
|
+
const SQL_STAMP: DialectTexts = {
|
|
222
|
+
postgres: `update ${F} set scope = $2 where id = $1::uuid and (scope is null or scope = $2) returning id`,
|
|
223
|
+
sqlite: `update ${F} set scope = ?2 where id = ?1 and (scope is null or scope = ?2) returning id`,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Runs the text of `session`'s dialect. */
|
|
227
|
+
function run(session: Session, texts: DialectTexts, parameters: readonly FixedParameter[]): Promise<readonly unknown[]> {
|
|
228
|
+
return runFixedStatement(session, texts[session.dialect], parameters)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* SQLite's form of a delete that records its object for deletion: the delete, then the record, in one transaction.
|
|
233
|
+
* The record's object key, or `null` when nothing was deleted or recorded.
|
|
234
|
+
*/
|
|
235
|
+
function deleteAndRecord(session: Session, deletion: string, parameters: readonly FixedParameter[], recording: string): Promise<string | null> {
|
|
236
|
+
return session.transaction(async (transaction) => {
|
|
237
|
+
const [gone] = await runFixedStatement(transaction, deletion, parameters)
|
|
238
|
+
if (gone === undefined) {
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
const [recorded] = await runFixedStatement(transaction, recording, [text(record(gone), 'object_key')])
|
|
242
|
+
return recorded === undefined ? null : text(record(recorded), 'object_key')
|
|
243
|
+
})
|
|
244
|
+
}
|
|
164
245
|
|
|
165
246
|
/** `exists (select 1 from <table> where <column> = f.id)`, one per column, over the policies' validated identifiers. */
|
|
166
247
|
function referencedBy(columns: readonly FileColumnRef[]): string {
|
|
@@ -227,8 +308,9 @@ function status(row: Readonly<Record<string, unknown>>): StoredFileStatus {
|
|
|
227
308
|
return found
|
|
228
309
|
}
|
|
229
310
|
|
|
230
|
-
|
|
231
|
-
|
|
311
|
+
/** A timestamp column: a `Date` from Postgres, a stored ISO text from SQLite. */
|
|
312
|
+
function date(session: Session, row: Readonly<Record<string, unknown>>, column: string): Date {
|
|
313
|
+
const value = session.dialect === 'sqlite' ? sqliteTimestamp(row[column], `${F}.${column}`) : row[column]
|
|
232
314
|
if (!(value instanceof Date)) {
|
|
233
315
|
throw new QueryRefusedError('unexpected_row', `${F}.${column} came back as ${typeof value}`)
|
|
234
316
|
}
|
|
@@ -248,7 +330,7 @@ function metaOf(row: Readonly<Record<string, unknown>>): StoredFileMeta {
|
|
|
248
330
|
|
|
249
331
|
/** Locks file `id` `for share` in `session`, the transaction that writes a reference to it; `null` when it is gone. */
|
|
250
332
|
export async function lockStoredFile(session: Session, id: string, graceMs: number): Promise<LockedFile | null> {
|
|
251
|
-
const [found] = await
|
|
333
|
+
const [found] = await run(session, SQL_LOCK, [uuid(id), positive(graceMs, 'a pending grace')])
|
|
252
334
|
if (found === undefined) {
|
|
253
335
|
return null
|
|
254
336
|
}
|
|
@@ -258,13 +340,14 @@ export async function lockStoredFile(session: Session, id: string, graceMs: numb
|
|
|
258
340
|
status: status(row),
|
|
259
341
|
request: nullableText(row, 'request'),
|
|
260
342
|
scope: nullableText(row, 'scope'),
|
|
261
|
-
|
|
343
|
+
// SQLite answers a comparison as 1 or 0.
|
|
344
|
+
fresh: row['fresh'] === (session.dialect === 'sqlite' ? 1 : true),
|
|
262
345
|
})
|
|
263
346
|
}
|
|
264
347
|
|
|
265
348
|
/** Ends the pending state of a file the reference write locked: `quarantined` for a scanning kind, else `live`. */
|
|
266
349
|
export async function claimStoredFile(session: Session, id: string, ended: 'quarantined' | 'live'): Promise<void> {
|
|
267
|
-
const rows = await
|
|
350
|
+
const rows = await run(session, SQL_CLAIM, [uuid(id), ended])
|
|
268
351
|
if (rows.length !== 1) {
|
|
269
352
|
throw new QueryRefusedError('unexpected_row', 'a pending file changed under its lock')
|
|
270
353
|
}
|
|
@@ -272,7 +355,7 @@ export async function claimStoredFile(session: Session, id: string, ended: 'quar
|
|
|
272
355
|
|
|
273
356
|
/** Sets the scope of a file's first dimensional reference; `false` when it already holds another. */
|
|
274
357
|
export async function stampStoredFile(session: Session, id: string, scope: string): Promise<boolean> {
|
|
275
|
-
return (await
|
|
358
|
+
return (await run(session, SQL_STAMP, [uuid(id), scope])).length === 1
|
|
276
359
|
}
|
|
277
360
|
|
|
278
361
|
/** Each file's metadata, by id, in `using`: one statement for every file column of a read. */
|
|
@@ -281,7 +364,8 @@ export async function storedFilesMetadata(using: Session, ids: readonly string[]
|
|
|
281
364
|
if (unique.length === 0) {
|
|
282
365
|
return new Map()
|
|
283
366
|
}
|
|
284
|
-
const
|
|
367
|
+
const checked = unique.map(uuid)
|
|
368
|
+
const rows = await run(using, SQL_METADATA, [using.dialect === 'sqlite' ? JSON.stringify(checked) : checked.join(',')])
|
|
285
369
|
return new Map(rows.map((found) => {
|
|
286
370
|
const meta = metaOf(record(found))
|
|
287
371
|
return [meta.id, meta] as const
|
|
@@ -290,32 +374,36 @@ export async function storedFilesMetadata(using: Session, ids: readonly string[]
|
|
|
290
374
|
|
|
291
375
|
export function storedFiles(runtime: () => DataLayerRuntime): StoredFiles {
|
|
292
376
|
const session = () => sessionOf(runtime())
|
|
293
|
-
async function fenced(statement:
|
|
377
|
+
async function fenced(statement: DialectTexts, parameters: readonly FixedParameter[]): Promise<boolean> {
|
|
294
378
|
const current = runtime()
|
|
295
379
|
const fence = current.capabilities.fence()
|
|
296
380
|
if (fence === null) {
|
|
297
381
|
throw new QueryRefusedError('file_scan', "a scan verdict is written only inside the scan job's lease")
|
|
298
382
|
}
|
|
299
|
-
const rows = await fencedTransaction(sessionOf(current), fence, (transaction) =>
|
|
383
|
+
const rows = await fencedTransaction(sessionOf(current), fence, (transaction) => run(transaction, statement, parameters))
|
|
300
384
|
return rows.length > 0
|
|
301
385
|
}
|
|
302
386
|
return freeze({
|
|
303
387
|
async stage(file: StagedFile): Promise<void> {
|
|
304
|
-
await
|
|
388
|
+
await run(session(), SQL_STAGE, [uuid(file.id), file.kind, file.objectKey, file.contentType, file.name, file.request])
|
|
305
389
|
},
|
|
306
390
|
|
|
307
391
|
async written(id: string, written: { readonly size: number; readonly sha256: string }): Promise<void> {
|
|
308
392
|
if (!/^[0-9a-f]{64}$/.test(written.sha256) || !Number.isSafeInteger(written.size) || written.size < 0) {
|
|
309
393
|
throw new QueryRefusedError('invalid_argument', 'a written file records its size and the hex SHA-256 of its bytes')
|
|
310
394
|
}
|
|
311
|
-
const rows = await
|
|
395
|
+
const rows = await run(session(), SQL_WRITTEN, [uuid(id), String(written.size), written.sha256])
|
|
312
396
|
if (rows.length !== 1) {
|
|
313
397
|
throw new QueryRefusedError('unexpected_row', 'a staged file is no longer pending')
|
|
314
398
|
}
|
|
315
399
|
},
|
|
316
400
|
|
|
317
401
|
async abandon(id: string, request: string): Promise<string | null> {
|
|
318
|
-
const
|
|
402
|
+
const current = session()
|
|
403
|
+
if (current.dialect === 'sqlite') {
|
|
404
|
+
return deleteAndRecord(current, SQLITE_ABANDON, [uuid(id), request], SQLITE_RECORD_ABANDONED)
|
|
405
|
+
}
|
|
406
|
+
const [found] = await runFixedStatement(current, SQL_ABANDON, [uuid(id), request])
|
|
319
407
|
return found === undefined ? null : text(record(found), 'object_key')
|
|
320
408
|
},
|
|
321
409
|
|
|
@@ -323,7 +411,7 @@ export function storedFiles(runtime: () => DataLayerRuntime): StoredFiles {
|
|
|
323
411
|
if (!UUID.test(id)) {
|
|
324
412
|
return null
|
|
325
413
|
}
|
|
326
|
-
const [found] = await
|
|
414
|
+
const [found] = await run(using ?? session(), SQL_FIND, [id])
|
|
327
415
|
if (found === undefined) {
|
|
328
416
|
return null
|
|
329
417
|
}
|
|
@@ -332,7 +420,7 @@ export function storedFiles(runtime: () => DataLayerRuntime): StoredFiles {
|
|
|
332
420
|
},
|
|
333
421
|
|
|
334
422
|
async requarantine(id: string, version: string | null): Promise<void> {
|
|
335
|
-
await
|
|
423
|
+
await run(session(), SQL_REQUARANTINE, [uuid(id), version])
|
|
336
424
|
},
|
|
337
425
|
|
|
338
426
|
metadata: storedFilesMetadata,
|
|
@@ -341,7 +429,9 @@ export function storedFiles(runtime: () => DataLayerRuntime): StoredFiles {
|
|
|
341
429
|
if (kinds.length === 0) {
|
|
342
430
|
return []
|
|
343
431
|
}
|
|
344
|
-
const
|
|
432
|
+
const current = session()
|
|
433
|
+
const listed = current.dialect === 'sqlite' ? JSON.stringify(kinds) : kinds.join(',')
|
|
434
|
+
const rows = await run(current, SQL_QUARANTINED, [listed, now.toISOString(), positive(limit, 'a scan batch')])
|
|
345
435
|
return rows.map((found) => {
|
|
346
436
|
const row = record(found)
|
|
347
437
|
return freeze({
|
|
@@ -351,7 +441,7 @@ export function storedFiles(runtime: () => DataLayerRuntime): StoredFiles {
|
|
|
351
441
|
sha256: text(row, 'sha256'),
|
|
352
442
|
contentType: text(row, 'content_type'),
|
|
353
443
|
scanAttempts: count(row, 'scan_attempts'),
|
|
354
|
-
dueSince: date(row, 'due_since'),
|
|
444
|
+
dueSince: date(current, row, 'due_since'),
|
|
355
445
|
})
|
|
356
446
|
})
|
|
357
447
|
},
|
|
@@ -363,33 +453,46 @@ export function storedFiles(runtime: () => DataLayerRuntime): StoredFiles {
|
|
|
363
453
|
reject: (id: string) => fenced(SQL_REJECT, [uuid(id)]),
|
|
364
454
|
|
|
365
455
|
async unfinishedDeletions(limit: number): Promise<readonly string[]> {
|
|
366
|
-
const rows = await
|
|
456
|
+
const rows = await run(session(), SQL_UNFINISHED, [positive(limit, 'a sweep batch')])
|
|
367
457
|
return rows.map((found) => text(record(found), 'object_key'))
|
|
368
458
|
},
|
|
369
459
|
|
|
370
460
|
async forgetDeletion(objectKey: string): Promise<void> {
|
|
371
|
-
await
|
|
461
|
+
await run(session(), SQL_FORGET, [objectKey])
|
|
372
462
|
},
|
|
373
463
|
|
|
374
464
|
async adoptReferenced(columns: readonly FileColumnRef[]): Promise<number> {
|
|
375
465
|
if (columns.length === 0) {
|
|
376
466
|
return 0
|
|
377
467
|
}
|
|
378
|
-
const
|
|
379
|
-
|
|
468
|
+
const current = session()
|
|
469
|
+
const statement = current.dialect === 'sqlite'
|
|
470
|
+
? `update ${F} as f set legacy = null where f.legacy is not null and (${referencedBy(columns)}) returning id`
|
|
471
|
+
: `update ${F} f set legacy = null where f.legacy is not null and (${referencedBy(columns)}) returning id`
|
|
472
|
+
return (await runFixedStatement(current, statement, [])).length
|
|
380
473
|
},
|
|
381
474
|
|
|
382
475
|
async unreferenced(columns: readonly FileColumnRef[], limit: number, graceMs: number): Promise<readonly string[]> {
|
|
383
|
-
const
|
|
476
|
+
const current = session()
|
|
477
|
+
const statement = current.dialect === 'sqlite'
|
|
478
|
+
? `select f.id as id from ${F} f
|
|
479
|
+
where f.legacy is null and f.created_at < ${sqliteNowPlus('-?1')} and not (${referencedBy(columns)})
|
|
480
|
+
order by f.created_at limit ?2`
|
|
481
|
+
: `select f.id::text as id from ${F} f
|
|
384
482
|
where f.legacy is null and f.created_at < now() - $1::bigint * interval '1 millisecond' and not (${referencedBy(columns)})
|
|
385
483
|
order by f.created_at limit $2::int`
|
|
386
|
-
const rows = await runFixedStatement(
|
|
484
|
+
const rows = await runFixedStatement(current, statement, [positive(graceMs, 'a pending grace'), positive(limit, 'a sweep batch')])
|
|
387
485
|
return rows.map((found) => text(record(found), 'id'))
|
|
388
486
|
},
|
|
389
487
|
|
|
390
488
|
async remove(id: string, graceMs: number): Promise<string | null> {
|
|
391
489
|
try {
|
|
392
|
-
const
|
|
490
|
+
const current = session()
|
|
491
|
+
const parameters = [uuid(id), positive(graceMs, 'a pending grace')]
|
|
492
|
+
if (current.dialect === 'sqlite') {
|
|
493
|
+
return await deleteAndRecord(current, SQLITE_REMOVE, parameters, SQLITE_RECORD_REMOVED)
|
|
494
|
+
}
|
|
495
|
+
const [found] = await runFixedStatement(current, SQL_REMOVE, parameters)
|
|
393
496
|
return found === undefined ? null : text(record(found), 'object_key')
|
|
394
497
|
} catch (error) {
|
|
395
498
|
if (error instanceof DatabaseError && error.sqlState === FOREIGN_KEY_VIOLATION) {
|
package/src/tables.ts
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
// driver, so `@fabrika/app/contract` and the static contract reader in `@fabrika/check` import it.
|
|
3
3
|
|
|
4
4
|
import type { ColumnPolicyArtifact, TableActionArtifact, TablePolicyArtifact, TableScope } from '@fabrika/auth-core'
|
|
5
|
+
import { type ApplicationRowsPlan, planApplicationRows } from './application-rows'
|
|
5
6
|
import { freeze, namesOf, ownEntry, ownKeys, setOwn } from './builtins'
|
|
6
7
|
import { PolicyError } from './errors'
|
|
7
8
|
import { modelOf, type ScopeModel, type TableModel, tableModels, type Tables } from './policy'
|
|
8
9
|
|
|
10
|
+
export { applicationRowRefusalText } from './application-rows'
|
|
11
|
+
export type { ApplicationRowRefusal, ApplicationRowsPlan } from './application-rows'
|
|
9
12
|
export { PolicyError } from './errors'
|
|
10
13
|
export type { FileReference } from './file-references'
|
|
11
14
|
export type {
|
|
@@ -96,6 +99,19 @@ export type {
|
|
|
96
99
|
VocabularyTablePolicy,
|
|
97
100
|
} from './vocabulary-tables'
|
|
98
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The plan of seeding one row of each application table `rows` names (table → column → value), as an endpoint access
|
|
104
|
+
* case seeds its `appRows`: the order, the foreign keys that link one given row to another, and every refusal.
|
|
105
|
+
* `@fabrika/check` reports the refusals from source; `@fabrika/db/testing` throws the first, both in the words of
|
|
106
|
+
* `applicationRowRefusalText`.
|
|
107
|
+
*/
|
|
108
|
+
export function applicationRowsPlan<DB, P>(
|
|
109
|
+
tables: Tables<DB, P>,
|
|
110
|
+
rows: Readonly<Record<string, Readonly<Record<string, unknown>>>>,
|
|
111
|
+
): ApplicationRowsPlan {
|
|
112
|
+
return planApplicationRows(tableModels(tables), rows)
|
|
113
|
+
}
|
|
114
|
+
|
|
99
115
|
/** Every table's policy in the form the contract artifact carries, by table name. */
|
|
100
116
|
export type TablePolicies = Readonly<Record<string, TablePolicyArtifact>>
|
|
101
117
|
|
package/src/temporal.ts
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
// the database's own text (`cast(<column> as text)`, `nodes.ts`), never through the `Date` `Bun.SQL` decodes: outside
|
|
4
4
|
// 0001–9999 that one drifts by the process's offset and can land on a neighbouring valid day. Only an aggregate's
|
|
5
5
|
// `min`/`max` and a store's joined child column still arrive as a `Date`. The generated column list names the `date`
|
|
6
|
-
// columns (`TableColumns.dates`); every read of a table's rows passes through `answerDays
|
|
6
|
+
// columns (`TableColumns.dates`); every read of a table's rows passes through `answerRows` (`answerDays` on Postgres). A write's text for a day or a
|
|
7
7
|
// timestamp is checked here before anything is sent: Postgres would also take `now`, `today` or `10/12/2026`, and read
|
|
8
|
-
// a timestamp without an offset in the session's time zone.
|
|
8
|
+
// a timestamp without an offset in the session's time zone. On SQLite (ADR-0065) a timestamp is stored as UTC ISO text
|
|
9
|
+
// and a boolean as 0 or 1: `storedTimestamp` is what a write binds and `answerRows` turns both back into what Postgres's
|
|
10
|
+
// driver answers.
|
|
9
11
|
|
|
10
12
|
import { defineValue, inSet, type NameSet, ownDescriptor, ownKeys } from './builtins'
|
|
13
|
+
import type { SqlDialect } from './database'
|
|
11
14
|
import { QueryRefusedError } from './errors'
|
|
12
15
|
|
|
13
16
|
/** The text Postgres prints for a day in 0001–9999 under its default `DateStyle` (ISO). */
|
|
@@ -61,7 +64,7 @@ export function answerDays<R>(table: string, days: NameSet, rows: R[]): R[] {
|
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
/** An instant in strict ISO 8601: a day, `T`, the time to the minute or finer, and `Z` or an offset. */
|
|
64
|
-
const TIMESTAMP_TEXT = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})(?::([0-9]{2})(?:\.[0-9]{1,6})?)?(?:Z|[+-]([0-9]{2}):([0-9]{2}))$/
|
|
67
|
+
const TIMESTAMP_TEXT = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})(?::([0-9]{2})(?:\.([0-9]{1,6}))?)?(?:Z|([+-])([0-9]{2}):([0-9]{2}))$/
|
|
65
68
|
|
|
66
69
|
/** Whether `year-month-day` is a day of the calendar: no 30 February, no month 13. */
|
|
67
70
|
function isCalendarDay(year: number, month: number, day: number): boolean {
|
|
@@ -78,14 +81,117 @@ export function dayInput(value: string, where: string): string {
|
|
|
78
81
|
return value
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
84
|
+
/** The instant of strict ISO 8601 text, in milliseconds since the epoch (digits past the millisecond dropped), or `undefined`. */
|
|
85
|
+
function instantOf(value: string): number | undefined {
|
|
83
86
|
const match = TIMESTAMP_TEXT.exec(value)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
if (match === null) {
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
const [, year, month, day, hour, minute, second = '0', fraction = '', sign = '+', offsetHour = '0', offsetMinute = '0'] = match
|
|
91
|
+
const valid = isCalendarDay(Number(year), Number(month), Number(day)) && Number(hour) < 24 && Number(minute) < 60 && Number(second) < 60
|
|
92
|
+
&& Number(offsetHour) < 16 && Number(offsetMinute) < 60
|
|
87
93
|
if (!valid) {
|
|
88
|
-
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
// setUTCFullYear, unlike Date.UTC, keeps the years 0001–0099.
|
|
97
|
+
const date = new Date(0)
|
|
98
|
+
date.setUTCFullYear(Number(year), Number(month) - 1, Number(day))
|
|
99
|
+
date.setUTCHours(Number(hour), Number(minute), Number(second), Number(fraction.padEnd(3, '0').slice(0, 3)))
|
|
100
|
+
const offset = (Number(offsetHour) * 60 + Number(offsetMinute)) * 60_000
|
|
101
|
+
return date.getTime() - (sign === '-' ? -offset : offset)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function timestampRefused(where: string): QueryRefusedError {
|
|
105
|
+
return new QueryRefusedError('invalid_value', `${where} takes a Date or ISO 8601 text with Z or an offset, such as 2026-10-12T08:30:00.000Z`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A timestamp a write binds as text: strict ISO 8601 with `Z` or an offset, every field in range. */
|
|
109
|
+
export function timestampInput(value: string, where: string): string {
|
|
110
|
+
if (instantOf(value) === undefined) {
|
|
111
|
+
throw timestampRefused(where)
|
|
89
112
|
}
|
|
90
113
|
return value
|
|
91
114
|
}
|
|
115
|
+
|
|
116
|
+
/** A timestamp as SQLite stores it: UTC `Date#toISOString()` text, whose text order is time order. */
|
|
117
|
+
const STORED_TIMESTAMP = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What a write or a comparison binds for a timestamp on SQLite: the instant of a `Date`, or of the text `timestampInput`
|
|
121
|
+
* admits, as stored text. Digits past the millisecond are dropped; an instant outside the years 0000–9999 UTC is
|
|
122
|
+
* refused, since its text would not sort.
|
|
123
|
+
*/
|
|
124
|
+
export function storedTimestamp(value: string | Date, where: string): string {
|
|
125
|
+
const time = typeof value === 'string' ? instantOf(value) : value.getTime()
|
|
126
|
+
if (time === undefined) {
|
|
127
|
+
throw timestampRefused(where)
|
|
128
|
+
}
|
|
129
|
+
const text = Number.isNaN(time) ? '' : new Date(time).toISOString()
|
|
130
|
+
if (!STORED_TIMESTAMP.test(text)) {
|
|
131
|
+
throw new QueryRefusedError(
|
|
132
|
+
'invalid_value',
|
|
133
|
+
`${where} holds an instant outside the years 0000-9999 UTC, which SQLite cannot store as sortable text`,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
return text
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The columns of a read's rows the layer answers in a form of its own, by the name each arrives under. */
|
|
140
|
+
export interface RowColumns {
|
|
141
|
+
/** `date` columns: `YYYY-MM-DD` text. */
|
|
142
|
+
readonly days: NameSet
|
|
143
|
+
/** Timestamps: a `Date` (SQLite answers its stored text). */
|
|
144
|
+
readonly timestamps: NameSet
|
|
145
|
+
/** Booleans: `true` or `false` (SQLite answers 1 or 0). */
|
|
146
|
+
readonly booleans: NameSet
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function unexpected(where: string, expected: string): QueryRefusedError {
|
|
150
|
+
return new QueryRefusedError('unexpected_row', `${where} came back as something other than ${expected}`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One SQLite value of `key` as Postgres's driver answers it; a value the migrations' types never store is refused. */
|
|
154
|
+
function sqliteValue(columns: RowColumns, where: string, key: string, value: unknown): unknown {
|
|
155
|
+
if (value === null) {
|
|
156
|
+
return value
|
|
157
|
+
}
|
|
158
|
+
if (inSet(columns.timestamps, key)) {
|
|
159
|
+
if (typeof value !== 'string' || !STORED_TIMESTAMP.test(value)) {
|
|
160
|
+
throw unexpected(where, 'UTC ISO text (YYYY-MM-DDTHH:MM:SS.sssZ), the form a timestamp is stored in on SQLite')
|
|
161
|
+
}
|
|
162
|
+
return new Date(value)
|
|
163
|
+
}
|
|
164
|
+
if (inSet(columns.booleans, key)) {
|
|
165
|
+
if (value !== 0 && value !== 1) {
|
|
166
|
+
throw unexpected(where, '0 or 1, the form a boolean is stored in on SQLite')
|
|
167
|
+
}
|
|
168
|
+
return value === 1
|
|
169
|
+
}
|
|
170
|
+
return typeof value === 'string' && inSet(columns.days, key) ? dayText(value, where) : value
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Every row of a read as the layer answers it, in place: each day as `YYYY-MM-DD` text, and on SQLite each timestamp as
|
|
175
|
+
* a `Date` and each boolean as `true` or `false`, so a handler reads the same values on both dialects.
|
|
176
|
+
*/
|
|
177
|
+
export function answerRows<R>(dialect: SqlDialect, table: string, columns: RowColumns, rows: R[]): R[] {
|
|
178
|
+
if (dialect === 'postgres') {
|
|
179
|
+
return answerDays(table, columns.days, rows)
|
|
180
|
+
}
|
|
181
|
+
for (let index = 0; index < rows.length; index++) {
|
|
182
|
+
const row = rows[index]
|
|
183
|
+
if (typeof row !== 'object' || row === null) {
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
const keys = ownKeys(row)
|
|
187
|
+
for (let position = 0; position < keys.length; position++) {
|
|
188
|
+
const key = keys[position] ?? ''
|
|
189
|
+
const value: unknown = ownDescriptor(row, key)?.value
|
|
190
|
+
const answered = sqliteValue(columns, `${table}.${key}`, key, value)
|
|
191
|
+
if (answered !== value) {
|
|
192
|
+
defineValue(row, key, answered)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return rows
|
|
197
|
+
}
|