@fabrika/db 0.5.5 → 0.7.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.
@@ -1,14 +1,15 @@
1
1
  // The framework's side of its own tables (`@fabrika/db/migrations`): webhook bindings and the delivery keys that
2
2
  // make a replay a no-op. A delivery key is derived from authenticated content (`@fabrika/app` hashes the binding
3
- // and the signed body), never from a header; the `delivery_id` column holds it. Only `@fabrika/app`'s webhook runtime reaches them, through `frameworkTablesOf`. Every
3
+ // and the signed body), never from a header; the `delivery_id` column holds it, claimed under a lease and completed
4
+ // in the handler's transaction. Only `@fabrika/app`'s webhook runtime reaches them, through `frameworkTablesOf`. Every
4
5
  // statement is a guarded tree like a store's, keyed by equalities on the framework table itself.
5
6
 
6
7
  import { BinaryOperationNode, type OperationNode, OperatorNode, ValueNode } from 'kysely'
7
- import { sessionOf } from './database'
8
- import { DatabaseError, QueryRefusedError } from './errors'
8
+ import { runFixedStatement, type Session, sessionOf } from './database'
9
+ import { QueryRefusedError } from './errors'
9
10
  import type { GuardContext } from './guard'
10
11
  import { WEBHOOK_BINDINGS_TABLE, WEBHOOK_DELIVERIES_TABLE } from './migrations'
11
- import { acknowledgement, allOf, column, deleteNode, equals, insertNode, locateNode, scopedWhere } from './nodes'
12
+ import { acknowledgement, allOf, column, deleteNode, equals, insertNode, locateNode, scopedWhere, updateNode } from './nodes'
12
13
  import type { DataLayerRuntime } from './store'
13
14
 
14
15
  /** One webhook binding: the scope value it reaches, the provider account it accepts, and its signing secret. */
@@ -17,7 +18,7 @@ export interface WebhookBinding {
17
18
  /** The webhook mount's name. */
18
19
  readonly webhook: string
19
20
  readonly scope: { readonly type: string; readonly value: string }
20
- /** `installation:<id>` or `repository:<id>`: the GitHub account a delivery must name. */
21
+ /** The provider account: for GitHub, `installation:<id>` or `repository:<id>`, which a delivery must name. */
21
22
  readonly account: string
22
23
  readonly secret: string
23
24
  }
@@ -28,22 +29,61 @@ export interface NewWebhookBinding extends WebhookBinding {
28
29
  }
29
30
 
30
31
  export interface WebhookTables {
31
- createBinding(binding: NewWebhookBinding): Promise<void>
32
+ /** Records a binding, in `using` when given: the transaction of the procedure that binds. */
33
+ createBinding(binding: NewWebhookBinding, using?: Session): Promise<void>
32
34
  /** The binding `id` of `webhook`, or `null`. */
33
35
  binding(webhook: string, id: string): Promise<WebhookBinding | null>
34
- /** Removes the binding when it belongs to `webhook` and `scope`; `false` when there was none. */
35
- removeBinding(webhook: string, id: string, scope: { readonly type: string; readonly value: string }): Promise<boolean>
36
36
  /**
37
- * Records a delivery key; `false` when the binding already received it within the retention window. Keys of the
38
- * binding older than `retainedSince` are dropped first, so the table stays bounded.
37
+ * Replaces the secret of the binding when it belongs to `webhook` and `scope`, in `using` when given: a provider that
38
+ * issues its own signing secret shows it only after the binding's URL is registered. `false` when there was none.
39
39
  */
40
- recordDelivery(bindingId: string, deliveryKey: string, receivedAt: number, retainedSince: number): Promise<boolean>
41
- /** Forgets a delivery whose handler failed, so the provider's redelivery runs it again. */
42
- forgetDelivery(bindingId: string, deliveryKey: string): Promise<void>
40
+ replaceSecret(
41
+ webhook: string,
42
+ id: string,
43
+ scope: { readonly type: string; readonly value: string },
44
+ secret: string,
45
+ using?: Session,
46
+ ): Promise<boolean>
47
+ /** Removes the binding when it belongs to `webhook` and `scope`, in `using` when given; `false` when there was none. */
48
+ removeBinding(webhook: string, id: string, scope: { readonly type: string; readonly value: string }, using?: Session): Promise<boolean>
49
+ /**
50
+ * Claims a delivery key for `leaseMs`: `null` when the binding completed it within the retention window, or another
51
+ * copy holds an unexpired claim. A claim whose lease expired is taken over. Keys of the binding older than
52
+ * `retainedSince` are dropped first, so the table stays bounded.
53
+ */
54
+ claimDelivery(bindingId: string, deliveryKey: string, now: number, leaseMs: number, retainedSince: number): Promise<DeliveryClaim | null>
55
+ /**
56
+ * Completes a claimed delivery in `using`, the transaction its handler wrote in, so the work and the completion
57
+ * commit together; `false` when another copy took the claim over meanwhile, and that transaction must not commit.
58
+ * Without `using` (a handler with no transaction) it completes on its own.
59
+ */
60
+ completeDelivery(claim: DeliveryClaim, using?: Session): Promise<boolean>
61
+ /** Gives a claim back after its handler failed, so the provider's redelivery runs it again at once. */
62
+ releaseDelivery(claim: DeliveryClaim): Promise<void>
63
+ }
64
+
65
+ /** One copy's claim on a delivery: its lease end fences the completion against a copy that took the claim over. */
66
+ export interface DeliveryClaim {
67
+ readonly bindingId: string
68
+ readonly key: string
69
+ readonly leaseUntil: number
43
70
  }
44
71
 
72
+ const D = WEBHOOK_DELIVERIES_TABLE
73
+
74
+ /** A new key, or one whose claim expired without completion; a completed one (no lease) is never taken. */
75
+ const SQL_CLAIM = `insert into ${D} (binding_id, delivery_id, received_at, lease_until) values ($1, $2, $3::bigint, $4::bigint)
76
+ on conflict (binding_id, delivery_id) do update set received_at = excluded.received_at, lease_until = excluded.lease_until
77
+ where ${D}.lease_until is not null and ${D}.lease_until < excluded.received_at
78
+ returning delivery_id`
79
+
80
+ const SQL_COMPLETE = `update ${D} set lease_until = null
81
+ where binding_id = $1 and delivery_id = $2 and lease_until = $3::bigint
82
+ returning delivery_id`
83
+
84
+ const SQL_RELEASE = `delete from ${D} where binding_id = $1 and delivery_id = $2 and lease_until = $3::bigint returning delivery_id`
85
+
45
86
  const BINDING_COLUMNS = ['id', 'webhook', 'scope_type', 'scope_value', 'account', 'secret']
46
- const UNIQUE_VIOLATION = '23505'
47
87
 
48
88
  function guard(root: GuardContext['root'], table: string, scope: OperationNode | undefined): GuardContext {
49
89
  return { root, table, joins: {}, scope }
@@ -57,14 +97,20 @@ function text(row: Readonly<Record<string, unknown>>, column: string): string {
57
97
  return value
58
98
  }
59
99
 
60
- function isUniqueViolation(error: unknown): boolean {
61
- return error instanceof DatabaseError && error.sqlState === UNIQUE_VIOLATION
100
+ /** The binding `id` of `webhook`, only when it reaches `scope`. */
101
+ function bindingOf(webhook: string, id: string, scope: { readonly type: string; readonly value: string }): OperationNode {
102
+ return allOf([
103
+ equals(WEBHOOK_BINDINGS_TABLE, 'id', id),
104
+ equals(WEBHOOK_BINDINGS_TABLE, 'webhook', webhook),
105
+ equals(WEBHOOK_BINDINGS_TABLE, 'scope_type', scope.type),
106
+ equals(WEBHOOK_BINDINGS_TABLE, 'scope_value', scope.value),
107
+ ])
62
108
  }
63
109
 
64
110
  export function webhookTables(runtime: () => DataLayerRuntime): WebhookTables {
65
111
  const session = () => sessionOf(runtime())
66
112
  return Object.freeze({
67
- async createBinding(binding: NewWebhookBinding): Promise<void> {
113
+ async createBinding(binding: NewWebhookBinding, using?: Session): Promise<void> {
68
114
  const values = new Map<string, string | number>([
69
115
  ['id', binding.id],
70
116
  ['webhook', binding.webhook],
@@ -76,7 +122,7 @@ export function webhookTables(runtime: () => DataLayerRuntime): WebhookTables {
76
122
  ['created_at', binding.createdAt],
77
123
  ])
78
124
  const node = insertNode(WEBHOOK_BINDINGS_TABLE, values, acknowledgement(WEBHOOK_BINDINGS_TABLE, 'id', undefined))
79
- await session().run(node, guard('InsertQueryNode', WEBHOOK_BINDINGS_TABLE, undefined))
125
+ await (using ?? session()).run(node, guard('InsertQueryNode', WEBHOOK_BINDINGS_TABLE, undefined))
80
126
  },
81
127
 
82
128
  async binding(webhook: string, id: string): Promise<WebhookBinding | null> {
@@ -97,21 +143,37 @@ export function webhookTables(runtime: () => DataLayerRuntime): WebhookTables {
97
143
  })
98
144
  },
99
145
 
100
- async removeBinding(webhook: string, id: string, scope: { readonly type: string; readonly value: string }): Promise<boolean> {
101
- const predicate = allOf([
102
- equals(WEBHOOK_BINDINGS_TABLE, 'id', id),
103
- equals(WEBHOOK_BINDINGS_TABLE, 'webhook', webhook),
104
- equals(WEBHOOK_BINDINGS_TABLE, 'scope_type', scope.type),
105
- equals(WEBHOOK_BINDINGS_TABLE, 'scope_value', scope.value),
106
- ])
107
- const removed = await session().run(
146
+ async replaceSecret(
147
+ webhook: string,
148
+ id: string,
149
+ scope: { readonly type: string; readonly value: string },
150
+ secret: string,
151
+ using?: Session,
152
+ ): Promise<boolean> {
153
+ const predicate = bindingOf(webhook, id, scope)
154
+ const replaced = await (using ?? session()).run(
155
+ updateNode(
156
+ WEBHOOK_BINDINGS_TABLE,
157
+ new Map([['secret', secret]]),
158
+ undefined,
159
+ scopedWhere(undefined, predicate),
160
+ acknowledgement(WEBHOOK_BINDINGS_TABLE, 'id', undefined),
161
+ ),
162
+ guard('UpdateQueryNode', WEBHOOK_BINDINGS_TABLE, predicate),
163
+ )
164
+ return replaced.length > 0
165
+ },
166
+
167
+ async removeBinding(webhook: string, id: string, scope: { readonly type: string; readonly value: string }, using?: Session): Promise<boolean> {
168
+ const predicate = bindingOf(webhook, id, scope)
169
+ const removed = await (using ?? session()).run(
108
170
  deleteNode(WEBHOOK_BINDINGS_TABLE, scopedWhere(undefined, predicate), 'id'),
109
171
  guard('DeleteQueryNode', WEBHOOK_BINDINGS_TABLE, predicate),
110
172
  )
111
173
  return removed.length > 0
112
174
  },
113
175
 
114
- async recordDelivery(bindingId: string, deliveryKey: string, receivedAt: number, retainedSince: number): Promise<boolean> {
176
+ async claimDelivery(bindingId: string, deliveryKey: string, now: number, leaseMs: number, retainedSince: number): Promise<DeliveryClaim | null> {
115
177
  const binding = equals(WEBHOOK_DELIVERIES_TABLE, 'binding_id', bindingId)
116
178
  const expired = BinaryOperationNode.create(
117
179
  column(WEBHOOK_DELIVERIES_TABLE, 'received_at'),
@@ -122,26 +184,19 @@ export function webhookTables(runtime: () => DataLayerRuntime): WebhookTables {
122
184
  deleteNode(WEBHOOK_DELIVERIES_TABLE, scopedWhere(expired, binding), 'delivery_id'),
123
185
  guard('DeleteQueryNode', WEBHOOK_DELIVERIES_TABLE, binding),
124
186
  )
125
- const values = new Map<string, string | number>([['binding_id', bindingId], ['delivery_id', deliveryKey], ['received_at', receivedAt]])
126
- const node = insertNode(WEBHOOK_DELIVERIES_TABLE, values, acknowledgement(WEBHOOK_DELIVERIES_TABLE, 'delivery_id', undefined))
127
- try {
128
- await session().run(node, guard('InsertQueryNode', WEBHOOK_DELIVERIES_TABLE, undefined))
129
- return true
130
- } catch (error) {
131
- // The primary key decides between two concurrent copies of one delivery: exactly one insert wins.
132
- if (isUniqueViolation(error)) {
133
- return false
134
- }
135
- throw error
136
- }
187
+ const leaseUntil = now + leaseMs
188
+ // The primary key decides between concurrent copies of one delivery: exactly one inserts or takes over.
189
+ const claimed = await runFixedStatement(session(), SQL_CLAIM, [bindingId, deliveryKey, now, leaseUntil])
190
+ return claimed.length === 0 ? null : Object.freeze({ bindingId, key: deliveryKey, leaseUntil })
137
191
  },
138
192
 
139
- async forgetDelivery(bindingId: string, deliveryKey: string): Promise<void> {
140
- const predicate = allOf([equals(WEBHOOK_DELIVERIES_TABLE, 'binding_id', bindingId), equals(WEBHOOK_DELIVERIES_TABLE, 'delivery_id', deliveryKey)])
141
- await session().run(
142
- deleteNode(WEBHOOK_DELIVERIES_TABLE, scopedWhere(undefined, predicate), 'delivery_id'),
143
- guard('DeleteQueryNode', WEBHOOK_DELIVERIES_TABLE, predicate),
144
- )
193
+ async completeDelivery(claim: DeliveryClaim, using?: Session): Promise<boolean> {
194
+ const completed = await runFixedStatement(using ?? session(), SQL_COMPLETE, [claim.bindingId, claim.key, claim.leaseUntil])
195
+ return completed.length > 0
196
+ },
197
+
198
+ async releaseDelivery(claim: DeliveryClaim): Promise<void> {
199
+ await runFixedStatement(session(), SQL_RELEASE, [claim.bindingId, claim.key, claim.leaseUntil])
145
200
  },
146
201
  })
147
202
  }
package/src/framework.ts CHANGED
@@ -2,11 +2,11 @@
2
2
  // the data layer an application passed to `implement` to the framework's connection and capability authority, and
3
3
  // hands the framework the locator its access check uses. Browser- and Worker-safe: the Worker adapter imports `boot`.
4
4
 
5
- import type { FileTables } from './file-tables'
6
5
  import type { DataAccess } from './orm'
7
6
  import { frameworkSideOf, type FrameworkTables, type Locator } from './registry'
8
7
  import type { DataLayerRuntime } from './store'
9
8
 
9
+ export type { Admissions } from './admissions'
10
10
  export type { LeaseFence } from './capability'
11
11
  export type { InvocationTransaction, Session } from './database'
12
12
  export {
@@ -18,27 +18,14 @@ export {
18
18
  type QueryRefusal,
19
19
  QueryRefusedError,
20
20
  } from './errors'
21
- export { fileTableName } from './file-table-sql'
22
- export type {
23
- AdoptedFile,
24
- FileCollectionSpec,
25
- FileCollectionTable,
26
- FilePage,
27
- FileTables,
28
- HeldStatus,
29
- ListedFile,
30
- PendingFile,
31
- QuarantinedFile,
32
- RemovableFile,
33
- StoredFile,
34
- } from './file-tables'
35
- export type { FormAdmissions, InboxQuota } from './form-admissions'
36
- export type { ClaimedFormFile, FormFiles, HeldFormFiles, RemovableFormFile, StagedFormFile, WrittenFormFile } from './form-files'
37
- export type { NewWebhookBinding, WebhookBinding, WebhookTables } from './framework-tables'
21
+ export type { FileBinding } from './file-columns'
22
+ export { type FileHolding, fileHoldingOf, type FileReference, fileReference, holdsFile } from './file-references'
23
+ export type { DeliveryClaim, NewWebhookBinding, WebhookBinding, WebhookTables } from './framework-tables'
38
24
  export type { JobLeases, JobLeaseState, JobOutcome, OverdueClaim } from './job-leases'
39
25
  export type { DataAccess, DataHandles, DataSite } from './orm'
40
26
  export type { FrameworkTables, Locator } from './registry'
41
27
  export type { SqlMode, SqlSite, SqlTag, SqlValue } from './sql'
28
+ export type { FileColumnRef, QuarantinedStoredFile, ServedFile, StagedFile, StoredFileMeta, StoredFiles, StoredFileStatus } from './stored-files'
42
29
 
43
30
  /** Whether `value` is an object `scoped(tables)` created, rather than a look-alike with the same fields. */
44
31
  export function isScopedDatabase(value: unknown): boolean {
@@ -57,18 +44,9 @@ export function bindScopedDatabase(value: unknown, runtime: DataLayerRuntime): L
57
44
  return side.bind(runtime)
58
45
  }
59
46
 
60
- /** The file collections' tables of the layer `scoped(tables)` created; each operation needs the layer bound first. */
61
- export function fileTablesOf(value: unknown): FileTables {
62
- const side = frameworkSideOf(value)
63
- if (side === undefined) {
64
- throw new TypeError('not a data layer scoped(tables) created')
65
- }
66
- return side.files
67
- }
68
-
69
47
  /**
70
- * The framework's own writes over the layer `scoped(tables)` created: inbox appends for `c.form` and the webhook
71
- * tables. Each operation needs the layer bound first. Throws for any other value.
48
+ * The framework's own writes over the layer `scoped(tables)` created: the webhook tables, the job leases and the
49
+ * admission counters. Each operation needs the layer bound first. Throws for any other value.
72
50
  */
73
51
  export function frameworkTablesOf(value: unknown): FrameworkTables {
74
52
  const side = frameworkSideOf(value)
package/src/guard.ts CHANGED
@@ -21,9 +21,11 @@ export interface GuardContext {
21
21
  readonly scope: OperationNode | undefined
22
22
  /**
23
23
  * A select that locks its rows: `share` is the layer's check of a mutation's destination, `update` a mutation's
24
- * `getForUpdate` of its own target. No other lock is admitted.
24
+ * `getForUpdate` of its own target, `claim` a queue claim's `for update skip locked`. No other lock is admitted.
25
25
  */
26
- readonly lock?: 'share' | 'update'
26
+ readonly lock?: 'share' | 'update' | 'claim'
27
+ /** An insert that does nothing on a conflict of its listed columns: `on conflict (columns) do nothing`, and no other. */
28
+ readonly onConflict?: 'nothing'
27
29
  }
28
30
 
29
31
  function lookup(...names: string[]): NameSet {
@@ -72,8 +74,10 @@ const NODE_KINDS = lookup(
72
74
  'ReturningNode',
73
75
  // Only as an orderBy direction; checked below.
74
76
  'RawNode',
75
- // Only as the one lock of a destination check or a getForUpdate; checked below.
77
+ // Only as the one lock of a destination check or a getForUpdate, or a claim's two; checked below.
76
78
  'SelectModifierNode',
79
+ // Only as an insert's `on conflict (columns) do nothing`; checked below.
80
+ 'OnConflictNode',
77
81
  // Only as a selected own column's text, the exact position a keyset page continues from; checked below.
78
82
  'CastNode',
79
83
  'DataTypeNode',
@@ -99,7 +103,8 @@ export function assertAllowedTree(root: OperationNode, context: GuardContext): v
99
103
  for (let index = 0; index < keys.length; index++) {
100
104
  const key = keys[index] ?? ''
101
105
  const lockModifier = key === 'endModifiers' && context.lock !== undefined && context.root === 'SelectQueryNode'
102
- if (read(root, key) !== undefined && !inSet(allowed, key) && !lockModifier) {
106
+ const conflict = key === 'onConflict' && context.onConflict === 'nothing' && context.root === 'InsertQueryNode'
107
+ if (read(root, key) !== undefined && !inSet(allowed, key) && !lockModifier && !conflict) {
103
108
  throw new QueryRefusedError('statement_shape', `${context.root}.${key} is not allowed`)
104
109
  }
105
110
  }
@@ -236,6 +241,8 @@ function checkNode(node: OperationNode, parent: Visit | undefined, context: Guar
236
241
  return checkRaw(node, parent)
237
242
  case 'SelectModifierNode':
238
243
  return checkLock(node, parent, context)
244
+ case 'OnConflictNode':
245
+ return checkConflict(node, parent, context)
239
246
  case 'CastNode':
240
247
  return checkCast(node, parent, context)
241
248
  case 'DataTypeNode':
@@ -265,7 +272,8 @@ function checkNode(node: OperationNode, parent: Visit | undefined, context: Guar
265
272
  case 'ColumnNode':
266
273
  if (
267
274
  parent === undefined
268
- || !(parent.node.kind === 'ReferenceNode' || parent.node.kind === 'InsertQueryNode' || parent.node.kind === 'ColumnUpdateNode')
275
+ || !(parent.node.kind === 'ReferenceNode' || parent.node.kind === 'InsertQueryNode' || parent.node.kind === 'ColumnUpdateNode'
276
+ || parent.node.kind === 'OnConflictNode')
269
277
  ) {
270
278
  throw new QueryRefusedError('statement_shape', 'unqualified column')
271
279
  }
@@ -320,17 +328,43 @@ function checkValue(node: OperationNode): void {
320
328
  }
321
329
  }
322
330
 
331
+ /** The lock modifiers each lock kind admits, in the order the compiler prints them. */
332
+ const LOCKS: Readonly<Record<'share' | 'update' | 'claim', readonly string[]>> = freeze({
333
+ share: freeze(['ForShare']),
334
+ update: freeze(['ForUpdate']),
335
+ claim: freeze(['ForUpdate', 'SkipLocked']),
336
+ })
337
+
323
338
  function checkLock(node: OperationNode, parent: Visit | undefined, context: GuardContext): void {
324
339
  const modifiers = parent === undefined ? undefined : read(parent.node, 'endModifiers')
325
- const onRoot = parent !== undefined && parent.up === undefined && parent.key === 'endModifiers' && isArray(modifiers) && modifiers.length === 1
326
- const expected = context.lock === 'share' ? 'ForShare' : context.lock === 'update' ? 'ForUpdate' : undefined
327
- const plain = expected !== undefined && read(node, 'modifier') === expected && read(node, 'of') === undefined
340
+ const expected = context.lock === undefined ? [] : LOCKS[context.lock]
341
+ const onRoot = parent !== undefined && parent.up === undefined && parent.key === 'endModifiers' && isArray(modifiers)
342
+ && modifiers.length === expected.length
343
+ let position = -1
344
+ for (let index = 0; onRoot && index < modifiers.length; index++) {
345
+ if (modifiers[index] === node) {
346
+ position = index
347
+ }
348
+ }
349
+ const plain = position !== -1 && read(node, 'modifier') === expected[position] && read(node, 'of') === undefined
328
350
  && read(node, 'rawModifier') === undefined
329
351
  if (!onRoot || !plain) {
330
352
  throw new QueryRefusedError('statement_shape', 'row locks are not allowed')
331
353
  }
332
354
  }
333
355
 
356
+ /** `on conflict (<columns>) do nothing` on the root insert: no constraint name, index expression, WHERE or update. */
357
+ function checkConflict(node: OperationNode, parent: Visit | undefined, context: GuardContext): void {
358
+ const columns = read(node, 'columns')
359
+ const onRoot = parent !== undefined && parent.up === undefined && parent.key === 'onConflict' && context.onConflict === 'nothing'
360
+ const plain = read(node, 'doNothing') === true && isArray(columns) && columns.length > 0 && read(node, 'constraint') === undefined
361
+ && read(node, 'indexExpression') === undefined && read(node, 'indexWhere') === undefined && read(node, 'updates') === undefined
362
+ && read(node, 'updateWhere') === undefined
363
+ if (!onRoot || !plain) {
364
+ throw new QueryRefusedError('statement_shape', 'an insert admits only on conflict (columns) do nothing')
365
+ }
366
+ }
367
+
334
368
  /** `cast(<own column> as text) as <alias>`, selected at the top of a select: nothing else casts. */
335
369
  function checkCast(node: OperationNode, parent: Visit | undefined, context: GuardContext): void {
336
370
  const selected = parent?.node.kind === 'AliasNode' && parent.key === 'node' && parent.up?.node.kind === 'SelectionNode'
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ export type {
7
7
  LeaseFence,
8
8
  Ref,
9
9
  RefGrant,
10
+ RowGrant,
11
+ RowRef,
10
12
  Within,
11
13
  WithinGrant,
12
14
  } from './capability'
@@ -35,17 +37,6 @@ export {
35
37
  type QueryRefusal,
36
38
  QueryRefusedError,
37
39
  } from './errors'
38
- export {
39
- FILE_STATUSES,
40
- FILE_TABLE_PREFIX,
41
- type FileColumn,
42
- type FileColumnType,
43
- type FileOwner,
44
- fileScanSql,
45
- type FileStatus,
46
- fileTableName,
47
- fileTableSql,
48
- } from './file-table-sql'
49
40
  export type {
50
41
  AnyDbIndex,
51
42
  ChildRows,
@@ -85,8 +76,6 @@ export {
85
76
  defineTables,
86
77
  type DimensionPolicy,
87
78
  type DimensionScope,
88
- type InboxPolicy,
89
- type InboxScope,
90
79
  type InsertValues,
91
80
  isTables,
92
81
  readTables,
package/src/job-leases.ts CHANGED
@@ -254,6 +254,17 @@ function refuseEnded(fence: LeaseFence): void {
254
254
  }
255
255
  }
256
256
 
257
+ const SQL_CLOCK = 'select clock_timestamp() as now'
258
+
259
+ /**
260
+ * The database's clock inside `session`'s transaction: a queue claim compares and moves `due` by it, so no process
261
+ * clock decides when a row is due.
262
+ */
263
+ export async function databaseNow(session: Session): Promise<Date> {
264
+ const [row] = await rows(session, SQL_CLOCK, [])
265
+ return requiredDate(row ?? {}, 'now')
266
+ }
267
+
257
268
  const lost = (fence: LeaseFence): LeaseLostError =>
258
269
  new LeaseLostError(`job '${fence.job}' lost its lease or its attempt's deadline (generation ${fence.generation}); nothing was written`)
259
270
 
package/src/links.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // A Ref's predicate over a table's containment links: the first link takes the Ref's id, every other link a value
2
- // of its chain, and a chain entry no link covers is refused. Shared by the stores and the file collections' tables.
2
+ // of its chain, and a chain entry no link covers is refused. Shared by the stores and the handles.
3
3
 
4
4
  import type { OperationNode } from 'kysely'
5
5
  import { freeze, inSet, nameSet, ownEntry, ownKeys, recordOf } from './builtins'
package/src/migrations.ts CHANGED
@@ -13,6 +13,15 @@ export const FORM_ADMISSIONS_TABLE = 'fabrika_form_admissions'
13
13
  export const FORM_CHALLENGES_TABLE = 'fabrika_form_challenges'
14
14
  export const FORM_FILES_TABLE = 'fabrika_form_files'
15
15
  export const FORM_BYTES_TABLE = 'fabrika_form_bytes'
16
+ export const FILES_TABLE = 'fabrika_files'
17
+ export const FILE_DELETIONS_TABLE = 'fabrika_file_deletions'
18
+ /** The function an application's last adoption migration calls with the name of a file collection it adopted. */
19
+ export const FILES_RELEASE_FUNCTION = 'fabrika_files_release'
20
+ /**
21
+ * The function an adoption migration calls right before it reads a collection's table: it copies the rows an older
22
+ * instance wrote since 0008 ran, so each one it adopts has its `fabrika_files` row. Idempotent.
23
+ */
24
+ export const FILES_COPY_FUNCTION = 'fabrika_files_copy_legacy'
16
25
 
17
26
  export interface FrameworkMigration {
18
27
  /** Ordered by name; an applied one is immutable, like an application's. */
@@ -128,6 +137,123 @@ CREATE TABLE ${FORM_BYTES_TABLE} (
128
137
  bytes BIGINT NOT NULL,
129
138
  PRIMARY KEY (form, window_start)
130
139
  );
140
+ `,
141
+ }),
142
+ Object.freeze({
143
+ name: '0007_webhook_delivery_claims.sql',
144
+ sql: `-- A delivery is claimed with a lease and completed in its handler's transaction, which clears the lease. A claim
145
+ -- whose lease expired without completion is taken over by the provider's redelivery. Every row recorded before has
146
+ -- no lease, so it stays a completed delivery, as does one an older framework release records during a rollout.
147
+ ALTER TABLE ${WEBHOOK_DELIVERIES_TABLE} ADD COLUMN lease_until BIGINT;
148
+ `,
149
+ }),
150
+ Object.freeze({
151
+ name: '0008_files.sql',
152
+ sql: `-- Every stored file: a value a table's file column references by id. A row is 'pending' from before its object
153
+ -- (<kind>/<id>) is written until the first reference claims it, then 'quarantined' (a scanning kind) or 'live'; the scan
154
+ -- makes a quarantined one 'live' or 'rejected'. scope is the top-level value of its first dimensional reference; legacy
155
+ -- names the collection a migrated file came from, which the sweep never deletes.
156
+ CREATE TABLE ${FILES_TABLE} (
157
+ id UUID PRIMARY KEY,
158
+ kind TEXT NOT NULL,
159
+ status TEXT NOT NULL CONSTRAINT ${FILES_TABLE}_status CHECK (status IN ('pending', 'quarantined', 'live', 'rejected')),
160
+ object_key TEXT NOT NULL UNIQUE,
161
+ size BIGINT,
162
+ sha256 TEXT,
163
+ content_type TEXT NOT NULL,
164
+ name TEXT,
165
+ scope TEXT,
166
+ request TEXT,
167
+ created_at TIMESTAMPTZ NOT NULL,
168
+ object_version TEXT,
169
+ scan_attempts INTEGER NOT NULL DEFAULT 0,
170
+ scan_after TIMESTAMPTZ,
171
+ legacy TEXT
172
+ );
173
+ CREATE INDEX ${FILES_TABLE}_created_at ON ${FILES_TABLE} (created_at) WHERE legacy IS NULL;
174
+ CREATE INDEX ${FILES_TABLE}_scan ON ${FILES_TABLE} (scan_after) WHERE status = 'quarantined';
175
+ CREATE INDEX ${FILES_TABLE}_legacy ON ${FILES_TABLE} (legacy) WHERE legacy IS NOT NULL;
176
+
177
+ -- An object whose file row is deleted, until the object is: the sweep finishes a deletion a crash interrupted.
178
+ CREATE TABLE ${FILE_DELETIONS_TABLE} (
179
+ object_key TEXT PRIMARY KEY,
180
+ recorded_at TIMESTAMPTZ NOT NULL
181
+ );
182
+
183
+ -- Copies the held rows of a collection's table (fabrika_files_<collection>) into ${FILES_TABLE}, marked legacy with
184
+ -- the collection: same id, object, status and scan state. A row copied before is skipped, and so is an orphan, whose
185
+ -- owner was deleted: no application can adopt it, so the release records its object for deletion.
186
+ CREATE FUNCTION ${FILES_COPY_FUNCTION}(collection TEXT) RETURNS void LANGUAGE plpgsql AS $$
187
+ DECLARE
188
+ source TEXT := 'fabrika_files_' || collection;
189
+ scanned BOOLEAN;
190
+ BEGIN
191
+ IF collection !~ '^[a-z_][a-z0-9_]*$' OR to_regclass(source) IS NULL THEN
192
+ RAISE EXCEPTION 'no file collection table %', source;
193
+ END IF;
194
+ SELECT count(*) = 3 INTO scanned FROM information_schema.columns
195
+ WHERE table_schema = current_schema() AND table_name = source AND column_name IN ('object_version', 'scan_attempts', 'scan_after');
196
+ EXECUTE format(
197
+ 'INSERT INTO ${FILES_TABLE} (id, kind, status, object_key, size, sha256, content_type, name, created_at, object_version, scan_attempts, scan_after, legacy) '
198
+ || 'SELECT id, $1, status, object_key, size, sha256, content_type, original_name, created_at, %s, $1 FROM %I '
199
+ || 'WHERE deleted_at IS NULL AND status <> ''pending'' AND owner_id IS NOT NULL ON CONFLICT (id) DO NOTHING',
200
+ CASE WHEN scanned THEN 'object_version, scan_attempts, scan_after' ELSE 'NULL, 0, NULL' END,
201
+ source
202
+ ) USING collection;
203
+ END
204
+ $$;
205
+
206
+ -- An application's last adoption migration of a collection: it copies what an older version wrote meanwhile, refuses
207
+ -- while a copied file is referenced by no file column, clears the marker of the rest (the sweep may then delete one),
208
+ -- records the objects only the old table knew for deletion, and drops the old table.
209
+ CREATE FUNCTION ${FILES_RELEASE_FUNCTION}(collection TEXT) RETURNS void LANGUAGE plpgsql AS $$
210
+ DECLARE
211
+ source TEXT := 'fabrika_files_' || collection;
212
+ reference RECORD;
213
+ referenced TEXT := 'false';
214
+ unreferenced BIGINT;
215
+ BEGIN
216
+ PERFORM ${FILES_COPY_FUNCTION}(collection);
217
+ FOR reference IN
218
+ SELECT c.conrelid::regclass::text AS relation, a.attname AS column_name
219
+ FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
220
+ WHERE c.contype = 'f' AND c.confrelid = '${FILES_TABLE}'::regclass AND cardinality(c.conkey) = 1
221
+ LOOP
222
+ referenced := referenced || format(' OR EXISTS (SELECT 1 FROM %s r WHERE r.%I = f.id)', reference.relation, reference.column_name);
223
+ END LOOP;
224
+ EXECUTE format('SELECT count(*) FROM ${FILES_TABLE} f WHERE f.legacy = $1 AND NOT (%s)', referenced) INTO unreferenced USING collection;
225
+ IF unreferenced > 0 THEN
226
+ RAISE EXCEPTION '${FILES_RELEASE_FUNCTION}(%): % files of the collection are referenced by no file column; adopt them in a migration first', collection, unreferenced;
227
+ END IF;
228
+ UPDATE ${FILES_TABLE} SET legacy = NULL WHERE legacy = collection;
229
+ EXECUTE format(
230
+ 'INSERT INTO ${FILE_DELETIONS_TABLE} (object_key, recorded_at) SELECT s.object_key, now() FROM %I s '
231
+ || 'WHERE NOT EXISTS (SELECT 1 FROM ${FILES_TABLE} f WHERE f.object_key = s.object_key) ON CONFLICT (object_key) DO NOTHING',
232
+ source
233
+ );
234
+ EXECUTE format('DROP TABLE %I', source);
235
+ END
236
+ $$;
237
+
238
+ -- Every collection table this schema holds now. A held form file moves too: its kind is its collection, and the grace
239
+ -- counts from the end of its hold, so the sweep keeps it as long as the hold did.
240
+ DO $$
241
+ DECLARE
242
+ found RECORD;
243
+ BEGIN
244
+ FOR found IN
245
+ SELECT substr(table_name, length('fabrika_files_') + 1) AS name FROM information_schema.tables
246
+ WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' AND table_name LIKE 'fabrika\\_files\\_%'
247
+ LOOP
248
+ PERFORM ${FILES_COPY_FUNCTION}(found.name);
249
+ END LOOP;
250
+ INSERT INTO ${FILES_TABLE} (id, kind, status, object_key, size, sha256, content_type, name, created_at)
251
+ SELECT f.id, f.collection, 'quarantined', f.object_key, f.size, f.sha256, f.content_type, f.original_name, f.held_until
252
+ FROM ${FORM_FILES_TABLE} f WHERE f.status = 'held'
253
+ ON CONFLICT (id) DO NOTHING;
254
+ DELETE FROM ${FORM_FILES_TABLE} f WHERE status = 'held' AND EXISTS (SELECT 1 FROM ${FILES_TABLE} c WHERE c.id = f.id);
255
+ END
256
+ $$;
131
257
  `,
132
258
  }),
133
259
  ])
package/src/nodes.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  JoinNode,
17
17
  LimitNode,
18
18
  OffsetNode,
19
+ type OnConflictNode,
19
20
  type OperationNode,
20
21
  OperatorNode,
21
22
  OrderByItemNode,
@@ -228,15 +229,24 @@ export function aggregateNode(
228
229
  return Object.freeze(node)
229
230
  }
230
231
 
231
- /** `answer` is `undefined` only for an inbox append, which answers nothing. */
232
- export function insertNode(table: string, values: ReadonlyMap<string, Primitive | null>, answer: ReturningNode | undefined): InsertQueryNode {
232
+ /** `conflict` names the columns of a unique index the insert does nothing on: `on conflict (<columns>) do nothing`. */
233
+ export function insertNode(
234
+ table: string,
235
+ values: ReadonlyMap<string, Primitive | null>,
236
+ answer: ReturningNode,
237
+ conflict?: readonly string[],
238
+ ): InsertQueryNode {
233
239
  const names = [...values.keys()]
240
+ const onConflict: OnConflictNode | undefined = conflict === undefined
241
+ ? undefined
242
+ : Object.freeze({ kind: 'OnConflictNode', columns: conflict.map((name) => ColumnNode.create(name)), doNothing: true })
234
243
  const node: InsertQueryNode = {
235
244
  kind: 'InsertQueryNode',
236
245
  into: TableNode.create(table),
237
246
  columns: names.map((name) => ColumnNode.create(name)),
238
247
  values: ValuesNode.create([ValueListNode.create(names.map((name) => ValueNode.create(values.get(name) ?? null)))]),
239
- ...(answer === undefined ? {} : { returning: answer }),
248
+ ...(onConflict === undefined ? {} : { onConflict }),
249
+ returning: answer,
240
250
  }
241
251
  return Object.freeze(node)
242
252
  }
@@ -331,6 +341,28 @@ export function withCursorColumns(node: SelectQueryNode, table: string, columns:
331
341
  return Object.freeze({ ...node, selections: [...(node.selections ?? []), ...cursor] })
332
342
  }
333
343
 
344
+ /**
345
+ * A queue claim's lease: `select <columns> from table where (<conditions> and (<due> is not null and <due> <= $now and
346
+ * <attempts> < $max)) and scope order by <due>, <key> limit $n for update skip locked`. A row another claim holds is
347
+ * skipped, never waited for.
348
+ */
349
+ export function claimNode(
350
+ state: SelectState,
351
+ columns: readonly string[],
352
+ due: { readonly due: string; readonly attempts: string; readonly now: Date; readonly maxAttempts: number },
353
+ scope: OperationNode,
354
+ days: NameSet,
355
+ ): SelectQueryNode {
356
+ const table = state.table
357
+ const waiting = allOf([
358
+ BinaryOperationNode.create(column(table, due.due), OperatorNode.create('is not'), ValueNode.createImmediate(null)),
359
+ BinaryOperationNode.create(column(table, due.due), OperatorNode.create('<='), ValueNode.create(due.now)),
360
+ BinaryOperationNode.create(column(table, due.attempts), OperatorNode.create('<'), ValueNode.create(due.maxAttempts)),
361
+ ])
362
+ const node = selectNode(state, columns, scope, waiting, days)
363
+ return Object.freeze({ ...node, endModifiers: [SelectModifierNode.create('ForUpdate'), SelectModifierNode.create('SkipLocked')] })
364
+ }
365
+
334
366
  /** `select <columns> from table where scope for update`: a mutation's precondition read of its own target. */
335
367
  export function lockedSelectNode(table: string, columns: readonly string[], scope: OperationNode, days: NameSet = NO_DAYS): SelectQueryNode {
336
368
  const node: SelectQueryNode = {