@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/store.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { uuidv7 } from '@fabrika/platform'
|
|
1
2
|
import { BinaryOperationNode, type OperationNode, OperatorNode, type ReturningNode, ValueNode } from 'kysely'
|
|
2
3
|
import { admissions } from './admissions'
|
|
3
4
|
import {
|
|
@@ -31,8 +32,11 @@ import {
|
|
|
31
32
|
type Within,
|
|
32
33
|
type WithinGrant,
|
|
33
34
|
} from './capability'
|
|
34
|
-
import { type Database, InvocationTransaction, type Session, sessionOf } from './database'
|
|
35
|
+
import { type Database, InvocationTransaction, type Session, sessionOf, type SqlDialect } from './database'
|
|
35
36
|
import {
|
|
37
|
+
type Compare,
|
|
38
|
+
type Condition,
|
|
39
|
+
type Contains,
|
|
36
40
|
finishSelect,
|
|
37
41
|
isPrimitive,
|
|
38
42
|
type JoinShape,
|
|
@@ -49,7 +53,7 @@ import { fileReference } from './file-references'
|
|
|
49
53
|
import { webhookTables } from './framework-tables'
|
|
50
54
|
import type { ForeignKeyPair, GuardContext, RootKind } from './guard'
|
|
51
55
|
import { fencedTransaction, jobLeases } from './job-leases'
|
|
52
|
-
import { linksPredicate, linkValues } from './links'
|
|
56
|
+
import { linksPredicate, type LinkValue, linkValues } from './links'
|
|
53
57
|
import {
|
|
54
58
|
acknowledgement,
|
|
55
59
|
acknowledgementNode,
|
|
@@ -99,11 +103,11 @@ import {
|
|
|
99
103
|
type VersionArguments,
|
|
100
104
|
visibleFor,
|
|
101
105
|
} from './policy'
|
|
102
|
-
import { frameworkSideOf, type
|
|
106
|
+
import { frameworkSideOf, type ProvisionalBinding, register } from './registry'
|
|
103
107
|
import type { ScopeTerms, ScopeTest, ScopeUse } from './sql'
|
|
104
108
|
import { storedFiles, storedFilesMetadata } from './stored-files'
|
|
105
109
|
import { type TablePolicies, tablePolicies } from './tables'
|
|
106
|
-
import {
|
|
110
|
+
import { answerRows, dayInput, type RowColumns, storedTimestamp, timestampInput } from './temporal'
|
|
107
111
|
|
|
108
112
|
export interface DataLayerRuntime {
|
|
109
113
|
readonly database: Database
|
|
@@ -124,9 +128,86 @@ export interface TableContext {
|
|
|
124
128
|
readonly destination: TableModel | undefined
|
|
125
129
|
readonly shape: TableShape
|
|
126
130
|
readonly joins: Readonly<Record<string, readonly ForeignKeyPair[]>>
|
|
131
|
+
/** The model of each child a select may join, by its table name. */
|
|
132
|
+
readonly children: Readonly<Record<string, TableModel>>
|
|
127
133
|
readonly connection: Connection
|
|
128
134
|
}
|
|
129
135
|
|
|
136
|
+
/** The dialect of the database the layer is bound to: what a read answers and a write binds depends on it. */
|
|
137
|
+
export function dialectOf(context: TableContext): SqlDialect {
|
|
138
|
+
return runtimeOf(context.connection).database.dialect
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The columns of `model`'s own rows that a read answers in a form of its own (`answerRows`). */
|
|
142
|
+
export function rowColumnsOf(model: TableModel): RowColumns {
|
|
143
|
+
return { days: model.dates, timestamps: model.timestamps, booleans: model.booleans }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A timestamp's bound value on SQLite: its stored UTC text. Postgres takes a `Date` or ISO text as it is. */
|
|
147
|
+
function boundTimestamp(model: TableModel, column: string, value: Primitive): Primitive {
|
|
148
|
+
if (typeof value !== 'string' && !(value instanceof Date)) {
|
|
149
|
+
throw new QueryRefusedError('invalid_value', `${model.name}.${column} takes a Date or ISO 8601 text with Z or an offset`)
|
|
150
|
+
}
|
|
151
|
+
return storedTimestamp(value, `${model.name}.${column}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The values a write binds: on SQLite each timestamp as its stored UTC text, so text order is time order. */
|
|
155
|
+
export function boundValues(context: TableContext, values: ReadonlyMap<string, Primitive | null>): ReadonlyMap<string, Primitive | null> {
|
|
156
|
+
const model = context.model
|
|
157
|
+
if (dialectOf(context) === 'postgres' || ownKeys(model.timestamps).length === 0) {
|
|
158
|
+
return values
|
|
159
|
+
}
|
|
160
|
+
const bound = new Map<string, Primitive | null>()
|
|
161
|
+
for (const [name, value] of values) {
|
|
162
|
+
bound.set(name, value === null || !inSet(model.timestamps, name) ? value : boundTimestamp(model, name, value))
|
|
163
|
+
}
|
|
164
|
+
return bound
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A term's values as they are bound: on SQLite, one over a timestamp of `model` compares stored UTC text. */
|
|
168
|
+
function boundTerm(model: TableModel, term: Compare | Contains): Compare | Contains {
|
|
169
|
+
const value = term.kind === 'compare' ? term.value : null
|
|
170
|
+
if (term.kind !== 'compare' || value === null || !inSet(model.timestamps, term.column)) {
|
|
171
|
+
return term
|
|
172
|
+
}
|
|
173
|
+
const bind = (item: Primitive): Primitive => boundTimestamp(model, term.column, item)
|
|
174
|
+
return { ...term, value: isList(value) ? value.map(bind) : bind(value) }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isList(value: Primitive | readonly Primitive[]): value is readonly Primitive[] {
|
|
178
|
+
return isArray(value)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A list's terms over `model`'s columns, as `boundTerm` binds each. */
|
|
182
|
+
export function boundTerms(context: TableContext, model: TableModel, terms: readonly (Compare | Contains)[]): readonly (Compare | Contains)[] {
|
|
183
|
+
if (dialectOf(context) === 'postgres' || ownKeys(model.timestamps).length === 0) {
|
|
184
|
+
return terms
|
|
185
|
+
}
|
|
186
|
+
return terms.map((term) => boundTerm(model, term))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A description's conditions over `model`'s columns, an OR group's terms included, as `boundTerm` binds each. */
|
|
190
|
+
function boundConditions(context: TableContext, model: TableModel, conditions: readonly Condition[]): readonly Condition[] {
|
|
191
|
+
if (dialectOf(context) === 'postgres' || ownKeys(model.timestamps).length === 0) {
|
|
192
|
+
return conditions
|
|
193
|
+
}
|
|
194
|
+
return conditions.map((condition) =>
|
|
195
|
+
condition.kind === 'any' ? { ...condition, conditions: condition.conditions.map((term) => boundTerm(model, term)) } : boundTerm(model, condition)
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A select's conditions, its joined children's included, as they are bound. */
|
|
200
|
+
function boundState(context: TableContext, state: SelectState): SelectState {
|
|
201
|
+
if (dialectOf(context) === 'postgres') {
|
|
202
|
+
return state
|
|
203
|
+
}
|
|
204
|
+
const joins = state.joins.map((join) => {
|
|
205
|
+
const child = ownEntry(context.children, join.join.shape.table)
|
|
206
|
+
return child === undefined ? join : { ...join, select: { ...join.select, conditions: boundConditions(context, child, join.select.conditions) } }
|
|
207
|
+
})
|
|
208
|
+
return { ...state, conditions: boundConditions(context, context.model, state.conditions), joins }
|
|
209
|
+
}
|
|
210
|
+
|
|
130
211
|
export function runtimeOf(connection: Connection): DataLayerRuntime {
|
|
131
212
|
const runtime = connection.runtime
|
|
132
213
|
if (runtime === undefined) {
|
|
@@ -227,6 +308,76 @@ function linksAbove(model: TableModel, dimension: string): readonly Container[]
|
|
|
227
308
|
return []
|
|
228
309
|
}
|
|
229
310
|
|
|
311
|
+
/**
|
|
312
|
+
* The links of a row that lie inside the `Within`'s own dimension: the object itself for a dimension table, and the
|
|
313
|
+
* chain links nearer than `dimension`. A grant on one of them admits the row as a single-object read admits it.
|
|
314
|
+
*/
|
|
315
|
+
function linksBelow(model: TableModel, dimension: string): readonly Container[] {
|
|
316
|
+
const links = model.scope.mode === 'dimension' ? selfLinks(model) : model.chain
|
|
317
|
+
const below: Container[] = []
|
|
318
|
+
for (let index = 0; index < links.length; index++) {
|
|
319
|
+
const link = links[index]
|
|
320
|
+
if (link === undefined || link.dimension === dimension) {
|
|
321
|
+
return below
|
|
322
|
+
}
|
|
323
|
+
below[below.length] = link
|
|
324
|
+
}
|
|
325
|
+
return []
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* One `<column> in (values)` per contained grant on a link of the row inside the `Within`'s dimension. A grant on a
|
|
330
|
+
* dimension the table carries no link of admits none of its rows; one on the `Within`'s dimension or above it is refused.
|
|
331
|
+
*/
|
|
332
|
+
function containedTests(model: TableModel, grant: WithinGrant): { readonly column: string; readonly values: readonly string[] }[] {
|
|
333
|
+
const below = linksBelow(model, grant.dimension)
|
|
334
|
+
const above = linksAbove(model, grant.dimension)
|
|
335
|
+
const tests: { readonly column: string; readonly values: readonly string[] }[] = []
|
|
336
|
+
for (let index = 0; index < grant.contained.length; index++) {
|
|
337
|
+
const contained = grant.contained[index]
|
|
338
|
+
if (contained === undefined) {
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
if (contained.dimension === grant.dimension || linkOf(above, contained.dimension) !== undefined) {
|
|
342
|
+
throw new CapabilityError(
|
|
343
|
+
'unenforceable',
|
|
344
|
+
`the Within of ${grant.dimension} carries a grant on ${contained.dimension}, which does not lie inside ${grant.dimension} in ${model.name}`,
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
const link = linkOf(below, contained.dimension)
|
|
348
|
+
if (link !== undefined && contained.values.length > 0) {
|
|
349
|
+
tests[tests.length] = { column: link.column, values: contained.values }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return tests
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The link of `dimension` among `links`, by an index loop: no array method an application could replace. */
|
|
356
|
+
function linkOf(links: readonly Container[], dimension: string): Container | undefined {
|
|
357
|
+
for (let index = 0; index < links.length; index++) {
|
|
358
|
+
const link = links[index]
|
|
359
|
+
if (link !== undefined && link.dimension === dimension) {
|
|
360
|
+
return link
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return undefined
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Every scope value a `Within` carries: its own, its containers' and its contained dimensions'. */
|
|
367
|
+
function withinCount(grant: WithinGrant): number {
|
|
368
|
+
if (grant.values === 'all') {
|
|
369
|
+
return 0
|
|
370
|
+
}
|
|
371
|
+
let count = grant.values.length
|
|
372
|
+
for (let index = 0; index < grant.containers.length; index++) {
|
|
373
|
+
count += grant.containers[index]?.values.length ?? 0
|
|
374
|
+
}
|
|
375
|
+
for (let index = 0; index < grant.contained.length; index++) {
|
|
376
|
+
count += grant.contained[index]?.values.length ?? 0
|
|
377
|
+
}
|
|
378
|
+
return count
|
|
379
|
+
}
|
|
380
|
+
|
|
230
381
|
/**
|
|
231
382
|
* `column in (values) or <container column> in (container values) …`, one disjunct per container dimension, so
|
|
232
383
|
* the statement itself judges each row by the chain it carries now. Undefined for an empty `Within`: the caller
|
|
@@ -238,7 +389,7 @@ function withinPredicate(model: TableModel, column: string, grant: WithinGrant):
|
|
|
238
389
|
}
|
|
239
390
|
const above = linksAbove(model, grant.dimension)
|
|
240
391
|
const predicates: OperationNode[] = []
|
|
241
|
-
|
|
392
|
+
const count = withinCount(grant)
|
|
242
393
|
if (grant.values.length > 0) {
|
|
243
394
|
predicates[predicates.length] = inList(model.name, column, grant.values)
|
|
244
395
|
}
|
|
@@ -247,23 +398,24 @@ function withinPredicate(model: TableModel, column: string, grant: WithinGrant):
|
|
|
247
398
|
if (container === undefined) {
|
|
248
399
|
continue
|
|
249
400
|
}
|
|
250
|
-
|
|
251
|
-
for (let position = 0; position < above.length; position++) {
|
|
252
|
-
if (above[position]?.dimension === container.dimension) {
|
|
253
|
-
link = above[position]
|
|
254
|
-
}
|
|
255
|
-
}
|
|
401
|
+
const link = linkOf(above, container.dimension)
|
|
256
402
|
if (link === undefined) {
|
|
257
403
|
throw new CapabilityError(
|
|
258
404
|
'unenforceable',
|
|
259
405
|
`the Within of ${grant.dimension} reaches through ${container.dimension}, which ${model.name} carries no column for above ${grant.dimension}; the layer cannot enforce it`,
|
|
260
406
|
)
|
|
261
407
|
}
|
|
262
|
-
count += container.values.length
|
|
263
408
|
if (container.values.length > 0) {
|
|
264
409
|
predicates[predicates.length] = inList(model.name, link.column, container.values)
|
|
265
410
|
}
|
|
266
411
|
}
|
|
412
|
+
const contained = containedTests(model, grant)
|
|
413
|
+
for (let index = 0; index < contained.length; index++) {
|
|
414
|
+
const test = contained[index]
|
|
415
|
+
if (test !== undefined) {
|
|
416
|
+
predicates[predicates.length] = inList(model.name, test.column, test.values)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
267
419
|
if (count > MAX_WITHIN_VALUES) {
|
|
268
420
|
throw new CapabilityError('within_size', `a Within carries at most ${MAX_WITHIN_VALUES} scope values; this one carries ${count}`)
|
|
269
421
|
}
|
|
@@ -273,7 +425,14 @@ function withinPredicate(model: TableModel, column: string, grant: WithinGrant):
|
|
|
273
425
|
/** The object a Ref of the model's own dimension names, with every link of its chain. */
|
|
274
426
|
export function selfLinks(model: TableModel): readonly Container[] {
|
|
275
427
|
const scope = dimensionScope(model)
|
|
276
|
-
|
|
428
|
+
const links: Container[] = [{ dimension: scope.dimension, column: scope.key }]
|
|
429
|
+
for (let index = 0; index < model.chain.length; index++) {
|
|
430
|
+
const link = model.chain[index]
|
|
431
|
+
if (link !== undefined) {
|
|
432
|
+
links[links.length] = link
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return links
|
|
277
436
|
}
|
|
278
437
|
|
|
279
438
|
/** This object, over the whole chain the Ref carries. */
|
|
@@ -443,7 +602,22 @@ export function scopeTerms(context: TableContext, capability: unknown, use: Scop
|
|
|
443
602
|
if (use === 'read' && !namesAction(model, grant.action)) {
|
|
444
603
|
throw new CapabilityError('wrong_action', `${model.name} does not name the action ${grant.action}: a Ref reads it with an action it names`)
|
|
445
604
|
}
|
|
446
|
-
|
|
605
|
+
const reached: Container[] = []
|
|
606
|
+
for (let index = start; index < links.length; index++) {
|
|
607
|
+
const link = links[index]
|
|
608
|
+
if (link !== undefined) {
|
|
609
|
+
reached[reached.length] = link
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const values = linkValues(model.name, reached, grant)
|
|
613
|
+
const tests: ScopeTest[] = []
|
|
614
|
+
for (let index = 0; index < values.length; index++) {
|
|
615
|
+
const entry = values[index]
|
|
616
|
+
if (entry !== undefined) {
|
|
617
|
+
tests[tests.length] = { column: entry.link.column, value: entry.value }
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return [tests]
|
|
447
621
|
}
|
|
448
622
|
const column = reachColumn(model, grant.dimension)
|
|
449
623
|
if (column === undefined) {
|
|
@@ -463,21 +637,31 @@ export function scopeTerms(context: TableContext, capability: unknown, use: Scop
|
|
|
463
637
|
}
|
|
464
638
|
const above = linksAbove(model, grant.dimension)
|
|
465
639
|
const groups: ScopeTest[][] = []
|
|
466
|
-
|
|
640
|
+
const count = withinCount(grant)
|
|
467
641
|
if (grant.values.length > 0) {
|
|
468
|
-
groups.
|
|
642
|
+
groups[groups.length] = [{ column, value: grant.values }]
|
|
469
643
|
}
|
|
470
|
-
for (
|
|
471
|
-
const
|
|
644
|
+
for (let index = 0; index < grant.containers.length; index++) {
|
|
645
|
+
const container = grant.containers[index]
|
|
646
|
+
if (container === undefined) {
|
|
647
|
+
continue
|
|
648
|
+
}
|
|
649
|
+
const link = linkOf(above, container.dimension)
|
|
472
650
|
if (link === undefined) {
|
|
473
651
|
throw new CapabilityError(
|
|
474
652
|
'unenforceable',
|
|
475
653
|
`the Within of ${grant.dimension} reaches through ${container.dimension}, which ${model.name} carries no column for above ${grant.dimension}; the layer cannot enforce it`,
|
|
476
654
|
)
|
|
477
655
|
}
|
|
478
|
-
count += container.values.length
|
|
479
656
|
if (container.values.length > 0) {
|
|
480
|
-
groups.
|
|
657
|
+
groups[groups.length] = [{ column: link.column, value: container.values }]
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const contained = containedTests(model, grant)
|
|
661
|
+
for (let index = 0; index < contained.length; index++) {
|
|
662
|
+
const test = contained[index]
|
|
663
|
+
if (test !== undefined) {
|
|
664
|
+
groups[groups.length] = [{ column: test.column, value: test.values }]
|
|
481
665
|
}
|
|
482
666
|
}
|
|
483
667
|
if (count > MAX_WITHIN_VALUES) {
|
|
@@ -895,29 +1079,39 @@ async function selectRows<S>(
|
|
|
895
1079
|
if (scope === undefined) {
|
|
896
1080
|
return []
|
|
897
1081
|
}
|
|
898
|
-
const
|
|
1082
|
+
const dialect = dialectOf(context)
|
|
1083
|
+
const node = selectNode(boundState(context, state), visible, scope, undefined, context.model.dates, dialect)
|
|
899
1084
|
const rows = await (await run.read()).run<S>(node, guard(context, 'SelectQueryNode', scope))
|
|
900
|
-
return
|
|
1085
|
+
return answerRows(dialect, context.model.name, joinedColumns(context, state), rows)
|
|
901
1086
|
}
|
|
902
1087
|
|
|
903
|
-
/** The
|
|
904
|
-
function
|
|
1088
|
+
/** The column sets of a select's rows: the table's own, and each joined child's under its `<child>.<column>` alias. */
|
|
1089
|
+
function joinedColumns(context: TableContext, state: SelectState): RowColumns {
|
|
1090
|
+
const own = rowColumnsOf(context.model)
|
|
905
1091
|
if (state.joins.length === 0) {
|
|
906
1092
|
return own
|
|
907
1093
|
}
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1094
|
+
const joined = (pick: (columns: RowColumns) => NameSet): NameSet => {
|
|
1095
|
+
const names: string[] = []
|
|
1096
|
+
const owned = namesOf(pick(own))
|
|
1097
|
+
for (let index = 0; index < owned.length; index++) {
|
|
1098
|
+
names[names.length] = owned[index] ?? ''
|
|
1099
|
+
}
|
|
1100
|
+
for (let index = 0; index < state.joins.length; index++) {
|
|
1101
|
+
const table = state.joins[index]?.join.shape.table ?? ''
|
|
1102
|
+
const child = ownEntry(context.children, table)
|
|
1103
|
+
const columns = child === undefined ? [] : namesOf(pick(rowColumnsOf(child)))
|
|
1104
|
+
for (let position = 0; position < columns.length; position++) {
|
|
1105
|
+
names[names.length] = `${table}.${columns[position]}`
|
|
1106
|
+
}
|
|
918
1107
|
}
|
|
1108
|
+
return nameSet(names)
|
|
1109
|
+
}
|
|
1110
|
+
return {
|
|
1111
|
+
days: joined((columns) => columns.days),
|
|
1112
|
+
timestamps: joined((columns) => columns.timestamps),
|
|
1113
|
+
booleans: joined((columns) => columns.booleans),
|
|
919
1114
|
}
|
|
920
|
-
return nameSet(names)
|
|
921
1115
|
}
|
|
922
1116
|
|
|
923
1117
|
/**
|
|
@@ -933,10 +1127,11 @@ export async function selectOne<O>(
|
|
|
933
1127
|
): Promise<O> {
|
|
934
1128
|
const start = startSelect(context.shape)
|
|
935
1129
|
const state = finishSelect(start, start)
|
|
936
|
-
const
|
|
1130
|
+
const dialect = dialectOf(context)
|
|
1131
|
+
const node = selectNode(state, visible, scope, undefined, context.model.dates, dialect)
|
|
937
1132
|
const session = await run.read()
|
|
938
1133
|
const rows = await session.run<O>(node, guard(context, 'SelectQueryNode', scope))
|
|
939
|
-
const [row] =
|
|
1134
|
+
const [row] = answerRows(dialect, context.model.name, rowColumnsOf(context.model), rows)
|
|
940
1135
|
if (row === undefined) {
|
|
941
1136
|
throw new NotFoundError(`${context.model.name} not found`)
|
|
942
1137
|
}
|
|
@@ -1027,9 +1222,10 @@ async function lockDestination(transaction: Session, context: TableContext, gran
|
|
|
1027
1222
|
export async function insertRow<O>(
|
|
1028
1223
|
context: TableContext,
|
|
1029
1224
|
run: Runner,
|
|
1030
|
-
|
|
1225
|
+
given: ReadonlyMap<string, Primitive | null>,
|
|
1031
1226
|
destination: RefGrant | undefined,
|
|
1032
1227
|
): Promise<O> {
|
|
1228
|
+
const values = boundValues(context, given)
|
|
1033
1229
|
return run.write(async (transaction) => {
|
|
1034
1230
|
if (destination !== undefined) {
|
|
1035
1231
|
await lockDestination(transaction, context, destination)
|
|
@@ -1172,10 +1368,12 @@ async function insertOnConflict(
|
|
|
1172
1368
|
context: TableContext,
|
|
1173
1369
|
run: Runner,
|
|
1174
1370
|
transaction: Session,
|
|
1175
|
-
|
|
1371
|
+
given: ReadonlyMap<string, Primitive | null>,
|
|
1176
1372
|
conflict: OnConflict,
|
|
1177
1373
|
): Promise<Readonly<Record<string, unknown>>> {
|
|
1178
1374
|
const model = context.model
|
|
1375
|
+
const values = boundValues(context, given)
|
|
1376
|
+
const updates = boundValues(context, conflict.updates)
|
|
1179
1377
|
const matches: OperationNode[] = []
|
|
1180
1378
|
for (let index = 0; index < conflict.columns.length; index++) {
|
|
1181
1379
|
const name = conflict.columns[index] ?? ''
|
|
@@ -1201,17 +1399,17 @@ async function insertOnConflict(
|
|
|
1201
1399
|
return { inserted: false }
|
|
1202
1400
|
}
|
|
1203
1401
|
const where = scopedWhere(allOf(matches), scope)
|
|
1204
|
-
const unchanged =
|
|
1402
|
+
const unchanged = updates.size === 0
|
|
1205
1403
|
const update = unchanged
|
|
1206
1404
|
? acknowledgementNode(model.name, keyOf(model), model.version, where)
|
|
1207
|
-
: updateNode(model.name,
|
|
1405
|
+
: updateNode(model.name, updates, model.version, where, answer(context))
|
|
1208
1406
|
const [updated] = await transaction.run<Readonly<Record<string, unknown>>>(
|
|
1209
1407
|
update,
|
|
1210
1408
|
guard(context, unchanged ? 'SelectQueryNode' : 'UpdateQueryNode', scope),
|
|
1211
1409
|
)
|
|
1212
1410
|
if (updated !== undefined) {
|
|
1213
1411
|
checkVersion(context, updated)
|
|
1214
|
-
await referenceUpdated(context, run, transaction,
|
|
1412
|
+
await referenceUpdated(context, run, transaction, updates, updated)
|
|
1215
1413
|
return { ...updated, inserted: false }
|
|
1216
1414
|
}
|
|
1217
1415
|
}
|
|
@@ -1264,11 +1462,12 @@ export async function updateRow<O>(
|
|
|
1264
1462
|
context: TableContext,
|
|
1265
1463
|
run: Runner,
|
|
1266
1464
|
target: Addressed,
|
|
1267
|
-
|
|
1465
|
+
given: ReadonlyMap<string, Primitive | null>,
|
|
1268
1466
|
expectedVersion: OperationNode | undefined,
|
|
1269
1467
|
destination?: RefGrant,
|
|
1270
1468
|
): Promise<O> {
|
|
1271
1469
|
const { name: table, version } = context.model
|
|
1470
|
+
const changes = boundValues(context, given)
|
|
1272
1471
|
const where = scopedWhere(conditionsWith(target, expectedVersion), target.scope)
|
|
1273
1472
|
const unchanged = changes.size === 0
|
|
1274
1473
|
const node = unchanged
|
|
@@ -1389,18 +1588,26 @@ export async function aggregateRows<Row, O>(
|
|
|
1389
1588
|
if (scope === undefined) {
|
|
1390
1589
|
return []
|
|
1391
1590
|
}
|
|
1392
|
-
const
|
|
1591
|
+
const dialect = dialectOf(context)
|
|
1592
|
+
const conditions = boundConditions(context, context.model, filter?.conditions ?? [])
|
|
1593
|
+
const node = aggregateNode(context.model.name, columns, conditions, scope, context.model.dates)
|
|
1393
1594
|
const rows = await (await run.read()).run<O>(node, guard(context, 'SelectQueryNode', scope))
|
|
1394
|
-
|
|
1595
|
+
const own = rowColumnsOf(context.model)
|
|
1596
|
+
const answered = answerRows(dialect, context.model.name, {
|
|
1597
|
+
days: aggregated(own.days, columns),
|
|
1598
|
+
timestamps: aggregated(own.timestamps, columns),
|
|
1599
|
+
booleans: aggregated(own.booleans, columns),
|
|
1600
|
+
}, rows)
|
|
1601
|
+
return dialect === 'postgres' ? answered : sqliteAggregates(context.model.name, columns, answered)
|
|
1395
1602
|
}
|
|
1396
1603
|
|
|
1397
|
-
/** The `
|
|
1398
|
-
function
|
|
1604
|
+
/** The columns of `set` among an aggregate's: a grouped one by its name, and its `min_` and `max_`. */
|
|
1605
|
+
function aggregated(set: NameSet, columns: AggregateColumns): NameSet {
|
|
1399
1606
|
const names: string[] = []
|
|
1400
1607
|
const add = (prefix: string, list: readonly string[]) => {
|
|
1401
1608
|
for (let index = 0; index < list.length; index++) {
|
|
1402
1609
|
const name = list[index] ?? ''
|
|
1403
|
-
if (inSet(
|
|
1610
|
+
if (inSet(set, name)) {
|
|
1404
1611
|
names[names.length] = `${prefix}${name}`
|
|
1405
1612
|
}
|
|
1406
1613
|
}
|
|
@@ -1411,6 +1618,37 @@ function aggregateDays(dates: NameSet, columns: AggregateColumns): NameSet {
|
|
|
1411
1618
|
return nameSet(names)
|
|
1412
1619
|
}
|
|
1413
1620
|
|
|
1621
|
+
/**
|
|
1622
|
+
* An aggregate's numbers as Postgres's driver answers them: `count` as bigint text, and an integral `sum` as text too
|
|
1623
|
+
* (Postgres sums an integer as bigint or numeric). SQLite answers both as numbers, and a whole sum of a REAL column is
|
|
1624
|
+
* text here where Postgres's float8 sum is a number; `AggregateRow` types a sum as either.
|
|
1625
|
+
*/
|
|
1626
|
+
function sqliteAggregates<O>(table: string, columns: AggregateColumns, rows: O[]): O[] {
|
|
1627
|
+
const integral = (where: string, value: unknown): unknown => {
|
|
1628
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
1629
|
+
return value
|
|
1630
|
+
}
|
|
1631
|
+
if (!isSafeInteger(value)) {
|
|
1632
|
+
throw new QueryRefusedError('unexpected_row', `${where} came back as an integer past the safe range, which a number cannot hold exactly`)
|
|
1633
|
+
}
|
|
1634
|
+
return String(value)
|
|
1635
|
+
}
|
|
1636
|
+
for (let index = 0; index < rows.length; index++) {
|
|
1637
|
+
const row = rows[index]
|
|
1638
|
+
if (typeof row !== 'object' || row === null) {
|
|
1639
|
+
continue
|
|
1640
|
+
}
|
|
1641
|
+
if (columns.count) {
|
|
1642
|
+
defineValue(row, 'count', integral(`${table}.count`, ownDescriptor(row, 'count')?.value))
|
|
1643
|
+
}
|
|
1644
|
+
for (let position = 0; position < columns.sum.length; position++) {
|
|
1645
|
+
const name = `sum_${columns.sum[position] ?? ''}`
|
|
1646
|
+
defineValue(row, name, integral(`${table}.${name}`, ownDescriptor(row, name)?.value))
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
return rows
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1414
1652
|
/** An insert's row, stamped from its capability, the destination it locks, and what it does on a conflict. */
|
|
1415
1653
|
export interface PreparedInsert {
|
|
1416
1654
|
readonly row: Map<string, Primitive | null>
|
|
@@ -1433,10 +1671,8 @@ export function prepareDimensionInsert(context: TableContext, target: unknown, v
|
|
|
1433
1671
|
stampInsert(context, grant, row, scope.key, grant.id)
|
|
1434
1672
|
return { row, destination: undefined, conflict }
|
|
1435
1673
|
}
|
|
1436
|
-
stampInsert(context, grant, row, scope.key,
|
|
1437
|
-
|
|
1438
|
-
row.set(link.column, value)
|
|
1439
|
-
}
|
|
1674
|
+
stampInsert(context, grant, row, scope.key, uuidv7())
|
|
1675
|
+
stampChain(row, linkValues(context.model.name, context.model.chain, grant))
|
|
1440
1676
|
return { row, destination: grant, conflict }
|
|
1441
1677
|
}
|
|
1442
1678
|
|
|
@@ -1445,7 +1681,7 @@ export function prepareApplicationInsert(context: TableContext, capability: unkn
|
|
|
1445
1681
|
const grant = appGrant(context, capability, 'insert')
|
|
1446
1682
|
const row = readValues(values, context.model.insert, 'insert', context.model)
|
|
1447
1683
|
const conflict = onConflictOf(context, options, grant.action, row)
|
|
1448
|
-
stampInsert(context, grant, row, keyOf(context.model),
|
|
1684
|
+
stampInsert(context, grant, row, keyOf(context.model), uuidv7())
|
|
1449
1685
|
return { row, destination: undefined, conflict }
|
|
1450
1686
|
}
|
|
1451
1687
|
|
|
@@ -1455,13 +1691,21 @@ export function prepareChildInsert(context: TableContext, parent: unknown, value
|
|
|
1455
1691
|
const grant = refGrant(context, parent, scope.parent.dimension, 'insert')
|
|
1456
1692
|
const row = readValues(values, context.model.insert, 'insert', context.model)
|
|
1457
1693
|
const conflict = onConflictOf(context, options, grant.action, row)
|
|
1458
|
-
stampInsert(context, grant, row, scope.key,
|
|
1459
|
-
|
|
1460
|
-
row.set(link.column, value)
|
|
1461
|
-
}
|
|
1694
|
+
stampInsert(context, grant, row, scope.key, uuidv7())
|
|
1695
|
+
stampChain(row, linkValues(context.model.name, context.model.chain, grant))
|
|
1462
1696
|
return { row, destination: grant, conflict }
|
|
1463
1697
|
}
|
|
1464
1698
|
|
|
1699
|
+
/** Writes each chain column the destination `Ref` settles into an insert's row. */
|
|
1700
|
+
function stampChain(row: Map<string, Primitive | null>, values: readonly LinkValue[]): void {
|
|
1701
|
+
for (let index = 0; index < values.length; index++) {
|
|
1702
|
+
const entry = values[index]
|
|
1703
|
+
if (entry !== undefined) {
|
|
1704
|
+
row.set(entry.link.column, entry.value)
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1465
1709
|
/** A prepared insert, with its conflict handling when the caller named one. */
|
|
1466
1710
|
export function insertPrepared(context: TableContext, run: Runner, prepared: PreparedInsert): Promise<Readonly<Record<string, unknown>>> {
|
|
1467
1711
|
const conflict = prepared.conflict
|
|
@@ -1565,9 +1809,17 @@ export class DimensionTable<DB, P, T extends string> {
|
|
|
1565
1809
|
const scope = objectPredicate(context, ref, 'delete')
|
|
1566
1810
|
const target = refGrant(context, to, container.dimension, 'insert')
|
|
1567
1811
|
const values = linkValues(context.model.name, context.model.chain, target)
|
|
1568
|
-
const changes = new Map<string, Primitive | null>(
|
|
1812
|
+
const changes = new Map<string, Primitive | null>()
|
|
1813
|
+
stampChain(changes, values)
|
|
1814
|
+
const chain: Record<string, string> = {}
|
|
1815
|
+
for (let index = 0; index < values.length; index++) {
|
|
1816
|
+
const entry = values[index]
|
|
1817
|
+
if (entry !== undefined) {
|
|
1818
|
+
setOwn(chain, entry.link.dimension, entry.value)
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1569
1821
|
const acknowledged = await updateRow<Acknowledgement<P, T>>(context, storeRunner(context), addressed(scope), changes, undefined, target)
|
|
1570
|
-
return { ...acknowledged, chain
|
|
1822
|
+
return { ...acknowledged, chain }
|
|
1571
1823
|
}
|
|
1572
1824
|
|
|
1573
1825
|
async aggregate<const S extends AggregateSpec<Row<DB, P, T>>>(
|
|
@@ -1900,8 +2152,23 @@ export function scoped<DB, P>(tables: Tables<DB, P>): ScopedDatabase<DB, P> {
|
|
|
1900
2152
|
setOwn(joins, child, join.on)
|
|
1901
2153
|
}
|
|
1902
2154
|
}
|
|
2155
|
+
const childModels: Record<string, TableModel> = {}
|
|
2156
|
+
for (let index = 0; index < children.length; index++) {
|
|
2157
|
+
const child = modelOf(models, children[index] ?? '')
|
|
2158
|
+
if (child !== undefined) {
|
|
2159
|
+
setOwn(childModels, child.name, child)
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
1903
2162
|
const shape = shapeOf(model, joinable)
|
|
1904
|
-
return freeze({
|
|
2163
|
+
return freeze({
|
|
2164
|
+
model,
|
|
2165
|
+
parent,
|
|
2166
|
+
destination: destinationOf(models, model, parent),
|
|
2167
|
+
shape,
|
|
2168
|
+
joins: freeze(joins),
|
|
2169
|
+
children: freeze(childModels),
|
|
2170
|
+
connection,
|
|
2171
|
+
})
|
|
1905
2172
|
}
|
|
1906
2173
|
|
|
1907
2174
|
const database: ScopedDatabase<DB, P> = Object.freeze({ tables: tablePolicies(tables) })
|
|
@@ -1917,13 +2184,20 @@ export function scoped<DB, P>(tables: Tables<DB, P>): ScopedDatabase<DB, P> {
|
|
|
1917
2184
|
}
|
|
1918
2185
|
return probeOf(contextFor(table, mode))
|
|
1919
2186
|
},
|
|
1920
|
-
bind(runtime: DataLayerRuntime):
|
|
2187
|
+
bind(runtime: DataLayerRuntime): ProvisionalBinding {
|
|
1921
2188
|
if (connection.runtime !== undefined) {
|
|
1922
2189
|
throw new CapabilityError('layer_unbound', 'the data layer is already connected; only the framework connects it, once, at boot')
|
|
1923
2190
|
}
|
|
1924
2191
|
const bound: DataLayerRuntime = freeze({ database: runtime.database, capabilities: runtime.capabilities, readOnly: runtime.readOnly })
|
|
1925
2192
|
connection.runtime = bound
|
|
1926
|
-
return
|
|
2193
|
+
return freeze({
|
|
2194
|
+
locate: async (dimension: string, id: string, using?: Session) => locateObject(models, bound, dimension, id, using),
|
|
2195
|
+
release() {
|
|
2196
|
+
if (connection.runtime === bound) {
|
|
2197
|
+
connection.runtime = undefined
|
|
2198
|
+
}
|
|
2199
|
+
},
|
|
2200
|
+
})
|
|
1927
2201
|
},
|
|
1928
2202
|
framework: freeze({
|
|
1929
2203
|
webhooks: webhookTables(() => runtimeOf(connection)),
|