@fabrika/db 0.8.2 → 0.9.0-cloudflare.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +44 -12
- package/src/admissions.ts +78 -12
- package/src/application-rows.ts +108 -0
- package/src/capability.ts +5 -0
- package/src/codegen-sqlite.ts +154 -0
- package/src/codegen.ts +59 -9
- package/src/database.ts +208 -56
- package/src/errors.ts +1 -0
- package/src/file-columns.ts +4 -1
- package/src/framework-tables.ts +28 -8
- package/src/framework.ts +10 -2
- package/src/guard.ts +8 -0
- package/src/index.ts +9 -1
- package/src/job-leases.ts +157 -37
- package/src/links.ts +8 -1
- package/src/migrations-sqlite.ts +132 -0
- package/src/nodes.ts +45 -9
- package/src/orm.ts +25 -15
- package/src/policy.ts +8 -2
- package/src/postgres.ts +26 -0
- package/src/registry.ts +8 -2
- package/src/sql.ts +15 -7
- package/src/sqlite-bun.ts +122 -0
- package/src/sqlite-migrate.ts +242 -0
- package/src/sqlite.ts +224 -0
- package/src/store.ts +336 -62
- package/src/stored-files.ts +149 -46
- package/src/tables.ts +16 -0
- package/src/temporal.ts +115 -9
- package/src/testing-shared.ts +733 -0
- package/src/testing-sqlite.ts +513 -0
- package/src/testing.ts +170 -482
- package/types/src/admissions.d.ts +21 -0
- package/types/src/application-rows.d.ts +58 -0
- package/types/src/builtins.d.ts +42 -0
- package/types/src/capability.d.ts +119 -0
- package/types/src/codegen-sqlite.d.ts +6 -0
- package/types/src/codegen.d.ts +82 -0
- package/types/src/database.d.ts +179 -0
- package/types/src/description.d.ts +109 -0
- package/types/src/errors.d.ts +98 -0
- package/types/src/file-columns.d.ts +37 -0
- package/types/src/file-references.d.ts +27 -0
- package/types/src/framework-tables.d.ts +59 -0
- package/types/src/framework.d.ts +37 -0
- package/types/src/guard.d.ts +23 -0
- package/types/src/identifier.d.ts +4 -0
- package/types/src/index.d.ts +10 -0
- package/types/src/job-leases.d.ts +93 -0
- package/types/src/links.d.ts +13 -0
- package/types/src/migrations-sqlite.d.ts +4 -0
- package/types/src/migrations.d.ts +25 -0
- package/types/src/nodes.d.ts +63 -0
- package/types/src/orm-types.d.ts +638 -0
- package/types/src/orm.d.ts +77 -0
- package/types/src/policy.d.ts +354 -0
- package/types/src/postgres.d.ts +12 -0
- package/types/src/production.d.ts +5 -0
- package/types/src/registry.d.ts +40 -0
- package/types/src/sql.d.ts +59 -0
- package/types/src/sqlite-bun.d.ts +13 -0
- package/types/src/sqlite-migrate.d.ts +28 -0
- package/types/src/sqlite.d.ts +24 -0
- package/types/src/store.d.ts +345 -0
- package/types/src/stored-files.d.ts +98 -0
- package/types/src/tables.d.ts +21 -0
- package/types/src/temporal.d.ts +33 -0
- package/types/src/testing-shared.d.ts +201 -0
- package/types/src/testing-sqlite.d.ts +22 -0
- package/types/src/testing.d.ts +35 -0
- package/types/src/vocabulary-tables.d.ts +225 -0
package/src/orm.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// is that invocation, then refuses what the site may not do with the message its types give, then runs the store's
|
|
4
4
|
// own checks of kind, dimension and action against the capability.
|
|
5
5
|
|
|
6
|
+
import { uuidv7 } from '@fabrika/platform'
|
|
6
7
|
import type { OperationNode } from 'kysely'
|
|
7
8
|
import { BinaryOperationNode, OperatorNode, ValueNode } from 'kysely'
|
|
8
9
|
import { freeze, hasOwn, inSet, isSafeInteger, nameSet, namesOf, ownDescriptor, ownKeys, ownNames, parseList, setOwn } from './builtins'
|
|
@@ -41,11 +42,14 @@ import {
|
|
|
41
42
|
appListPredicate,
|
|
42
43
|
appRecordPredicate,
|
|
43
44
|
appRowPredicate,
|
|
45
|
+
boundTerms,
|
|
46
|
+
boundValues,
|
|
44
47
|
childObjectPredicate,
|
|
45
48
|
childScope,
|
|
46
49
|
claimedRowPredicate,
|
|
47
50
|
claimScope,
|
|
48
51
|
type DataLayerRuntime,
|
|
52
|
+
dialectOf,
|
|
49
53
|
heldAction,
|
|
50
54
|
insertPrepared,
|
|
51
55
|
insertRow,
|
|
@@ -60,6 +64,7 @@ import {
|
|
|
60
64
|
reachPredicate,
|
|
61
65
|
readValues,
|
|
62
66
|
removeRow,
|
|
67
|
+
rowColumnsOf,
|
|
63
68
|
type Runner,
|
|
64
69
|
runtimeOf,
|
|
65
70
|
scopedApplicationRead,
|
|
@@ -72,7 +77,7 @@ import {
|
|
|
72
77
|
updatingRef,
|
|
73
78
|
versionArgument,
|
|
74
79
|
} from './store'
|
|
75
|
-
import {
|
|
80
|
+
import { answerRows, timestampInput } from './temporal'
|
|
76
81
|
|
|
77
82
|
/** A list page holds this many rows unless the caller asks for fewer, and never more than `MAX_LIST_LIMIT`. */
|
|
78
83
|
export const DEFAULT_LIST_LIMIT = 50
|
|
@@ -331,7 +336,7 @@ function targetRow(
|
|
|
331
336
|
const node = lockedSelectNode(context.model.name, visibleFor(context.model, grant.action), scope, context.model.dates)
|
|
332
337
|
const session = await run.read()
|
|
333
338
|
const rows = await session.run(node, { root: 'SelectQueryNode', table: context.model.name, joins: {}, scope, lock: 'update' })
|
|
334
|
-
const [row] =
|
|
339
|
+
const [row] = answerRows(dialectOf(context), context.model.name, rowColumnsOf(context.model), rows)
|
|
335
340
|
if (row === undefined) {
|
|
336
341
|
throw new NotFoundError(`${context.model.name} not found`)
|
|
337
342
|
}
|
|
@@ -590,7 +595,7 @@ function applicationRows(
|
|
|
590
595
|
const node = lockedSelectNode(table, visibleFor(model, action), scope, model.dates)
|
|
591
596
|
const session = await run.read()
|
|
592
597
|
const rows = await session.run(node, { root: 'SelectQueryNode', table, joins: {}, scope, lock: 'update' })
|
|
593
|
-
const [row] =
|
|
598
|
+
const [row] = answerRows(dialectOf(context), table, rowColumnsOf(model), rows)
|
|
594
599
|
if (row === undefined) {
|
|
595
600
|
throw new NotFoundError(`${table} not found`)
|
|
596
601
|
}
|
|
@@ -752,10 +757,11 @@ async function claimRows(
|
|
|
752
757
|
}
|
|
753
758
|
const limit = limitOf(options['limit'])
|
|
754
759
|
const visible = visibleFor(model, grant.action)
|
|
755
|
-
const conditions = conditionsOf(actionShape(model, visible), options['where'])
|
|
760
|
+
const conditions = conditionsOf(context, actionShape(model, visible), options['where'])
|
|
756
761
|
if (scope === undefined) {
|
|
757
762
|
return []
|
|
758
763
|
}
|
|
764
|
+
const dialect = dialectOf(context)
|
|
759
765
|
const key = keyOf(model)
|
|
760
766
|
const chain = model.chain.map((link) => link.column)
|
|
761
767
|
const selected = namesOf(nameSet([...visible, key, version, queue.attempts, ...chain]))
|
|
@@ -769,6 +775,7 @@ async function claimRows(
|
|
|
769
775
|
{ due: queue.due, attempts: queue.attempts, now, maxAttempts: queue.maxAttempts },
|
|
770
776
|
scope,
|
|
771
777
|
model.dates,
|
|
778
|
+
dialect,
|
|
772
779
|
)
|
|
773
780
|
const rows = await session.run<Readonly<Record<string, unknown>>>(node, { root: 'SelectQueryNode', table, joins: {}, scope, lock: 'claim' })
|
|
774
781
|
const byAttempts = new Map<number, string[]>()
|
|
@@ -785,7 +792,7 @@ async function claimRows(
|
|
|
785
792
|
const due = new Date(now.getTime() + backoffMs(tickMs, attempts + 1))
|
|
786
793
|
const changes = new Map<string, Primitive>([[queue.attempts, attempts + 1], [queue.due, due]])
|
|
787
794
|
const updated = await session.run<Readonly<Record<string, unknown>>>(
|
|
788
|
-
updateNode(table, changes, version, scopedWhere(inList(table, key, ids), scope), answer(context)),
|
|
795
|
+
updateNode(table, boundValues(context, changes), version, scopedWhere(inList(table, key, ids), scope), answer(context)),
|
|
789
796
|
{ root: 'UpdateQueryNode', table, joins: {}, scope },
|
|
790
797
|
)
|
|
791
798
|
for (const row of updated) {
|
|
@@ -797,7 +804,7 @@ async function claimRows(
|
|
|
797
804
|
claimed.set(id, { version: next, attempts: attempts + 1, due })
|
|
798
805
|
}
|
|
799
806
|
}
|
|
800
|
-
return { rows: await answerFiles(model, site.files, session,
|
|
807
|
+
return { rows: await answerFiles(model, site.files, session, answerRows(dialect, table, rowColumnsOf(model), rows), grant.action), claimed }
|
|
801
808
|
})
|
|
802
809
|
return leased.rows.map((row) => {
|
|
803
810
|
const id = String(row[key])
|
|
@@ -957,10 +964,11 @@ function unscopedRows(context: TableContext, site: DataSite, run: Runner, mode:
|
|
|
957
964
|
aggregate: bound(site, async <Row>(spec: AggregateSpec<Row>) => aggregateRows(context, run, everything(), context.shape, spec)),
|
|
958
965
|
get: bound(site, async (id: unknown) => {
|
|
959
966
|
const scope = everything()
|
|
960
|
-
const
|
|
967
|
+
const dialect = dialectOf(context)
|
|
968
|
+
const node = selectNode(rowState(table, [], [], 1), visible, scope, byKey(id), model.dates, dialect)
|
|
961
969
|
const session = await run.read()
|
|
962
970
|
const rows = await session.run(node, { root: 'SelectQueryNode', table, joins: {}, scope })
|
|
963
|
-
const [row] =
|
|
971
|
+
const [row] = answerRows(dialect, table, rowColumnsOf(model), rows)
|
|
964
972
|
if (row === undefined) {
|
|
965
973
|
throw new NotFoundError(`${table} not found`)
|
|
966
974
|
}
|
|
@@ -1003,7 +1011,7 @@ async function insertUnscoped(context: TableContext, run: Runner, principal: str
|
|
|
1003
1011
|
)
|
|
1004
1012
|
}
|
|
1005
1013
|
}
|
|
1006
|
-
row.set(keyOf(model),
|
|
1014
|
+
row.set(keyOf(model), uuidv7())
|
|
1007
1015
|
if (model.version !== undefined) {
|
|
1008
1016
|
row.set(model.version, 1)
|
|
1009
1017
|
}
|
|
@@ -1045,7 +1053,8 @@ function readOptions(options: unknown, allowed: readonly string[], what: string)
|
|
|
1045
1053
|
return read
|
|
1046
1054
|
}
|
|
1047
1055
|
|
|
1048
|
-
|
|
1056
|
+
/** A list's `where`, parsed, with each value as its dialect binds it (`boundTerms`). */
|
|
1057
|
+
function conditionsOf(context: TableContext, shape: TableShape, where: unknown): readonly (Compare | Contains)[] {
|
|
1049
1058
|
if (where === undefined) {
|
|
1050
1059
|
return []
|
|
1051
1060
|
}
|
|
@@ -1059,7 +1068,7 @@ function conditionsOf(shape: TableShape, where: unknown): (Compare | Contains)[]
|
|
|
1059
1068
|
if (conditions === undefined) {
|
|
1060
1069
|
throw new QueryRefusedError('invalid_argument', 'where takes a plain array of [column, operator, value] conditions')
|
|
1061
1070
|
}
|
|
1062
|
-
return conditions
|
|
1071
|
+
return boundTerms(context, context.model, conditions)
|
|
1063
1072
|
}
|
|
1064
1073
|
|
|
1065
1074
|
function orderOf(shape: TableShape, orderBy: unknown, tiebreak: readonly string[]): OrderColumn[] {
|
|
@@ -1202,15 +1211,16 @@ async function listRows(
|
|
|
1202
1211
|
const table = context.model.name
|
|
1203
1212
|
const options = readOptions(input, ['where', 'orderBy', 'limit', 'after'], 'list')
|
|
1204
1213
|
const shape = actionShape(context.model, visible)
|
|
1205
|
-
const conditions = conditionsOf(shape, options['where'])
|
|
1214
|
+
const conditions = conditionsOf(context, shape, options['where'])
|
|
1206
1215
|
const order = orderOf(shape, options['orderBy'], tiebreak)
|
|
1207
1216
|
const limit = limitOf(options['limit'])
|
|
1208
1217
|
const position = options['after'] === undefined ? undefined : keysetAfter(table, order, decodeCursor(options['after'], order))
|
|
1209
1218
|
if (scope === undefined) {
|
|
1210
1219
|
return { items: [], next: null }
|
|
1211
1220
|
}
|
|
1221
|
+
const dialect = dialectOf(context)
|
|
1212
1222
|
const node = withCursorColumns(
|
|
1213
|
-
selectNode(rowState(table, conditions, order, limit + 1), visible, scope, position, context.model.dates),
|
|
1223
|
+
selectNode(rowState(table, conditions, order, limit + 1), visible, scope, position, context.model.dates, dialect),
|
|
1214
1224
|
table,
|
|
1215
1225
|
order.map((term) => term.column),
|
|
1216
1226
|
)
|
|
@@ -1221,7 +1231,7 @@ async function listRows(
|
|
|
1221
1231
|
joins: {},
|
|
1222
1232
|
scope,
|
|
1223
1233
|
})
|
|
1224
|
-
const page =
|
|
1234
|
+
const page = answerRows(dialect, table, rowColumnsOf(context.model), rows.slice(0, limit))
|
|
1225
1235
|
const last = page[page.length - 1]
|
|
1226
1236
|
const items = page.map((row) => withoutCursor(row, order))
|
|
1227
1237
|
return {
|
|
@@ -1239,7 +1249,7 @@ async function countRows(
|
|
|
1239
1249
|
): Promise<number> {
|
|
1240
1250
|
const table = context.model.name
|
|
1241
1251
|
const options = readOptions(input, ['where'], 'count')
|
|
1242
|
-
const conditions = conditionsOf(actionShape(context.model, visible), options['where'])
|
|
1252
|
+
const conditions = conditionsOf(context, actionShape(context.model, visible), options['where'])
|
|
1243
1253
|
if (scope === undefined) {
|
|
1244
1254
|
return 0
|
|
1245
1255
|
}
|
package/src/policy.ts
CHANGED
|
@@ -160,6 +160,8 @@ export interface TableColumns<Col extends string = string, Table extends string
|
|
|
160
160
|
readonly dates?: readonly Col[]
|
|
161
161
|
/** The `timestamp` and `timestamptz` columns, which a write takes as a `Date` or strict ISO 8601 text; absent when none. */
|
|
162
162
|
readonly timestamps?: readonly Col[]
|
|
163
|
+
/** On SQLite, the columns declared `BOOLEAN`, stored as 0 or 1 and read as a boolean; absent when none. */
|
|
164
|
+
readonly booleans?: readonly Col[]
|
|
163
165
|
}
|
|
164
166
|
|
|
165
167
|
/** The columns and foreign keys of every table, generated next to the schema types. */
|
|
@@ -221,6 +223,8 @@ export interface TableModel {
|
|
|
221
223
|
readonly dates: NameSet
|
|
222
224
|
/** The `timestamp` and `timestamptz` columns: a write's text must be strict ISO 8601 with an offset. */
|
|
223
225
|
readonly timestamps: NameSet
|
|
226
|
+
/** On SQLite, the `BOOLEAN` columns, stored as 0 or 1. */
|
|
227
|
+
readonly booleans: NameSet
|
|
224
228
|
/** The table's foreign keys, from the column list; a containment column must be one to its container's key. */
|
|
225
229
|
readonly foreignKeys: readonly ForeignKey[]
|
|
226
230
|
/**
|
|
@@ -342,7 +346,7 @@ function parseVocabulary(input: unknown): VocabularyModel {
|
|
|
342
346
|
return freeze({ dimensions: freeze(containment), actions: nameSet(actions) })
|
|
343
347
|
}
|
|
344
348
|
|
|
345
|
-
const COLUMN_LIST_FIELDS = nameSet(['columns', 'foreignKeys', 'dates', 'timestamps'])
|
|
349
|
+
const COLUMN_LIST_FIELDS = nameSet(['columns', 'foreignKeys', 'dates', 'timestamps', 'booleans'])
|
|
346
350
|
const FOREIGN_KEY_FIELDS = nameSet(['columns', 'table', 'references'])
|
|
347
351
|
const POLICY_FIELDS = nameSet(['scope', 'actions', 'insert', 'update', 'version', 'createdBy', 'columns', 'queue', 'files'])
|
|
348
352
|
const QUEUE_FIELDS = nameSet(['attempts', 'due', 'maxAttempts'])
|
|
@@ -524,6 +528,7 @@ function parseTable(table: string, columnList: unknown, policyInput: unknown, vo
|
|
|
524
528
|
}
|
|
525
529
|
const dates = parseColumnSet(table, ownEntry(entry, 'dates'), column, 'dates')
|
|
526
530
|
const timestamps = parseColumnSet(table, ownEntry(entry, 'timestamps'), column, 'timestamps')
|
|
531
|
+
const booleans = parseColumnSet(table, ownEntry(entry, 'booleans'), column, 'booleans')
|
|
527
532
|
const queue = queueInput === undefined
|
|
528
533
|
? undefined
|
|
529
534
|
: parseQueue(table, queueInput, column, { columns, timestamps, version, actions, update, columnWrites })
|
|
@@ -551,6 +556,7 @@ function parseTable(table: string, columnList: unknown, policyInput: unknown, vo
|
|
|
551
556
|
columnWrites: freeze(columnWrites),
|
|
552
557
|
dates,
|
|
553
558
|
timestamps,
|
|
559
|
+
booleans,
|
|
554
560
|
foreignKeys,
|
|
555
561
|
}
|
|
556
562
|
}
|
|
@@ -673,7 +679,7 @@ function parseQueue(
|
|
|
673
679
|
return freeze({ attempts, due, maxAttempts: maxInput === undefined ? DEFAULT_MAX_ATTEMPTS : maxInput })
|
|
674
680
|
}
|
|
675
681
|
|
|
676
|
-
/** The column list's `date` (`dates`)
|
|
682
|
+
/** The column list's `date` (`dates`), timestamp (`timestamps`) or boolean (`booleans`) columns, each one of the table's. */
|
|
677
683
|
function parseColumnSet(table: string, input: unknown, column: (name: unknown, field: string) => string, field: string): NameSet {
|
|
678
684
|
if (input === undefined) {
|
|
679
685
|
return nameSet([])
|
package/src/postgres.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { SQL } from 'bun'
|
|
2
|
+
import { PostgresJSDialect } from 'kysely-postgres-js'
|
|
3
|
+
import { Database } from './database'
|
|
4
|
+
import { QueryRefusedError } from './errors'
|
|
5
|
+
import { isPlainIdentifier } from './identifier'
|
|
6
|
+
|
|
7
|
+
export interface PostgresOptions {
|
|
8
|
+
readonly url: string
|
|
9
|
+
/** The application's schema (`app_<id>`), set as the connection's `search_path`. */
|
|
10
|
+
readonly schema: string
|
|
11
|
+
readonly max?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Over `Bun.SQL` with `prepare: false`: Bun otherwise keeps one prepared statement per distinct SQL text per
|
|
16
|
+
* connection, and `in` lists of different lengths make that grow without bound.
|
|
17
|
+
*/
|
|
18
|
+
export function openPostgres(options: PostgresOptions): Database {
|
|
19
|
+
if (!isPlainIdentifier(options.schema)) {
|
|
20
|
+
throw new QueryRefusedError('invalid_name', 'the schema must match ^[a-z_][a-z0-9_]*$')
|
|
21
|
+
}
|
|
22
|
+
const pool = new SQL({ url: options.url, adapter: 'postgres', prepare: false, max: options.max ?? 10, connection: { search_path: options.schema } })
|
|
23
|
+
return Database.fromDialect(new PostgresJSDialect({ postgres: pool }), { schema: options.schema })
|
|
24
|
+
}
|
|
25
|
+
// Application modules load after this one: a replaced opener would receive the database URL.
|
|
26
|
+
Object.freeze(openPostgres)
|
package/src/registry.ts
CHANGED
|
@@ -26,9 +26,15 @@ export interface FrameworkTables {
|
|
|
26
26
|
readonly files: StoredFiles
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** A connection of the layer and its locator. Only its holder can `release` it, which lets the layer be bound again. */
|
|
30
|
+
export interface ProvisionalBinding {
|
|
31
|
+
readonly locate: Locator
|
|
32
|
+
release(): void
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
export interface FrameworkSide {
|
|
30
|
-
/** Connects the layer once and answers its
|
|
31
|
-
bind(runtime: DataLayerRuntime):
|
|
36
|
+
/** Connects the layer once and answers its binding; a call while one is connected throws. */
|
|
37
|
+
bind(runtime: DataLayerRuntime): ProvisionalBinding
|
|
32
38
|
readonly framework: FrameworkTables
|
|
33
39
|
/** The untyped handle over one table that `@fabrika/db/testing` hands the isolation tests. */
|
|
34
40
|
probe(table: string): TableProbe
|
package/src/sql.ts
CHANGED
|
@@ -373,17 +373,21 @@ function scopeFragment(terms: ScopeTerms, alias: string): { readonly parts: read
|
|
|
373
373
|
const parts: string[] = []
|
|
374
374
|
const values: SqlValue[] = []
|
|
375
375
|
let text = '('
|
|
376
|
-
terms.
|
|
376
|
+
// Index loops, never a method of the terms or of Array.prototype: this is the scope of the statement.
|
|
377
|
+
for (let groupIndex = 0; groupIndex < terms.length; groupIndex++) {
|
|
378
|
+
const group = terms[groupIndex] ?? []
|
|
377
379
|
text += `${groupIndex === 0 ? '' : ' or '}(`
|
|
378
|
-
group.
|
|
380
|
+
for (let testIndex = 0; testIndex < group.length; testIndex++) {
|
|
381
|
+
const test = group[testIndex]
|
|
382
|
+
if (test === undefined) continue
|
|
379
383
|
text += `${testIndex === 0 ? '' : ' and '}${alias}.${test.column} = ${typeof test.value === 'string' ? '' : 'any('}`
|
|
380
|
-
parts.
|
|
381
|
-
values.
|
|
384
|
+
parts[parts.length] = text
|
|
385
|
+
values[values.length] = test.value
|
|
382
386
|
text = typeof test.value === 'string' ? '' : ')'
|
|
383
|
-
}
|
|
387
|
+
}
|
|
384
388
|
text += ')'
|
|
385
|
-
}
|
|
386
|
-
parts.
|
|
389
|
+
}
|
|
390
|
+
parts[parts.length] = `${text})`
|
|
387
391
|
return { parts, values }
|
|
388
392
|
}
|
|
389
393
|
|
|
@@ -830,6 +834,10 @@ export function sqlTag(site: SqlSite, schema: string | undefined, termsOf: Scope
|
|
|
830
834
|
const statement = classifySql(expanded.parts, site.mode, schema ?? '')
|
|
831
835
|
const parameters = expanded.values.map((value, index) => parameterOf(value, index + 1))
|
|
832
836
|
const session = await site.session()
|
|
837
|
+
// ADR-0065: the classifier reads Postgres's grammar, so nothing of it is sent to SQLite.
|
|
838
|
+
if (session.dialect !== 'postgres') {
|
|
839
|
+
refuse('sql_dialect', 'raw SQL runs only on Postgres; an application on Cloudflare reads and writes through ctx.db')
|
|
840
|
+
}
|
|
833
841
|
if (session.readOnly && site.mode === 'write') {
|
|
834
842
|
refuse('read_only', 'this invocation is read-only')
|
|
835
843
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// A `bun:sqlite` database shaped like a Durable Object's storage, for the tests and the SQLite test kit. Not a subpath:
|
|
2
|
+
// a Worker never loads it.
|
|
3
|
+
|
|
4
|
+
import { Database, type SQLQueryBindings } from 'bun:sqlite'
|
|
5
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
6
|
+
import type { SqliteCursor, SqliteStorage, SqliteValue } from './sqlite'
|
|
7
|
+
|
|
8
|
+
export interface BunSqliteStorage extends SqliteStorage {
|
|
9
|
+
/** Runs `fn` in one transaction of its own, as a Durable Object's `storage.transactionSync` does. */
|
|
10
|
+
transactionSync<T>(fn: () => T): T
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** workerd's authorizer refuses these in `sql.exec`: a transaction runs through `transaction` or `transactionSync`. */
|
|
14
|
+
const TRANSACTION_CONTROL = /(?:^|;)(?:\s|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*(?:begin|commit|end|rollback|savepoint|release)\b/i
|
|
15
|
+
|
|
16
|
+
function bunBinding(value: SqliteValue): SQLQueryBindings {
|
|
17
|
+
return value instanceof ArrayBuffer ? new Uint8Array(value) : value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A column value as workerd answers it: a BLOB is an `ArrayBuffer`. */
|
|
21
|
+
function storageValue(value: unknown): SqliteValue {
|
|
22
|
+
if (value === null || typeof value === 'string' || typeof value === 'number') {
|
|
23
|
+
return value
|
|
24
|
+
}
|
|
25
|
+
if (value instanceof Uint8Array) {
|
|
26
|
+
return value.slice().buffer
|
|
27
|
+
}
|
|
28
|
+
throw new Error('bun:sqlite answered a value a Durable Object never does')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function storageRow(row: Record<string, unknown>): Record<string, SqliteValue> {
|
|
32
|
+
return Object.fromEntries(Object.entries(row).map(([name, value]) => [name, storageValue(value)]))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The transaction a `transaction` callback runs in: its savepoint depth, and the queue its own nested ones wait in. */
|
|
36
|
+
interface OpenTransaction {
|
|
37
|
+
readonly depth: number
|
|
38
|
+
queue: Promise<void>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `database` (a fresh `:memory:` one by default) with foreign keys enforced, as a Durable Object enforces them. One
|
|
43
|
+
* `transaction` runs at a time; one started inside a callback nests in a savepoint, as workerd nests it, and its
|
|
44
|
+
* siblings wait for each other. A statement outside one runs at once. A text of several statements runs whole when its
|
|
45
|
+
* first statement answers no rows, as a migration's does; otherwise only its first statement runs.
|
|
46
|
+
*/
|
|
47
|
+
export function bunSqliteStorage(database: Database = new Database(':memory:')): BunSqliteStorage {
|
|
48
|
+
database.run('PRAGMA foreign_keys = ON')
|
|
49
|
+
const totalChanges = (): number => database.query<{ readonly changes: number }, []>('select total_changes() as changes').get()?.changes ?? 0
|
|
50
|
+
const root: OpenTransaction = { depth: 0, queue: Promise.resolve() }
|
|
51
|
+
const open = new AsyncLocalStorage<OpenTransaction>()
|
|
52
|
+
|
|
53
|
+
function exec(query: string, ...bindings: SqliteValue[]): SqliteCursor {
|
|
54
|
+
if (TRANSACTION_CONTROL.test(query)) {
|
|
55
|
+
throw new Error('sql.exec runs no transaction statement: use storage.transaction() or storage.transactionSync()')
|
|
56
|
+
}
|
|
57
|
+
const values = bindings.map(bunBinding)
|
|
58
|
+
const before = totalChanges()
|
|
59
|
+
const statement = database.prepare<Record<string, unknown>, SQLQueryBindings[]>(query)
|
|
60
|
+
let rows: Record<string, SqliteValue>[] = []
|
|
61
|
+
try {
|
|
62
|
+
if (statement.columnNames.length > 0) {
|
|
63
|
+
rows = statement.all(...values).map(storageRow)
|
|
64
|
+
} else {
|
|
65
|
+
database.run(query, values)
|
|
66
|
+
}
|
|
67
|
+
} finally {
|
|
68
|
+
statement.finalize()
|
|
69
|
+
}
|
|
70
|
+
const rowsWritten = totalChanges() - before
|
|
71
|
+
return { toArray: () => rows, rowsWritten }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
sql: { exec },
|
|
76
|
+
async transaction<T>(closure: () => Promise<T>): Promise<T> {
|
|
77
|
+
const parent = open.getStore() ?? root
|
|
78
|
+
const previous = parent.queue
|
|
79
|
+
const done = Promise.withResolvers<void>()
|
|
80
|
+
parent.queue = previous.then(() => done.promise)
|
|
81
|
+
await previous
|
|
82
|
+
const depth = parent.depth + 1
|
|
83
|
+
const savepoint = `_test_savepoint_${depth}`
|
|
84
|
+
try {
|
|
85
|
+
database.run(depth === 1 ? 'BEGIN' : `SAVEPOINT ${savepoint}`)
|
|
86
|
+
try {
|
|
87
|
+
const result = await open.run({ depth, queue: Promise.resolve() }, closure)
|
|
88
|
+
database.run(depth === 1 ? 'COMMIT' : `RELEASE ${savepoint}`)
|
|
89
|
+
return result
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (depth === 1 && database.inTransaction) {
|
|
92
|
+
database.run('ROLLBACK')
|
|
93
|
+
} else if (depth > 1) {
|
|
94
|
+
database.run(`ROLLBACK TO ${savepoint}`)
|
|
95
|
+
database.run(`RELEASE ${savepoint}`)
|
|
96
|
+
}
|
|
97
|
+
throw error
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
done.resolve()
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
transactionSync<T>(fn: () => T): T {
|
|
104
|
+
// Inside an open transaction workerd nests it in a savepoint too.
|
|
105
|
+
const nested = database.inTransaction
|
|
106
|
+
database.run(nested ? 'SAVEPOINT _test_sync_savepoint' : 'BEGIN')
|
|
107
|
+
try {
|
|
108
|
+
const result = fn()
|
|
109
|
+
database.run(nested ? 'RELEASE _test_sync_savepoint' : 'COMMIT')
|
|
110
|
+
return result
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (nested) {
|
|
113
|
+
database.run('ROLLBACK TO _test_sync_savepoint')
|
|
114
|
+
database.run('RELEASE _test_sync_savepoint')
|
|
115
|
+
} else if (database.inTransaction) {
|
|
116
|
+
database.run('ROLLBACK')
|
|
117
|
+
}
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// @fabrika/db/sqlite-migrate — applies the framework's and the application's migrations to an application's SQLite
|
|
2
|
+
// database when its Durable Object boots (ADR-0065), in a ledger in the same database. The identity and the
|
|
3
|
+
// checksum rule are the Postgres runner's (ADR-0017): `(bundle, name)`, and the SHA-256 of an applied file's text.
|
|
4
|
+
// Every new object instance runs it, so a bundle whose files match the digest recorded for it is not read further.
|
|
5
|
+
// Runs inside a Worker: no Bun import.
|
|
6
|
+
|
|
7
|
+
/** What the runner needs of the database; a Durable Object's `storage.sql.exec` and `storage.transactionSync` fit it. */
|
|
8
|
+
export interface SqliteMigrationTarget {
|
|
9
|
+
/** Runs `query`, which may hold several statements, and answers the rows of the last one. */
|
|
10
|
+
exec(query: string, ...bindings: (string | number | null)[]): { toArray(): Record<string, unknown>[] }
|
|
11
|
+
/** Runs `fn` in one transaction: commits when it returns, rolls back when it throws. */
|
|
12
|
+
transactionSync<T>(fn: () => T): T
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SqliteMigrationBundle {
|
|
16
|
+
readonly bundle: string
|
|
17
|
+
readonly migrations: readonly { readonly name: string; readonly sql: string }[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class SqliteMigrationError extends Error {
|
|
21
|
+
override readonly name = 'SqliteMigrationError'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const LEDGER = 'fabrika_migrations'
|
|
25
|
+
const DIGESTS = 'fabrika_migration_bundles'
|
|
26
|
+
const BUNDLE_NAME = /^[a-z][a-z0-9-]*$/
|
|
27
|
+
const MIGRATION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.sql$/
|
|
28
|
+
|
|
29
|
+
const LEDGER_DDL = `CREATE TABLE IF NOT EXISTS ${LEDGER} (
|
|
30
|
+
bundle TEXT NOT NULL,
|
|
31
|
+
name TEXT NOT NULL,
|
|
32
|
+
checksum TEXT NOT NULL,
|
|
33
|
+
applied_at TEXT NOT NULL,
|
|
34
|
+
PRIMARY KEY (bundle, name)
|
|
35
|
+
) STRICT`
|
|
36
|
+
|
|
37
|
+
/** A bundle's digest: the SHA-256 of its ordered file names and checksums, written with the last of its files applied. */
|
|
38
|
+
const DIGESTS_DDL = `CREATE TABLE IF NOT EXISTS ${DIGESTS} (
|
|
39
|
+
bundle TEXT PRIMARY KEY,
|
|
40
|
+
digest TEXT NOT NULL
|
|
41
|
+
) STRICT`
|
|
42
|
+
|
|
43
|
+
interface HashedMigration {
|
|
44
|
+
readonly name: string
|
|
45
|
+
readonly sql: string
|
|
46
|
+
readonly checksum: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface PlannedMigration extends HashedMigration {
|
|
50
|
+
readonly bundle: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface PlannedBundle {
|
|
54
|
+
readonly bundle: string
|
|
55
|
+
readonly digest: string
|
|
56
|
+
readonly migrations: readonly PlannedMigration[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface HashedFiles {
|
|
60
|
+
readonly digest: string
|
|
61
|
+
readonly migrations: readonly HashedMigration[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The files an array held when it was hashed, and their hashes: reused while the array still holds the same files. */
|
|
65
|
+
interface HashedEntry {
|
|
66
|
+
readonly snapshot: readonly { readonly name: string; readonly sql: string }[]
|
|
67
|
+
readonly files: Promise<HashedFiles>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The migration arrays are module values, hashed once per isolate; an array mutated since is hashed again. */
|
|
71
|
+
const hashedFiles = new WeakMap<SqliteMigrationBundle['migrations'], HashedEntry>()
|
|
72
|
+
|
|
73
|
+
/** The SHA-256 hex of the file's UTF-8 text: the Postgres runner's `migrationChecksum`, so both ledgers agree. */
|
|
74
|
+
async function checksumOf(sql: string): Promise<string> {
|
|
75
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(sql))
|
|
76
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function compareNames(a: string, b: string): number {
|
|
80
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Refuses a malformed bundle before anything is hashed or run. */
|
|
84
|
+
function check(bundles: readonly SqliteMigrationBundle[]): void {
|
|
85
|
+
const bundleNames = new Set<string>()
|
|
86
|
+
for (const bundle of bundles) {
|
|
87
|
+
if (!BUNDLE_NAME.test(bundle.bundle)) throw new SqliteMigrationError(`Invalid SQLite migration bundle name: ${bundle.bundle}`)
|
|
88
|
+
if (bundleNames.has(bundle.bundle)) throw new SqliteMigrationError(`Duplicate SQLite migration bundle ${bundle.bundle}`)
|
|
89
|
+
bundleNames.add(bundle.bundle)
|
|
90
|
+
const names = new Set<string>()
|
|
91
|
+
for (const migration of bundle.migrations) {
|
|
92
|
+
const identity = `${bundle.bundle}/${migration.name}`
|
|
93
|
+
if (!MIGRATION_NAME.test(migration.name)) throw new SqliteMigrationError(`Invalid SQLite migration filename: ${identity}`)
|
|
94
|
+
if (names.has(migration.name)) throw new SqliteMigrationError(`Duplicate SQLite migration ${identity}`)
|
|
95
|
+
if (migration.sql.trim() === '') throw new SqliteMigrationError(`SQLite migration ${identity} is empty`)
|
|
96
|
+
names.add(migration.name)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A bundle's files in name order with their checksums, and its digest: the SHA-256 of the ordered names and checksums. */
|
|
102
|
+
async function hash(files: SqliteMigrationBundle['migrations']): Promise<HashedFiles> {
|
|
103
|
+
const migrations: HashedMigration[] = []
|
|
104
|
+
for (const { name, sql } of [...files].sort((a, b) => compareNames(a.name, b.name))) {
|
|
105
|
+
migrations.push({ name, sql, checksum: await checksumOf(sql) })
|
|
106
|
+
}
|
|
107
|
+
// A filename holds no space or newline, so each line is unambiguous.
|
|
108
|
+
const digest = await checksumOf(migrations.map((migration) => `${migration.name} ${migration.checksum}\n`).join(''))
|
|
109
|
+
return { digest, migrations }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function holdsSnapshot(files: SqliteMigrationBundle['migrations'], snapshot: HashedEntry['snapshot']): boolean {
|
|
113
|
+
return files.length === snapshot.length && files.every((file, index) => file.name === snapshot[index]?.name && file.sql === snapshot[index]?.sql)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Each bundle's migrations in name order, the bundles in the order given; refuses a malformed bundle before any SQL runs. */
|
|
117
|
+
async function plan(bundles: readonly SqliteMigrationBundle[]): Promise<readonly PlannedBundle[]> {
|
|
118
|
+
check(bundles)
|
|
119
|
+
const planned: PlannedBundle[] = []
|
|
120
|
+
for (const bundle of bundles) {
|
|
121
|
+
let entry = hashedFiles.get(bundle.migrations)
|
|
122
|
+
if (entry === undefined || !holdsSnapshot(bundle.migrations, entry.snapshot)) {
|
|
123
|
+
entry = { snapshot: bundle.migrations.map(({ name, sql }) => ({ name, sql })), files: hash(bundle.migrations) }
|
|
124
|
+
hashedFiles.set(bundle.migrations, entry)
|
|
125
|
+
}
|
|
126
|
+
const { digest, migrations } = await entry.files
|
|
127
|
+
planned.push({ bundle: bundle.bundle, digest, migrations: migrations.map((migration) => ({ ...migration, bundle: bundle.bundle })) })
|
|
128
|
+
}
|
|
129
|
+
return planned
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The digest recorded for each bundle that has one; empty while the table does not exist, as before its first run. */
|
|
133
|
+
function readDigests(target: SqliteMigrationTarget, planned: readonly PlannedBundle[]): Map<string, string> {
|
|
134
|
+
const recorded = new Map<string, string>()
|
|
135
|
+
if (target.exec(`SELECT 1 AS present FROM sqlite_schema WHERE type = 'table' AND name = ?`, DIGESTS).toArray().length === 0) return recorded
|
|
136
|
+
for (const { bundle } of planned) {
|
|
137
|
+
const [row] = target.exec(`SELECT digest FROM ${DIGESTS} WHERE bundle = ?`, bundle).toArray()
|
|
138
|
+
if (row === undefined) continue
|
|
139
|
+
const { digest } = row
|
|
140
|
+
if (typeof digest !== 'string') throw new SqliteMigrationError(`SQLite migration table ${DIGESTS} holds a malformed row`)
|
|
141
|
+
recorded.set(bundle, digest)
|
|
142
|
+
}
|
|
143
|
+
return recorded
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function recordDigest(target: SqliteMigrationTarget, bundle: PlannedBundle): void {
|
|
147
|
+
target.exec(DIGESTS_DDL)
|
|
148
|
+
target.exec(
|
|
149
|
+
`INSERT INTO ${DIGESTS} (bundle, digest) VALUES (?, ?) ON CONFLICT (bundle) DO UPDATE SET digest = excluded.digest`,
|
|
150
|
+
bundle.bundle,
|
|
151
|
+
bundle.digest,
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Recorded checksums by `bundle/name`; empty while the ledger does not exist, so a fresh database is checked first. */
|
|
156
|
+
function readLedger(target: SqliteMigrationTarget): Map<string, string> {
|
|
157
|
+
const present = target.exec(`SELECT 1 AS present FROM sqlite_schema WHERE type = 'table' AND name = ?`, LEDGER).toArray()
|
|
158
|
+
const applied = new Map<string, string>()
|
|
159
|
+
if (present.length === 0) return applied
|
|
160
|
+
for (const row of target.exec(`SELECT bundle, name, checksum FROM ${LEDGER}`).toArray()) {
|
|
161
|
+
const { bundle, name, checksum } = row
|
|
162
|
+
if (typeof bundle !== 'string' || typeof name !== 'string' || typeof checksum !== 'string') {
|
|
163
|
+
throw new SqliteMigrationError(`SQLite migration ledger ${LEDGER} holds a malformed row`)
|
|
164
|
+
}
|
|
165
|
+
applied.set(`${bundle}/${name}`, checksum)
|
|
166
|
+
}
|
|
167
|
+
return applied
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Refuses, before anything is applied: an applied file whose text changed, and a new file that sorts before the last
|
|
172
|
+
* applied one of its bundle, which would run after the files it precedes. A ledger row without a file is the database
|
|
173
|
+
* ahead of the code after a rollback, which is tolerated, as on Postgres.
|
|
174
|
+
*/
|
|
175
|
+
function verify(planned: readonly PlannedBundle[], applied: ReadonlyMap<string, string>): void {
|
|
176
|
+
for (const { migrations } of planned) {
|
|
177
|
+
let lastApplied: string | undefined
|
|
178
|
+
for (const migration of migrations) {
|
|
179
|
+
const identity = `${migration.bundle}/${migration.name}`
|
|
180
|
+
const recorded = applied.get(identity)
|
|
181
|
+
if (recorded === undefined) continue
|
|
182
|
+
if (recorded !== migration.checksum) {
|
|
183
|
+
throw new SqliteMigrationError(
|
|
184
|
+
`SQLite migration ${identity} changed after it was applied; an applied migration is immutable, so write the change as a new migration`,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
lastApplied = migration.name
|
|
188
|
+
}
|
|
189
|
+
for (const migration of migrations) {
|
|
190
|
+
if (lastApplied === undefined || compareNames(migration.name, lastApplied) >= 0) break
|
|
191
|
+
if (!applied.has(`${migration.bundle}/${migration.name}`)) {
|
|
192
|
+
throw new SqliteMigrationError(
|
|
193
|
+
`SQLite migration ${migration.bundle}/${migration.name} sorts before ${migration.bundle}/${lastApplied}, which is already applied; rename it to sort after it`,
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Applies every migration the ledger does not record, each in its own transaction together with its ledger row, so a
|
|
202
|
+
* failing one leaves nothing of itself and keeps the ones before it. A bundle's digest is recorded in the transaction
|
|
203
|
+
* of its last file, or alone when nothing of it was pending; while every bundle matches its digest, the ledger is not
|
|
204
|
+
* read. Answers the identities it applied.
|
|
205
|
+
*/
|
|
206
|
+
export async function migrateSqlite(
|
|
207
|
+
target: SqliteMigrationTarget,
|
|
208
|
+
bundles: readonly SqliteMigrationBundle[],
|
|
209
|
+
): Promise<{ readonly applied: readonly string[] }> {
|
|
210
|
+
const planned = await plan(bundles)
|
|
211
|
+
const digests = readDigests(target, planned)
|
|
212
|
+
if (planned.every((bundle) => digests.get(bundle.bundle) === bundle.digest)) return { applied: [] }
|
|
213
|
+
const recorded = readLedger(target)
|
|
214
|
+
verify(planned, recorded)
|
|
215
|
+
target.exec(LEDGER_DDL)
|
|
216
|
+
const applied: string[] = []
|
|
217
|
+
for (const bundle of planned) {
|
|
218
|
+
if (digests.get(bundle.bundle) === bundle.digest) continue
|
|
219
|
+
const pending = bundle.migrations.filter((migration) => !recorded.has(`${migration.bundle}/${migration.name}`))
|
|
220
|
+
if (pending.length === 0) target.transactionSync(() => recordDigest(target, bundle))
|
|
221
|
+
for (const [index, migration] of pending.entries()) {
|
|
222
|
+
const identity = `${migration.bundle}/${migration.name}`
|
|
223
|
+
try {
|
|
224
|
+
target.transactionSync(() => {
|
|
225
|
+
target.exec(migration.sql)
|
|
226
|
+
target.exec(
|
|
227
|
+
`INSERT INTO ${LEDGER} (bundle, name, checksum, applied_at) VALUES (?, ?, ?, ?)`,
|
|
228
|
+
migration.bundle,
|
|
229
|
+
migration.name,
|
|
230
|
+
migration.checksum,
|
|
231
|
+
new Date().toISOString(),
|
|
232
|
+
)
|
|
233
|
+
if (index === pending.length - 1) recordDigest(target, bundle)
|
|
234
|
+
})
|
|
235
|
+
} catch (error) {
|
|
236
|
+
throw new SqliteMigrationError(`SQLite migration ${identity} failed`, { cause: error })
|
|
237
|
+
}
|
|
238
|
+
applied.push(identity)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { applied }
|
|
242
|
+
}
|