@fabrika/db 0.6.0 → 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.
- package/package.json +3 -3
- package/src/{form-admissions.ts → admissions.ts} +29 -36
- package/src/capability.ts +30 -3
- package/src/description.ts +2 -1
- package/src/errors.ts +8 -2
- package/src/file-columns.ts +184 -0
- package/src/file-references.ts +60 -0
- package/src/framework-tables.ts +100 -45
- package/src/framework.ts +7 -29
- package/src/guard.ts +42 -8
- package/src/index.ts +2 -13
- package/src/job-leases.ts +11 -0
- package/src/links.ts +1 -1
- package/src/migrations.ts +126 -0
- package/src/nodes.ts +35 -3
- package/src/orm-types.ts +152 -28
- package/src/orm.ts +397 -38
- package/src/policy.ts +221 -74
- package/src/registry.ts +7 -18
- package/src/store.ts +447 -143
- package/src/stored-files.ts +402 -0
- package/src/tables.ts +32 -26
- package/src/testing.ts +39 -7
- package/src/vocabulary-tables.ts +4 -4
- package/src/file-table-sql.ts +0 -133
- package/src/file-tables.ts +0 -622
- package/src/form-files.ts +0 -226
package/src/framework-tables.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
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
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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 {
|
|
22
|
-
export type
|
|
23
|
-
|
|
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:
|
|
71
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
326
|
-
const
|
|
327
|
-
|
|
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
|
|
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
|
-
/** `
|
|
232
|
-
export function insertNode(
|
|
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
|
-
...(
|
|
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 = {
|