@fabrika/db 0.0.0-stage → 0.4.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.
@@ -0,0 +1,97 @@
1
+ // @fabrika/db/migrations — the framework's own tables in an application's schema (`fabrika_*`, ADR-0017). The
2
+ // migration runner applies them as the bundle `fabrika` before the application's `app` bundle; codegen leaves them
3
+ // out, so no store reaches them. Plain data, imported by the runner and by `@fabrika/db`'s framework side.
4
+
5
+ /** Migration identity of the framework's tables in every application schema: bundle `fabrika`, same ledger. */
6
+ export const FRAMEWORK_MIGRATION_BUNDLE = 'fabrika'
7
+
8
+ export const WEBHOOK_BINDINGS_TABLE = 'fabrika_webhook_bindings'
9
+ export const WEBHOOK_DELIVERIES_TABLE = 'fabrika_webhook_deliveries'
10
+ export const JOB_LEASES_TABLE = 'fabrika_job_leases'
11
+ export const JOB_KICKS_TABLE = 'fabrika_job_kicks'
12
+ export const FORM_ADMISSIONS_TABLE = 'fabrika_form_admissions'
13
+ export const FORM_CHALLENGES_TABLE = 'fabrika_form_challenges'
14
+
15
+ export interface FrameworkMigration {
16
+ /** Ordered by name; an applied one is immutable, like an application's. */
17
+ readonly name: string
18
+ readonly sql: string
19
+ }
20
+
21
+ export const FRAMEWORK_MIGRATIONS: readonly FrameworkMigration[] = Object.freeze([
22
+ Object.freeze({
23
+ name: '0001_webhooks.sql',
24
+ sql: `-- A binding admits deliveries for one scope value from one provider account, signed with its own secret.
25
+ CREATE TABLE ${WEBHOOK_BINDINGS_TABLE} (
26
+ id TEXT PRIMARY KEY,
27
+ webhook TEXT NOT NULL,
28
+ scope_type TEXT NOT NULL,
29
+ scope_value TEXT NOT NULL,
30
+ account TEXT NOT NULL,
31
+ secret TEXT NOT NULL,
32
+ created_by TEXT NOT NULL,
33
+ created_at BIGINT NOT NULL
34
+ );
35
+
36
+ -- GitHub signs no timestamp, so a recorded delivery id is the replay defence.
37
+ CREATE TABLE ${WEBHOOK_DELIVERIES_TABLE} (
38
+ binding_id TEXT NOT NULL REFERENCES ${WEBHOOK_BINDINGS_TABLE} (id) ON DELETE CASCADE,
39
+ delivery_id TEXT NOT NULL,
40
+ received_at BIGINT NOT NULL,
41
+ PRIMARY KEY (binding_id, delivery_id)
42
+ );
43
+ `,
44
+ }),
45
+ Object.freeze({
46
+ name: '0002_job_leases.sql',
47
+ sql: `-- One row per job: the lease a run takes, fenced by its generation, and the outcome of the last run.
48
+ CREATE TABLE ${JOB_LEASES_TABLE} (
49
+ job TEXT PRIMARY KEY,
50
+ generation INTEGER NOT NULL,
51
+ holder TEXT,
52
+ expires_at TIMESTAMPTZ NOT NULL,
53
+ attempt_deadline TIMESTAMPTZ NOT NULL,
54
+ first_seen_at TIMESTAMPTZ NOT NULL,
55
+ last_started_at TIMESTAMPTZ,
56
+ last_finished_at TIMESTAMPTZ,
57
+ last_status TEXT,
58
+ last_succeeded_at TIMESTAMPTZ,
59
+ last_duration_ms INTEGER,
60
+ last_missed_reported_at TIMESTAMPTZ
61
+ );
62
+ `,
63
+ }),
64
+ Object.freeze({
65
+ name: '0003_form_admissions.sql',
66
+ sql: `-- A form's admission counters, one row per form, bucket (a client's digest, or the form's accepted submissions) and
67
+ -- minute; a row of a past minute is expired and pruned by the next admission.
68
+ CREATE TABLE ${FORM_ADMISSIONS_TABLE} (
69
+ form TEXT NOT NULL,
70
+ bucket TEXT NOT NULL,
71
+ window_start TIMESTAMPTZ NOT NULL,
72
+ count INTEGER NOT NULL,
73
+ PRIMARY KEY (form, bucket, window_start)
74
+ );
75
+ CREATE INDEX ${FORM_ADMISSIONS_TABLE}_window_start ON ${FORM_ADMISSIONS_TABLE} (window_start);
76
+
77
+ -- The digest of each challenge token a form accepted, until the token could no longer verify: a replay is refused.
78
+ CREATE TABLE ${FORM_CHALLENGES_TABLE} (
79
+ form TEXT NOT NULL,
80
+ token_digest TEXT NOT NULL,
81
+ expires_at TIMESTAMPTZ NOT NULL,
82
+ PRIMARY KEY (form, token_digest)
83
+ );
84
+ CREATE INDEX ${FORM_CHALLENGES_TABLE}_expires_at ON ${FORM_CHALLENGES_TABLE} (expires_at);
85
+ `,
86
+ }),
87
+ Object.freeze({
88
+ name: '0004_job_kicks.sql',
89
+ sql: `-- Whether a kick arrived after the job's current run started: that run runs the job once more after it succeeded.
90
+ -- Apart from the lease row, so a kick never waits on the fence a running attempt holds there.
91
+ CREATE TABLE ${JOB_KICKS_TABLE} (
92
+ job TEXT PRIMARY KEY,
93
+ pending BOOLEAN NOT NULL
94
+ );
95
+ `,
96
+ }),
97
+ ])
package/src/nodes.ts ADDED
@@ -0,0 +1,344 @@
1
+ import {
2
+ AggregateFunctionNode,
3
+ AliasNode,
4
+ AndNode,
5
+ BinaryOperationNode,
6
+ CastNode,
7
+ ColumnNode,
8
+ ColumnUpdateNode,
9
+ DataTypeNode,
10
+ type DeleteQueryNode,
11
+ FromNode,
12
+ GroupByItemNode,
13
+ GroupByNode,
14
+ IdentifierNode,
15
+ type InsertQueryNode,
16
+ JoinNode,
17
+ LimitNode,
18
+ OffsetNode,
19
+ type OperationNode,
20
+ OperatorNode,
21
+ OrderByItemNode,
22
+ OrderByNode,
23
+ OrNode,
24
+ ParensNode,
25
+ RawNode,
26
+ ReferenceNode,
27
+ ReturningNode,
28
+ SelectAllNode,
29
+ SelectionNode,
30
+ SelectModifierNode,
31
+ type SelectQueryNode,
32
+ TableNode,
33
+ type UpdateQueryNode,
34
+ ValueListNode,
35
+ ValueNode,
36
+ ValuesNode,
37
+ WhereNode,
38
+ } from 'kysely'
39
+ import { inSet, type NameSet, nameSet, namesOf } from './builtins'
40
+ import type { Compare, Condition, Contains, JoinState, Primitive, SelectState } from './description'
41
+ import { QueryRefusedError } from './errors'
42
+
43
+ export function column(table: string, name: string): ReferenceNode {
44
+ return ReferenceNode.create(ColumnNode.create(name), TableNode.create(table))
45
+ }
46
+
47
+ export function equals(table: string, name: string, value: Primitive): OperationNode {
48
+ return BinaryOperationNode.create(column(table, name), OperatorNode.create('='), ValueNode.create(value))
49
+ }
50
+
51
+ export function allOf(nodes: readonly OperationNode[]): OperationNode {
52
+ const [first, ...rest] = nodes
53
+ if (first === undefined) {
54
+ throw new QueryRefusedError('statement_shape', 'an empty conjunction')
55
+ }
56
+ return rest.reduce<OperationNode>((left, right) => AndNode.create(left, right), first)
57
+ }
58
+
59
+ /** One node, or the OR of several in a parenthesis: inside an AND, `a and b or c` would read as `(a and b) or c`. */
60
+ export function anyOf(nodes: readonly OperationNode[]): OperationNode {
61
+ const [first, ...rest] = nodes
62
+ if (first === undefined) {
63
+ throw new QueryRefusedError('statement_shape', 'an empty disjunction')
64
+ }
65
+ return rest.length === 0 ? first : ParensNode.create(rest.reduce<OperationNode>((left, right) => OrNode.create(left, right), first))
66
+ }
67
+
68
+ export function inList(table: string, name: string, values: readonly string[]): OperationNode {
69
+ return BinaryOperationNode.create(
70
+ column(table, name),
71
+ OperatorNode.create('in'),
72
+ ValueListNode.create(values.map((value) => ValueNode.create(value))),
73
+ )
74
+ }
75
+
76
+ /** The scope of a `Within` that is not limited to scope values. */
77
+ export function everything(): OperationNode {
78
+ return ValueNode.createImmediate(true)
79
+ }
80
+
81
+ function compareNode(table: string, condition: Compare): OperationNode {
82
+ const value = condition.value
83
+ const right = value === null
84
+ ? ValueNode.createImmediate(null)
85
+ : Array.isArray(value)
86
+ ? ValueListNode.create(value.map((item) => ValueNode.create(item)))
87
+ : ValueNode.create(value)
88
+ return BinaryOperationNode.create(column(table, condition.column), OperatorNode.create(condition.operator), right)
89
+ }
90
+
91
+ /** LIKE treats `\` as its escape character by default, so the text matches literally. */
92
+ export function containsPattern(text: string): string {
93
+ return `%${text.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`
94
+ }
95
+
96
+ function containsNode(table: string, condition: Contains): OperationNode {
97
+ return BinaryOperationNode.create(column(table, condition.column), OperatorNode.create('ilike'), ValueNode.create(containsPattern(condition.text)))
98
+ }
99
+
100
+ function conditionNode(table: string, condition: Condition): OperationNode {
101
+ switch (condition.kind) {
102
+ case 'compare':
103
+ return compareNode(table, condition)
104
+ case 'contains':
105
+ return containsNode(table, condition)
106
+ case 'any': {
107
+ const [first, ...rest] = condition.conditions.map((item) => item.kind === 'compare' ? compareNode(table, item) : containsNode(table, item))
108
+ if (first === undefined) {
109
+ throw new QueryRefusedError('statement_shape', 'whereAny needs at least one condition')
110
+ }
111
+ return ParensNode.create(rest.reduce<OperationNode>((left, right) => OrNode.create(left, right), first))
112
+ }
113
+ }
114
+ }
115
+
116
+ function conditionsNode(table: string, conditions: readonly Condition[]): OperationNode | undefined {
117
+ return conditions.length === 0 ? undefined : allOf(conditions.map((condition) => conditionNode(table, condition)))
118
+ }
119
+
120
+ /**
121
+ * The caller's conditions go inside an explicit parenthesis and the scope is the top-level right conjunct, so
122
+ * `x or true` cannot escape it. The guard checks this shape.
123
+ */
124
+ export function scopedWhere(conditions: OperationNode | undefined, scope: OperationNode): WhereNode {
125
+ return WhereNode.create(conditions === undefined ? scope : AndNode.create(ParensNode.create(conditions), scope))
126
+ }
127
+
128
+ function selection(table: string, name: string): SelectionNode {
129
+ return SelectionNode.create(column(table, name))
130
+ }
131
+
132
+ /**
133
+ * A selected column; a `date` one as `cast(<column> as text)` under its own name, so the layer reads the database's
134
+ * day rather than the `Date` `Bun.SQL` decodes, which drifts by the process's offset outside 0001–9999.
135
+ */
136
+ function rowSelection(table: string, name: string, days: NameSet): SelectionNode {
137
+ if (!inSet(days, name)) {
138
+ return selection(table, name)
139
+ }
140
+ return SelectionNode.create(AliasNode.create(CastNode.create(column(table, name), DataTypeNode.create('text')), IdentifierNode.create(name)))
141
+ }
142
+
143
+ const NO_DAYS: NameSet = nameSet([])
144
+
145
+ /** `returning key as id[, version as version]`: a mutation's acknowledgement, never row data. */
146
+ export function acknowledgement(table: string, key: string, version: string | undefined): ReturningNode {
147
+ const fields: [string, string][] = version === undefined ? [[key, 'id']] : [[key, 'id'], [version, 'version']]
148
+ return ReturningNode.create(fields.map(([name, alias]) => SelectionNode.create(AliasNode.create(column(table, name), IdentifierNode.create(alias)))))
149
+ }
150
+
151
+ function joinNode(parent: string, join: JoinState): JoinNode {
152
+ const child = join.join.shape.table
153
+ const foreignKey = allOf(
154
+ join.join.on.map((pair) =>
155
+ BinaryOperationNode.create(column(child, pair.childColumn), OperatorNode.create('='), column(parent, pair.parentColumn))
156
+ ),
157
+ )
158
+ const conditions = conditionsNode(child, join.select.conditions)
159
+ const on = conditions === undefined ? foreignKey : AndNode.create(foreignKey, ParensNode.create(conditions))
160
+ return JoinNode.createWithOn('InnerJoin', TableNode.create(child), on)
161
+ }
162
+
163
+ function joinedSelections(join: JoinState): SelectionNode[] {
164
+ const child = join.join.shape.table
165
+ const columns = join.select.columns ?? namesOf(join.join.shape.visible)
166
+ return columns.map((name) => SelectionNode.create(AliasNode.create(column(child, name), IdentifierNode.create(`${child}.${name}`))))
167
+ }
168
+
169
+ /** `after` is a keyset page's position, ANDed to the caller's conditions inside their parenthesis. */
170
+ export function selectNode(
171
+ state: SelectState,
172
+ visible: readonly string[],
173
+ scope: OperationNode,
174
+ after?: OperationNode,
175
+ days: NameSet = NO_DAYS,
176
+ ): SelectQueryNode {
177
+ const table = state.table
178
+ const columns = state.columns ?? visible
179
+ const conditions = conditionsNode(table, state.conditions)
180
+ const filter = after === undefined ? conditions : conditions === undefined ? after : AndNode.create(conditions, ParensNode.create(after))
181
+ const orderBy = state.orderBy.map((order) => OrderByItemNode.create(column(table, order.column), RawNode.createWithSql(order.direction)))
182
+ const node: SelectQueryNode = {
183
+ kind: 'SelectQueryNode',
184
+ from: FromNode.create([TableNode.create(table)]),
185
+ selections: [...columns.map((name) => rowSelection(table, name, days)), ...state.joins.flatMap(joinedSelections)],
186
+ joins: state.joins.length === 0 ? undefined : state.joins.map((join) => joinNode(table, join)),
187
+ where: scopedWhere(filter, scope),
188
+ orderBy: orderBy.length === 0 ? undefined : OrderByNode.create(orderBy),
189
+ limit: state.limit === undefined ? undefined : LimitNode.create(ValueNode.create(state.limit)),
190
+ offset: state.offset === undefined ? undefined : OffsetNode.create(ValueNode.create(state.offset)),
191
+ }
192
+ return Object.freeze(node)
193
+ }
194
+
195
+ export interface AggregateColumns {
196
+ readonly groupBy: readonly string[]
197
+ readonly count: boolean
198
+ readonly sum: readonly string[]
199
+ readonly min: readonly string[]
200
+ readonly max: readonly string[]
201
+ }
202
+
203
+ function aggregate(func: string, aggregated: OperationNode, alias: string): SelectionNode {
204
+ return SelectionNode.create(AliasNode.create(AggregateFunctionNode.create(func, [aggregated]), IdentifierNode.create(alias)))
205
+ }
206
+
207
+ export function aggregateNode(
208
+ table: string,
209
+ columns: AggregateColumns,
210
+ conditions: readonly Condition[],
211
+ scope: OperationNode,
212
+ days: NameSet = NO_DAYS,
213
+ ): SelectQueryNode {
214
+ const selections = [
215
+ ...columns.groupBy.map((name) => rowSelection(table, name, days)),
216
+ ...(columns.count ? [aggregate('count', SelectAllNode.create(), 'count')] : []),
217
+ ...columns.sum.map((name) => aggregate('sum', column(table, name), `sum_${name}`)),
218
+ ...columns.min.map((name) => aggregate('min', column(table, name), `min_${name}`)),
219
+ ...columns.max.map((name) => aggregate('max', column(table, name), `max_${name}`)),
220
+ ]
221
+ const node: SelectQueryNode = {
222
+ kind: 'SelectQueryNode',
223
+ from: FromNode.create([TableNode.create(table)]),
224
+ selections,
225
+ where: scopedWhere(conditionsNode(table, conditions), scope),
226
+ groupBy: columns.groupBy.length === 0 ? undefined : GroupByNode.create(columns.groupBy.map((name) => GroupByItemNode.create(column(table, name)))),
227
+ }
228
+ return Object.freeze(node)
229
+ }
230
+
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 {
233
+ const names = [...values.keys()]
234
+ const node: InsertQueryNode = {
235
+ kind: 'InsertQueryNode',
236
+ into: TableNode.create(table),
237
+ columns: names.map((name) => ColumnNode.create(name)),
238
+ values: ValuesNode.create([ValueListNode.create(names.map((name) => ValueNode.create(values.get(name) ?? null)))]),
239
+ ...(answer === undefined ? {} : { returning: answer }),
240
+ }
241
+ return Object.freeze(node)
242
+ }
243
+
244
+ export function updateNode(
245
+ table: string,
246
+ values: ReadonlyMap<string, Primitive | null>,
247
+ bump: string | undefined,
248
+ where: WhereNode,
249
+ answer: ReturningNode,
250
+ ): UpdateQueryNode {
251
+ const updates = [...values].map(([name, value]) => ColumnUpdateNode.create(ColumnNode.create(name), ValueNode.create(value)))
252
+ if (bump !== undefined) {
253
+ const next = BinaryOperationNode.create(column(table, bump), OperatorNode.create('+'), ValueNode.create(1))
254
+ updates.push(ColumnUpdateNode.create(ColumnNode.create(bump), next))
255
+ }
256
+ if (updates.length === 0) {
257
+ throw new QueryRefusedError('statement_shape', 'nothing to update')
258
+ }
259
+ const node: UpdateQueryNode = { kind: 'UpdateQueryNode', table: TableNode.create(table), updates, where, returning: answer }
260
+ return Object.freeze(node)
261
+ }
262
+
263
+ export function deleteNode(table: string, where: WhereNode, key: string): DeleteQueryNode {
264
+ const node: DeleteQueryNode = {
265
+ kind: 'DeleteQueryNode',
266
+ from: FromNode.create([TableNode.create(table)]),
267
+ where,
268
+ returning: acknowledgement(table, key, undefined),
269
+ }
270
+ return Object.freeze(node)
271
+ }
272
+
273
+ /** `select key from table where (conditions) and scope limit 1`: does the scoped object exist? */
274
+ export function existsNode(table: string, key: string, scope: OperationNode, conditions?: OperationNode): SelectQueryNode {
275
+ const node: SelectQueryNode = {
276
+ kind: 'SelectQueryNode',
277
+ from: FromNode.create([TableNode.create(table)]),
278
+ selections: [selection(table, key)],
279
+ where: scopedWhere(conditions, scope),
280
+ limit: LimitNode.create(ValueNode.create(1)),
281
+ }
282
+ return Object.freeze(node)
283
+ }
284
+
285
+ /** `select key as id, version as version from table where (conditions) and scope limit 1`: an update that sets nothing. */
286
+ export function acknowledgementNode(table: string, key: string, version: string | undefined, where: WhereNode): SelectQueryNode {
287
+ const node: SelectQueryNode = {
288
+ kind: 'SelectQueryNode',
289
+ from: FromNode.create([TableNode.create(table)]),
290
+ selections: acknowledgement(table, key, version).selections,
291
+ where,
292
+ limit: LimitNode.create(ValueNode.create(1)),
293
+ }
294
+ return Object.freeze(node)
295
+ }
296
+
297
+ /** `select columns from table where scope limit 1`: the containment columns of one object, for the framework's locator. */
298
+ export function locateNode(table: string, columns: readonly string[], scope: OperationNode): SelectQueryNode {
299
+ const node: SelectQueryNode = {
300
+ kind: 'SelectQueryNode',
301
+ from: FromNode.create([TableNode.create(table)]),
302
+ selections: columns.map((name) => selection(table, name)),
303
+ where: scopedWhere(undefined, scope),
304
+ limit: LimitNode.create(ValueNode.create(1)),
305
+ }
306
+ return Object.freeze(node)
307
+ }
308
+
309
+ /**
310
+ * The same check, locked: `for share` keeps the row from moving until the transaction that checked it ends, `for
311
+ * update` also keeps every other writer and locker of it waiting.
312
+ */
313
+ export function lockNode(table: string, key: string, scope: OperationNode, lock: 'share' | 'update' = 'share'): SelectQueryNode {
314
+ return Object.freeze({ ...existsNode(table, key, scope), endModifiers: [lockModifier(lock)] })
315
+ }
316
+
317
+ function lockModifier(lock: 'share' | 'update'): SelectModifierNode {
318
+ return SelectModifierNode.create(lock === 'share' ? 'ForShare' : 'ForUpdate')
319
+ }
320
+
321
+ /** The alias a keyset page's position column is selected under; a column name holds no dot, so none collides. */
322
+ export function cursorAlias(index: number): string {
323
+ return `keyset.c${index}`
324
+ }
325
+
326
+ /** `select …, cast(<column> as text) as "keyset.c<n>"`: each order column's exact value, which a Date would round. */
327
+ export function withCursorColumns(node: SelectQueryNode, table: string, columns: readonly string[]): SelectQueryNode {
328
+ const cursor = columns.map((name, index) =>
329
+ SelectionNode.create(AliasNode.create(CastNode.create(column(table, name), DataTypeNode.create('text')), IdentifierNode.create(cursorAlias(index))))
330
+ )
331
+ return Object.freeze({ ...node, selections: [...(node.selections ?? []), ...cursor] })
332
+ }
333
+
334
+ /** `select <columns> from table where scope for update`: a mutation's precondition read of its own target. */
335
+ export function lockedSelectNode(table: string, columns: readonly string[], scope: OperationNode, days: NameSet = NO_DAYS): SelectQueryNode {
336
+ const node: SelectQueryNode = {
337
+ kind: 'SelectQueryNode',
338
+ from: FromNode.create([TableNode.create(table)]),
339
+ selections: columns.map((name) => rowSelection(table, name, days)),
340
+ where: scopedWhere(undefined, scope),
341
+ endModifiers: [lockModifier('update')],
342
+ }
343
+ return Object.freeze(node)
344
+ }