ultimabase 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +4 -2
- package/dist/access.d.ts +62 -0
- package/dist/access.d.ts.map +1 -0
- package/dist/access.js +258 -0
- package/dist/adapter-errors.d.ts +43 -0
- package/dist/adapter-errors.d.ts.map +1 -0
- package/dist/adapter-errors.js +72 -0
- package/dist/adapter-json.d.ts +20 -0
- package/dist/adapter-json.d.ts.map +1 -0
- package/dist/adapter-json.js +2 -0
- package/dist/adapter-migrations.d.ts +90 -0
- package/dist/adapter-migrations.d.ts.map +1 -0
- package/dist/adapter-migrations.js +1 -0
- package/dist/adapter.d.ts +333 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +5 -0
- package/dist/aggregate.d.ts +31 -0
- package/dist/aggregate.d.ts.map +1 -0
- package/dist/aggregate.js +99 -0
- package/dist/app-internals.d.ts +10 -0
- package/dist/app-internals.d.ts.map +1 -0
- package/dist/app-internals.js +12 -0
- package/dist/app-options.d.ts +22 -0
- package/dist/app-options.d.ts.map +1 -0
- package/dist/app-options.js +3 -0
- package/dist/cascade.d.ts +47 -0
- package/dist/cascade.d.ts.map +1 -0
- package/dist/cascade.js +209 -0
- package/dist/cli-config.d.ts +6 -0
- package/dist/cli-config.d.ts.map +1 -0
- package/dist/cli-config.js +45 -0
- package/dist/cli-deploy.d.ts +14 -0
- package/dist/cli-deploy.d.ts.map +1 -0
- package/dist/cli-deploy.js +193 -0
- package/dist/cli-main.d.ts +16 -0
- package/dist/cli-main.d.ts.map +1 -0
- package/dist/cli-main.js +151 -0
- package/dist/cli-migrate.d.ts +20 -0
- package/dist/cli-migrate.d.ts.map +1 -0
- package/dist/cli-migrate.js +105 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +4 -0
- package/dist/complete.d.ts +13 -0
- package/dist/complete.d.ts.map +1 -0
- package/dist/complete.js +60 -0
- package/dist/console-logger.d.ts +3 -0
- package/dist/console-logger.d.ts.map +1 -0
- package/dist/console-logger.js +62 -0
- package/dist/create-many.d.ts +7 -0
- package/dist/create-many.d.ts.map +1 -0
- package/dist/create-many.js +88 -0
- package/dist/decimal.d.ts +23 -0
- package/dist/decimal.d.ts.map +1 -0
- package/dist/decimal.js +50 -0
- package/dist/define-app.d.ts +59 -0
- package/dist/define-app.d.ts.map +1 -0
- package/dist/define-app.js +5 -0
- package/dist/define-enum.d.ts +6 -0
- package/dist/define-enum.d.ts.map +1 -0
- package/dist/define-enum.js +18 -0
- package/dist/define-function.d.ts +25 -0
- package/dist/define-function.d.ts.map +1 -0
- package/dist/define-function.js +111 -0
- package/dist/define-model.d.ts +48 -0
- package/dist/define-model.d.ts.map +1 -0
- package/dist/define-model.js +85 -0
- package/dist/definition.d.ts +179 -0
- package/dist/definition.d.ts.map +1 -0
- package/dist/definition.js +165 -0
- package/dist/delete-many.d.ts +13 -0
- package/dist/delete-many.d.ts.map +1 -0
- package/dist/delete-many.js +58 -0
- package/dist/engine.d.ts +16 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +174 -0
- package/dist/errors.d.ts +50 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +43 -0
- package/dist/exact.d.ts +20 -0
- package/dist/exact.d.ts.map +1 -0
- package/dist/exact.js +1 -0
- package/dist/feed.d.ts +6 -0
- package/dist/feed.d.ts.map +1 -0
- package/dist/feed.js +33 -0
- package/dist/field-codec.d.ts +11 -0
- package/dist/field-codec.d.ts.map +1 -0
- package/dist/field-codec.js +48 -0
- package/dist/field-rules.d.ts +57 -0
- package/dist/field-rules.d.ts.map +1 -0
- package/dist/field-rules.js +1 -0
- package/dist/field-type.d.ts +51 -0
- package/dist/field-type.d.ts.map +1 -0
- package/dist/field-type.js +106 -0
- package/dist/fields.d.ts +295 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +1 -0
- package/dist/function.d.ts +126 -0
- package/dist/function.d.ts.map +1 -0
- package/dist/function.js +1 -0
- package/dist/functions.d.ts +18 -0
- package/dist/functions.d.ts.map +1 -0
- package/dist/functions.js +99 -0
- package/dist/group-by.d.ts +10 -0
- package/dist/group-by.d.ts.map +1 -0
- package/dist/group-by.js +145 -0
- package/dist/group-having.d.ts +8 -0
- package/dist/group-having.d.ts.map +1 -0
- package/dist/group-having.js +97 -0
- package/dist/hooks.d.ts +41 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +149 -0
- package/dist/hosts.d.ts +3 -0
- package/dist/hosts.d.ts.map +1 -0
- package/dist/hosts.js +8 -0
- package/dist/identify.d.ts +15 -0
- package/dist/identify.d.ts.map +1 -0
- package/dist/identify.js +71 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/internal.d.ts +28 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +39 -0
- package/dist/lifecycle.d.ts +21 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +149 -0
- package/dist/list-update.d.ts +12 -0
- package/dist/list-update.d.ts.map +1 -0
- package/dist/list-update.js +52 -0
- package/dist/live.d.ts +37 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +139 -0
- package/dist/manifest.d.ts +11 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +60 -0
- package/dist/memory-aggregate.d.ts +7 -0
- package/dist/memory-aggregate.d.ts.map +1 -0
- package/dist/memory-aggregate.js +107 -0
- package/dist/memory-conditions.d.ts +8 -0
- package/dist/memory-conditions.d.ts.map +1 -0
- package/dist/memory-conditions.js +82 -0
- package/dist/memory-draft.d.ts +34 -0
- package/dist/memory-draft.d.ts.map +1 -0
- package/dist/memory-draft.js +255 -0
- package/dist/memory-json.d.ts +6 -0
- package/dist/memory-json.d.ts.map +1 -0
- package/dist/memory-json.js +86 -0
- package/dist/memory-read.d.ts +45 -0
- package/dist/memory-read.d.ts.map +1 -0
- package/dist/memory-read.js +244 -0
- package/dist/memory-search.d.ts +16 -0
- package/dist/memory-search.d.ts.map +1 -0
- package/dist/memory-search.js +110 -0
- package/dist/memory-values.d.ts +23 -0
- package/dist/memory-values.d.ts.map +1 -0
- package/dist/memory-values.js +82 -0
- package/dist/memory.d.ts +4 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +157 -0
- package/dist/migrate.d.ts +22 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +131 -0
- package/dist/model.d.ts +239 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +1 -0
- package/dist/nested-write.d.ts +64 -0
- package/dist/nested-write.d.ts.map +1 -0
- package/dist/nested-write.js +274 -0
- package/dist/number-update.d.ts +18 -0
- package/dist/number-update.d.ts.map +1 -0
- package/dist/number-update.js +96 -0
- package/dist/plugin-app.d.ts +48 -0
- package/dist/plugin-app.d.ts.map +1 -0
- package/dist/plugin-app.js +230 -0
- package/dist/plugin-code.d.ts +17 -0
- package/dist/plugin-code.d.ts.map +1 -0
- package/dist/plugin-code.js +125 -0
- package/dist/plugin-use.d.ts +22 -0
- package/dist/plugin-use.d.ts.map +1 -0
- package/dist/plugin-use.js +161 -0
- package/dist/plugin.d.ts +357 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +17 -0
- package/dist/query-aggregate.d.ts +82 -0
- package/dist/query-aggregate.d.ts.map +1 -0
- package/dist/query-aggregate.js +1 -0
- package/dist/query-args.d.ts +41 -0
- package/dist/query-args.d.ts.map +1 -0
- package/dist/query-args.js +263 -0
- package/dist/query-bulk.d.ts +35 -0
- package/dist/query-bulk.d.ts.map +1 -0
- package/dist/query-bulk.js +1 -0
- package/dist/query-conditions.d.ts +9 -0
- package/dist/query-conditions.d.ts.map +1 -0
- package/dist/query-conditions.js +46 -0
- package/dist/query-context.d.ts +32 -0
- package/dist/query-context.d.ts.map +1 -0
- package/dist/query-context.js +55 -0
- package/dist/query-filter.d.ts +46 -0
- package/dist/query-filter.d.ts.map +1 -0
- package/dist/query-filter.js +1 -0
- package/dist/query-group.d.ts +82 -0
- package/dist/query-group.d.ts.map +1 -0
- package/dist/query-group.js +1 -0
- package/dist/query-json.d.ts +9 -0
- package/dist/query-json.d.ts.map +1 -0
- package/dist/query-json.js +66 -0
- package/dist/query-order.d.ts +15 -0
- package/dist/query-order.d.ts.map +1 -0
- package/dist/query-order.js +115 -0
- package/dist/query-project.d.ts +15 -0
- package/dist/query-project.d.ts.map +1 -0
- package/dist/query-project.js +45 -0
- package/dist/query-search.d.ts +24 -0
- package/dist/query-search.d.ts.map +1 -0
- package/dist/query-search.js +2 -0
- package/dist/query-values.d.ts +28 -0
- package/dist/query-values.d.ts.map +1 -0
- package/dist/query-values.js +91 -0
- package/dist/query-where.d.ts +46 -0
- package/dist/query-where.d.ts.map +1 -0
- package/dist/query-where.js +200 -0
- package/dist/query-write.d.ts +14 -0
- package/dist/query-write.d.ts.map +1 -0
- package/dist/query-write.js +260 -0
- package/dist/query.d.ts +254 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +1 -0
- package/dist/record.d.ts +257 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +1 -0
- package/dist/recorder.d.ts +32 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/recorder.js +152 -0
- package/dist/relation-write.d.ts +41 -0
- package/dist/relation-write.d.ts.map +1 -0
- package/dist/relation-write.js +112 -0
- package/dist/relations.d.ts +42 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +119 -0
- package/dist/rules.d.ts +97 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +71 -0
- package/dist/running.d.ts +29 -0
- package/dist/running.d.ts.map +1 -0
- package/dist/running.js +39 -0
- package/dist/schema-indexes.d.ts +11 -0
- package/dist/schema-indexes.d.ts.map +1 -0
- package/dist/schema-indexes.js +59 -0
- package/dist/schema-virtuals.d.ts +19 -0
- package/dist/schema-virtuals.d.ts.map +1 -0
- package/dist/schema-virtuals.js +100 -0
- package/dist/schema.d.ts +35 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +267 -0
- package/dist/scope.d.ts +46 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +82 -0
- package/dist/scratch.d.ts +14 -0
- package/dist/scratch.d.ts.map +1 -0
- package/dist/scratch.js +10 -0
- package/dist/serve.d.ts +2 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +4 -0
- package/dist/symbols.d.ts +10 -0
- package/dist/symbols.d.ts.map +1 -0
- package/dist/symbols.js +1 -0
- package/dist/transaction.d.ts +28 -0
- package/dist/transaction.d.ts.map +1 -0
- package/dist/transaction.js +156 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/update-many.d.ts +7 -0
- package/dist/update-many.d.ts.map +1 -0
- package/dist/update-many.js +182 -0
- package/dist/uuid.d.ts +7 -0
- package/dist/uuid.d.ts.map +1 -0
- package/dist/uuid.js +48 -0
- package/dist/validation.d.ts +24 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +72 -0
- package/dist/virtual-read.d.ts +9 -0
- package/dist/virtual-read.d.ts.map +1 -0
- package/dist/virtual-read.js +145 -0
- package/dist/virtual.d.ts +142 -0
- package/dist/virtual.d.ts.map +1 -0
- package/dist/virtual.js +1 -0
- package/dist/write-context.d.ts +46 -0
- package/dist/write-context.d.ts.map +1 -0
- package/dist/write-context.js +102 -0
- package/dist/write-data.d.ts +26 -0
- package/dist/write-data.d.ts.map +1 -0
- package/dist/write-data.js +144 -0
- package/dist/write-plan.d.ts +107 -0
- package/dist/write-plan.d.ts.map +1 -0
- package/dist/write-plan.js +170 -0
- package/dist/write-target.d.ts +17 -0
- package/dist/write-target.d.ts.map +1 -0
- package/dist/write-target.js +59 -0
- package/dist/writes.d.ts +23 -0
- package/dist/writes.d.ts.map +1 -0
- package/dist/writes.js +246 -0
- package/dist/writing.d.ts +10 -0
- package/dist/writing.d.ts.map +1 -0
- package/dist/writing.js +66 -0
- package/package.json +42 -3
- package/src/access.ts +329 -0
- package/src/adapter-errors.ts +89 -0
- package/src/adapter-json.ts +21 -0
- package/src/adapter-migrations.ts +98 -0
- package/src/adapter.ts +363 -0
- package/src/aggregate.ts +136 -0
- package/src/app-internals.ts +26 -0
- package/src/app-options.ts +24 -0
- package/src/cascade.ts +297 -0
- package/src/cli-config.ts +45 -0
- package/src/cli-deploy.ts +235 -0
- package/src/cli-main.ts +165 -0
- package/src/cli-migrate.ts +143 -0
- package/src/cli.ts +5 -0
- package/src/complete.ts +73 -0
- package/src/console-logger.ts +87 -0
- package/src/create-many.ts +126 -0
- package/src/decimal.ts +74 -0
- package/src/define-app.ts +108 -0
- package/src/define-enum.ts +26 -0
- package/src/define-function.ts +188 -0
- package/src/define-model.ts +220 -0
- package/src/definition.ts +352 -0
- package/src/delete-many.ts +76 -0
- package/src/engine.ts +281 -0
- package/src/errors.ts +97 -0
- package/src/exact.ts +49 -0
- package/src/feed.ts +39 -0
- package/src/field-codec.ts +72 -0
- package/src/field-rules.ts +77 -0
- package/src/field-type.ts +219 -0
- package/src/fields.ts +383 -0
- package/src/function.ts +143 -0
- package/src/functions.ts +154 -0
- package/src/group-by.ts +186 -0
- package/src/group-having.ts +143 -0
- package/src/hooks.ts +215 -0
- package/src/hosts.ts +11 -0
- package/src/identify.ts +95 -0
- package/src/index.ts +125 -0
- package/src/internal.ts +68 -0
- package/src/lifecycle.ts +214 -0
- package/src/list-update.ts +71 -0
- package/src/live.ts +199 -0
- package/src/manifest.ts +71 -0
- package/src/memory-aggregate.ts +162 -0
- package/src/memory-conditions.ts +88 -0
- package/src/memory-draft.ts +319 -0
- package/src/memory-json.ts +91 -0
- package/src/memory-read.ts +311 -0
- package/src/memory-search.ts +149 -0
- package/src/memory-values.ts +106 -0
- package/src/memory.ts +173 -0
- package/src/migrate.ts +152 -0
- package/src/model.ts +312 -0
- package/src/nested-write.ts +390 -0
- package/src/number-update.ts +142 -0
- package/src/plugin-app.ts +308 -0
- package/src/plugin-code.ts +158 -0
- package/src/plugin-use.ts +217 -0
- package/src/plugin.ts +400 -0
- package/src/query-aggregate.ts +96 -0
- package/src/query-args.ts +375 -0
- package/src/query-bulk.ts +53 -0
- package/src/query-conditions.ts +53 -0
- package/src/query-context.ts +104 -0
- package/src/query-filter.ts +54 -0
- package/src/query-group.ts +137 -0
- package/src/query-json.ts +90 -0
- package/src/query-order.ts +158 -0
- package/src/query-project.ts +73 -0
- package/src/query-search.ts +31 -0
- package/src/query-values.ts +131 -0
- package/src/query-where.ts +318 -0
- package/src/query-write.ts +360 -0
- package/src/query.ts +400 -0
- package/src/record.ts +379 -0
- package/src/recorder.ts +212 -0
- package/src/relation-write.ts +176 -0
- package/src/relations.ts +165 -0
- package/src/rules.ts +188 -0
- package/src/running.ts +62 -0
- package/src/schema-indexes.ts +88 -0
- package/src/schema-virtuals.ts +134 -0
- package/src/schema.ts +367 -0
- package/src/scope.ts +123 -0
- package/src/scratch.ts +28 -0
- package/src/serve.ts +5 -0
- package/src/symbols.ts +11 -0
- package/src/transaction.ts +211 -0
- package/src/types.ts +79 -0
- package/src/update-many.ts +268 -0
- package/src/uuid.ts +51 -0
- package/src/validation.ts +107 -0
- package/src/virtual-read.ts +198 -0
- package/src/virtual.ts +187 -0
- package/src/write-context.ts +170 -0
- package/src/write-data.ts +188 -0
- package/src/write-plan.ts +273 -0
- package/src/write-target.ts +92 -0
- package/src/writes.ts +361 -0
- package/src/writing.ts +93 -0
package/src/group-by.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// `groupBy` (D-146): the rows the caller reads in groups of the same `by`
|
|
2
|
+
// values, each with the aggregates of `aggregate` (D-143), kept by its
|
|
3
|
+
// `having` (D-149), sorted by its keys or its aggregates, then paged.
|
|
4
|
+
import type { AggregateRead, GroupKey } from './adapter.ts'
|
|
5
|
+
import type { Asked, Key } from './aggregate.ts'
|
|
6
|
+
import {
|
|
7
|
+
checked,
|
|
8
|
+
keys,
|
|
9
|
+
shaped,
|
|
10
|
+
toAggregate,
|
|
11
|
+
typeOf,
|
|
12
|
+
wanted,
|
|
13
|
+
} from './aggregate.ts'
|
|
14
|
+
import { InvalidQuery } from './errors.ts'
|
|
15
|
+
import type { Position } from './group-having.ts'
|
|
16
|
+
import { having } from './group-having.ts'
|
|
17
|
+
import type { Context } from './query-context.ts'
|
|
18
|
+
import { filtered, given } from './query-context.ts'
|
|
19
|
+
import { integer } from './query-args.ts'
|
|
20
|
+
import { direction, sortOf } from './query-order.ts'
|
|
21
|
+
import { entries, isObject } from './query-where.ts'
|
|
22
|
+
import type { Store } from './transaction.ts'
|
|
23
|
+
|
|
24
|
+
type Sort = NonNullable<AggregateRead['orderBy']>[number]
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The fields of `by`, or of `distinct` (D-147), each checked; a `one`
|
|
28
|
+
* relation the row stores.
|
|
29
|
+
*/
|
|
30
|
+
function grouping(
|
|
31
|
+
{ model, scope }: Context,
|
|
32
|
+
value: unknown,
|
|
33
|
+
name: 'by' | 'distinct',
|
|
34
|
+
): string[] {
|
|
35
|
+
const message = `${name} must be a non-empty list of fields.`
|
|
36
|
+
const list: readonly unknown[] = Array.isArray(value) ? value : []
|
|
37
|
+
if (list.length === 0) throw new InvalidQuery(name, message)
|
|
38
|
+
const [verb, done] =
|
|
39
|
+
name === 'by'
|
|
40
|
+
? (['aggregates', 'grouped'] as const)
|
|
41
|
+
: (['compares', 'compared'] as const)
|
|
42
|
+
const fields: string[] = []
|
|
43
|
+
for (const [index, field] of list.entries()) {
|
|
44
|
+
const path = `${name}.${index}`
|
|
45
|
+
if (typeof field !== 'string') throw new InvalidQuery(path, message)
|
|
46
|
+
if (fields.includes(field)) {
|
|
47
|
+
throw new InvalidQuery(path, `${path} repeats ${field}.`)
|
|
48
|
+
}
|
|
49
|
+
const known = model.fields.get(field)
|
|
50
|
+
if (known?.kind !== 'one' || !known.stored) {
|
|
51
|
+
typeOf(model, field, path, done)
|
|
52
|
+
}
|
|
53
|
+
scope.check(model, field, path, verb)
|
|
54
|
+
fields.push(field)
|
|
55
|
+
}
|
|
56
|
+
return fields
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Groups by a relation's target id, `null` when the caller cannot read it. */
|
|
60
|
+
async function keyOf(context: Context, field: string): Promise<GroupKey> {
|
|
61
|
+
const known = context.model.fields.get(field)
|
|
62
|
+
if (known?.kind !== 'one') return { field }
|
|
63
|
+
const where = await context.scope.filter(known.target, false)
|
|
64
|
+
if (where === undefined) return { field }
|
|
65
|
+
return { field: 'id', through: [{ relation: field, where }] }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The keys of a `findMany`'s `distinct`, which a cursor cannot page (D-147):
|
|
70
|
+
* `undefined` without one.
|
|
71
|
+
*/
|
|
72
|
+
export async function distinctOf(
|
|
73
|
+
context: Context,
|
|
74
|
+
args: unknown,
|
|
75
|
+
): Promise<GroupKey[] | undefined> {
|
|
76
|
+
if (!isObject(args) || args['distinct'] === undefined) return undefined
|
|
77
|
+
if (args['cursor'] !== undefined) {
|
|
78
|
+
throw new InvalidQuery('distinct', 'distinct cannot be used with cursor.')
|
|
79
|
+
}
|
|
80
|
+
const fields = grouping(context, args['distinct'], 'distinct')
|
|
81
|
+
return Promise.all(fields.map(async (field) => keyOf(context, field)))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* An aggregate's position in a group's values, after its keys; one the
|
|
86
|
+
* result does not ask for is added to `aggregates`.
|
|
87
|
+
*/
|
|
88
|
+
const positions =
|
|
89
|
+
(by: readonly string[], aggregates: Asked[]): Position =>
|
|
90
|
+
(asked) => {
|
|
91
|
+
const index = aggregates.findIndex(
|
|
92
|
+
(each) => each.key === asked.key && each.field === asked.field,
|
|
93
|
+
)
|
|
94
|
+
if (index !== -1) return by.length + index
|
|
95
|
+
aggregates.push(asked)
|
|
96
|
+
return by.length + aggregates.length - 1
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The groups' sort: each key of `orderBy`, then the other fields of `by`. */
|
|
100
|
+
function sorts(
|
|
101
|
+
context: Context,
|
|
102
|
+
value: unknown,
|
|
103
|
+
by: readonly string[],
|
|
104
|
+
at: Position,
|
|
105
|
+
): Sort[] {
|
|
106
|
+
const parts: readonly unknown[] = Array.isArray(value) ? value : [value]
|
|
107
|
+
const sorted: Sort[] = []
|
|
108
|
+
for (const [index, part] of parts.entries()) {
|
|
109
|
+
const base = Array.isArray(value) ? `orderBy.${index}` : 'orderBy'
|
|
110
|
+
for (const [name, order] of entries(part, base)) {
|
|
111
|
+
const path = `${base}.${name}`
|
|
112
|
+
if (by.includes(name)) {
|
|
113
|
+
const known = context.model.fields.get(name)
|
|
114
|
+
const optional = known?.kind !== 'many' && known?.optional === true
|
|
115
|
+
sorted.push({ at: by.indexOf(name), ...sortOf(order, path, optional) })
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
const key = keys.find((each): each is Key => each === name)
|
|
119
|
+
if (key === undefined) {
|
|
120
|
+
throw new InvalidQuery(
|
|
121
|
+
path,
|
|
122
|
+
`${path} must be a field of by or an aggregate.`,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
if (key === '_count' && !isObject(order)) {
|
|
126
|
+
const asked = { key, field: undefined }
|
|
127
|
+
sorted.push({ at: at(asked), direction: direction(order, path) })
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
for (const [field, each] of entries(order, path)) {
|
|
131
|
+
const asked = checked(context, key, field, `${path}.${field}`)
|
|
132
|
+
const sort = direction(each, `${path}.${field}`)
|
|
133
|
+
sorted.push({ at: at(asked), direction: sort })
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const [index] of by.entries()) {
|
|
138
|
+
if (!sorted.some((sort) => sort.at === index)) {
|
|
139
|
+
sorted.push({ at: index, direction: 'asc' })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return sorted
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function groupBy(
|
|
146
|
+
context: Context,
|
|
147
|
+
store: Store,
|
|
148
|
+
args: unknown,
|
|
149
|
+
): Promise<unknown> {
|
|
150
|
+
const { model } = context
|
|
151
|
+
const allowed = ['by', 'where', 'having', 'orderBy', 'skip', 'take', ...keys]
|
|
152
|
+
const found = given(args, model.name, allowed, ['by'])
|
|
153
|
+
const by = grouping(context, found.get('by'), 'by')
|
|
154
|
+
const asked = wanted(context, found)
|
|
155
|
+
const aggregates = [...asked]
|
|
156
|
+
const position = positions(by, aggregates)
|
|
157
|
+
const orderBy = sorts(context, found.get('orderBy') ?? {}, by, position)
|
|
158
|
+
const filter = found.has('having')
|
|
159
|
+
? having(context, found.get('having'), by, position)
|
|
160
|
+
: undefined
|
|
161
|
+
const page: { skip?: number; take?: number } = {}
|
|
162
|
+
if (found.has('skip')) page.skip = integer(found.get('skip'), 'skip', 0)
|
|
163
|
+
if (found.has('take')) page.take = integer(found.get('take'), 'take', 0)
|
|
164
|
+
const where = await filtered(context, found, 'read')
|
|
165
|
+
const groupKeys = await Promise.all(
|
|
166
|
+
by.map(async (field) => keyOf(context, field)),
|
|
167
|
+
)
|
|
168
|
+
const groups = await store.aggregate({
|
|
169
|
+
model: model.name,
|
|
170
|
+
...(where && { where }),
|
|
171
|
+
aggregates: aggregates.map(toAggregate),
|
|
172
|
+
by: groupKeys,
|
|
173
|
+
...(filter && { having: filter }),
|
|
174
|
+
orderBy,
|
|
175
|
+
...page,
|
|
176
|
+
})
|
|
177
|
+
return groups.map((values) => {
|
|
178
|
+
const row: { [field: string]: unknown } = {}
|
|
179
|
+
for (const [index, field] of by.entries()) {
|
|
180
|
+
const value = values[index] ?? null
|
|
181
|
+
const relation = model.fields.get(field)?.kind === 'one'
|
|
182
|
+
row[field] = relation && value !== null ? { id: value } : value
|
|
183
|
+
}
|
|
184
|
+
return { ...row, ...shaped(found, asked, values.slice(by.length)) }
|
|
185
|
+
})
|
|
186
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// `groupBy`'s `having` (D-149), as Prisma's: a field of `by` filters the
|
|
2
|
+
// groups by its value, any field by its aggregates, which the result need
|
|
3
|
+
// not ask for. The adapter's filter names each value by its position in the
|
|
4
|
+
// group's values, as `orderBy` does.
|
|
5
|
+
import type { Condition, Filter } from './adapter.ts'
|
|
6
|
+
import type { Asked, Key } from './aggregate.ts'
|
|
7
|
+
import { checked, keys, typeOf } from './aggregate.ts'
|
|
8
|
+
import { decimalPattern, normalized } from './decimal.ts'
|
|
9
|
+
import { InvalidQuery } from './errors.ts'
|
|
10
|
+
import type { Context } from './query-context.ts'
|
|
11
|
+
import type { Operand } from './query-where.ts'
|
|
12
|
+
import { entries, fieldCondition, isObject } from './query-where.ts'
|
|
13
|
+
|
|
14
|
+
/** An aggregate's position in the group's values, added if not asked. */
|
|
15
|
+
export type Position = (asked: Asked) => number
|
|
16
|
+
|
|
17
|
+
const decimal = new RegExp(decimalPattern)
|
|
18
|
+
|
|
19
|
+
const whole: Operand = {
|
|
20
|
+
type: 'number',
|
|
21
|
+
check: (item, at) => {
|
|
22
|
+
if (!Number.isSafeInteger(item)) {
|
|
23
|
+
throw new InvalidQuery(at, `${at} must be a whole number.`)
|
|
24
|
+
}
|
|
25
|
+
return item
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const finite: Operand = {
|
|
30
|
+
type: 'number',
|
|
31
|
+
check: (item, at) => {
|
|
32
|
+
if (typeof item !== 'number' || !Number.isFinite(item)) {
|
|
33
|
+
throw new InvalidQuery(at, `${at} must be a finite number.`)
|
|
34
|
+
}
|
|
35
|
+
return item
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A decimal's sum, exact at its field's scale, of any precision (D-145). */
|
|
40
|
+
const sum = (scale: number): Operand => ({
|
|
41
|
+
type: 'decimal',
|
|
42
|
+
check: (item, at) => {
|
|
43
|
+
if (typeof item !== 'string' || !decimal.test(item)) {
|
|
44
|
+
throw new InvalidQuery(
|
|
45
|
+
at,
|
|
46
|
+
`${at} must be a decimal string, such as '12.50'.`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
return normalized(item, scale)
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The operands of aggregate `key` of a field: a count's a whole number, a
|
|
55
|
+
* sum's and an average's a number, a decimal's sum a decimal string, a
|
|
56
|
+
* minimum's and a maximum's the field's own.
|
|
57
|
+
*/
|
|
58
|
+
function operandOf(
|
|
59
|
+
context: Context,
|
|
60
|
+
key: Key,
|
|
61
|
+
name: string,
|
|
62
|
+
): Operand | undefined {
|
|
63
|
+
if (key === '_count') return whole
|
|
64
|
+
if (key === '_min' || key === '_max') return undefined
|
|
65
|
+
const field = context.model.fields.get(name)
|
|
66
|
+
return key === '_sum' && field?.kind === 'scalar' && field.type === 'decimal'
|
|
67
|
+
? sum(field.scale ?? 0)
|
|
68
|
+
: finite
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Parts = { [key: string]: Condition | Filter[] }
|
|
72
|
+
|
|
73
|
+
/** The conditions of field `name`'s value and aggregates, by position. */
|
|
74
|
+
function conditions(
|
|
75
|
+
context: Context,
|
|
76
|
+
name: string,
|
|
77
|
+
part: unknown,
|
|
78
|
+
at: string,
|
|
79
|
+
by: readonly string[],
|
|
80
|
+
position: Position,
|
|
81
|
+
): Parts {
|
|
82
|
+
const { model, scope } = context
|
|
83
|
+
typeOf(model, name, at, 'used in having')
|
|
84
|
+
const found: Parts = {}
|
|
85
|
+
const own: [string, unknown][] = []
|
|
86
|
+
const listed = isObject(part) ? entries(part, at) : []
|
|
87
|
+
for (const [key, operand] of listed) {
|
|
88
|
+
const aggregate = keys.find((each): each is Key => each === key)
|
|
89
|
+
if (aggregate === undefined) {
|
|
90
|
+
own.push([key, operand])
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
const where = `${at}.${key}`
|
|
94
|
+
const asked = checked(context, aggregate, name, where)
|
|
95
|
+
const of = operandOf(context, aggregate, name)
|
|
96
|
+
const condition = fieldCondition(model, name, operand, where, of)
|
|
97
|
+
if (condition !== undefined) found[String(position(asked))] = condition
|
|
98
|
+
}
|
|
99
|
+
if (isObject(part) && own.length === 0) return found
|
|
100
|
+
if (!by.includes(name)) {
|
|
101
|
+
throw new InvalidQuery(
|
|
102
|
+
at,
|
|
103
|
+
`${at} is not a field of by: only its aggregates filter the groups.`,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
scope.check(model, name, at, 'filters')
|
|
107
|
+
const value = isObject(part) ? Object.fromEntries(own) : part
|
|
108
|
+
const condition = fieldCondition(model, name, value, at)
|
|
109
|
+
if (condition !== undefined) found[String(by.indexOf(name))] = condition
|
|
110
|
+
return found
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The `having` of the groups by `by`, normalized; `path` names it in errors. */
|
|
114
|
+
export function having(
|
|
115
|
+
context: Context,
|
|
116
|
+
value: unknown,
|
|
117
|
+
by: readonly string[],
|
|
118
|
+
position: Position,
|
|
119
|
+
path = 'having',
|
|
120
|
+
): Filter {
|
|
121
|
+
const filter: Parts = {}
|
|
122
|
+
for (const [name, part] of entries(value, path)) {
|
|
123
|
+
const at = `${path}.${name}`
|
|
124
|
+
if (name !== 'AND' && name !== 'OR' && name !== 'NOT') {
|
|
125
|
+
Object.assign(filter, conditions(context, name, part, at, by, position))
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(part) && name === 'OR') {
|
|
129
|
+
throw new InvalidQuery(at, `${at} must be a list.`)
|
|
130
|
+
}
|
|
131
|
+
const list: readonly unknown[] = Array.isArray(part) ? part : [part]
|
|
132
|
+
filter[name] = list.map((item, index) =>
|
|
133
|
+
having(
|
|
134
|
+
context,
|
|
135
|
+
item,
|
|
136
|
+
by,
|
|
137
|
+
position,
|
|
138
|
+
Array.isArray(part) ? `${at}.${index}` : at,
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
return filter
|
|
143
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// The fixed hooks of D-15 at run time (D-71, D-88): the plugins', then the
|
|
2
|
+
// model's `.with` (D-89), run with their facts; `after*` hooks in the
|
|
3
|
+
// transaction, the `*Commit` hooks once it commits.
|
|
4
|
+
import { and, recordOf } from './access.ts'
|
|
5
|
+
import type { Filter, Row } from './adapter.ts'
|
|
6
|
+
import type { hookNames } from './plugin-code.ts'
|
|
7
|
+
import { commandOf, outsideCommands } from './plugin-code.ts'
|
|
8
|
+
import type { FindOperation } from './model.ts'
|
|
9
|
+
import type { Caller } from './rules.ts'
|
|
10
|
+
import type { Relations } from './relations.ts'
|
|
11
|
+
import { trusted } from './relations.ts'
|
|
12
|
+
import { filterOf, isReadFilter, readFilter } from './rules.ts'
|
|
13
|
+
import type { ModelInfo } from './schema.ts'
|
|
14
|
+
import type { Models } from './query-where.ts'
|
|
15
|
+
import { isObject, normalizeWhere } from './query-where.ts'
|
|
16
|
+
import type { Commit, Store } from './transaction.ts'
|
|
17
|
+
|
|
18
|
+
type HookName = (typeof hookNames)[number]
|
|
19
|
+
|
|
20
|
+
type Hook = {
|
|
21
|
+
/** Where it is written, for its errors. */
|
|
22
|
+
readonly path: string
|
|
23
|
+
readonly run: (facts: object) => Promise<unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Runs a hook's body; the queries it makes run outside any command. */
|
|
27
|
+
async function called(hook: Function, facts: object): Promise<unknown> {
|
|
28
|
+
return outsideCommands(async () => {
|
|
29
|
+
const result: unknown = await Reflect.apply(hook, undefined, [facts])
|
|
30
|
+
return result
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The application's hook `name` of the model, from its `.with` (D-71). */
|
|
35
|
+
function ownHook(model: ModelInfo, name: HookName): Hook | undefined {
|
|
36
|
+
const rules = model.definition.rules
|
|
37
|
+
const hooks =
|
|
38
|
+
rules !== undefined && isObject(rules) ? rules['hooks'] : undefined
|
|
39
|
+
if (hooks === undefined) return undefined
|
|
40
|
+
if (!isObject(hooks)) {
|
|
41
|
+
throw new TypeError(`${model.name}: hooks is an object.`)
|
|
42
|
+
}
|
|
43
|
+
const hook = hooks[name]
|
|
44
|
+
if (hook === undefined) return undefined
|
|
45
|
+
if (typeof hook !== 'function') {
|
|
46
|
+
throw new TypeError(`${model.name}.hooks.${name} is a function.`)
|
|
47
|
+
}
|
|
48
|
+
const path = `${model.name}.hooks.${name}`
|
|
49
|
+
return { path, run: async (facts) => called(hook, facts) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The hooks `name` of the model: its plugins' first, in the order of
|
|
54
|
+
* `plugins`, then the application's (D-29). A plugin's facts add what its
|
|
55
|
+
* code gets of the model and the command running (D-89).
|
|
56
|
+
*/
|
|
57
|
+
function hooksOf(model: ModelInfo, name: HookName): readonly Hook[] {
|
|
58
|
+
const hooks: Hook[] = []
|
|
59
|
+
for (const use of model.plugins) {
|
|
60
|
+
const hook = use.code.hooks[name]
|
|
61
|
+
if (hook === undefined) continue
|
|
62
|
+
const plugin = use.code.name
|
|
63
|
+
hooks.push({
|
|
64
|
+
path: `${plugin}.hooks.${name} on ${model.name}`,
|
|
65
|
+
run: async (facts) =>
|
|
66
|
+
called(hook, {
|
|
67
|
+
...facts,
|
|
68
|
+
...use.model,
|
|
69
|
+
command: commandOf(model.name, plugin),
|
|
70
|
+
}),
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
const own = ownHook(model, name)
|
|
74
|
+
return own === undefined ? hooks : [...hooks, own]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function hasHook(model: ModelInfo, name: HookName): boolean {
|
|
78
|
+
return hooksOf(model, name).length > 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runHook(
|
|
82
|
+
model: ModelInfo,
|
|
83
|
+
name: 'beforeCreate' | 'beforeUpdate' | 'beforeDelete',
|
|
84
|
+
facts: object,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
for (const hook of hooksOf(model, name)) await hook.run(facts)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The filters every `beforeFind` ANDs into each read of the model (D-71). */
|
|
90
|
+
export async function beforeFind(
|
|
91
|
+
models: Models,
|
|
92
|
+
model: ModelInfo,
|
|
93
|
+
caller: Caller,
|
|
94
|
+
operation: FindOperation,
|
|
95
|
+
relations: Relations,
|
|
96
|
+
): Promise<Filter | undefined> {
|
|
97
|
+
let filter: Filter | undefined
|
|
98
|
+
for (const hook of hooksOf(model, 'beforeFind')) {
|
|
99
|
+
const result = await hook.run({ caller, operation, where: readFilter })
|
|
100
|
+
if (result === undefined) continue
|
|
101
|
+
if (!isReadFilter(result)) {
|
|
102
|
+
throw new TypeError(`${hook.path} returns where({ ... }) or nothing.`)
|
|
103
|
+
}
|
|
104
|
+
// Trusted server code, as a read rule's filter (D-86, D-88); the one
|
|
105
|
+
// filter of a plugin field (D-122).
|
|
106
|
+
const where = normalizeWhere(
|
|
107
|
+
models,
|
|
108
|
+
model,
|
|
109
|
+
filterOf(result),
|
|
110
|
+
hook.path,
|
|
111
|
+
trusted(relations),
|
|
112
|
+
true,
|
|
113
|
+
)
|
|
114
|
+
filter = and(where, filter)
|
|
115
|
+
}
|
|
116
|
+
return filter
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The plugin's `redirect.delete`, which replaces the write of a `delete`,
|
|
121
|
+
* given `where: { id }` of the row found (D-15, D-89).
|
|
122
|
+
*/
|
|
123
|
+
export function redirectOf(
|
|
124
|
+
model: ModelInfo,
|
|
125
|
+
): ((caller: Caller, id: string) => Promise<unknown>) | undefined {
|
|
126
|
+
const use = model.plugins.find((each) => each.code.redirect !== undefined)
|
|
127
|
+
const redirect = use?.code.redirect
|
|
128
|
+
if (use === undefined || redirect === undefined) return undefined
|
|
129
|
+
return async (caller, id) => {
|
|
130
|
+
const facts = { caller, where: { id }, ...use.model }
|
|
131
|
+
const result: unknown = await Reflect.apply(redirect, undefined, [facts])
|
|
132
|
+
return result
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A row a write changed, whose `after*` hooks run once it is written. */
|
|
137
|
+
export type Event =
|
|
138
|
+
| {
|
|
139
|
+
readonly model: ModelInfo
|
|
140
|
+
readonly operation: 'create'
|
|
141
|
+
readonly id: string
|
|
142
|
+
}
|
|
143
|
+
| {
|
|
144
|
+
readonly model: ModelInfo
|
|
145
|
+
readonly operation: 'update' | 'delete'
|
|
146
|
+
readonly id: string
|
|
147
|
+
/** The stored row before the write. */
|
|
148
|
+
readonly before: Row
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const names = {
|
|
152
|
+
create: ['afterCreate', 'afterCreateCommit'],
|
|
153
|
+
update: ['afterUpdate', 'afterUpdateCommit'],
|
|
154
|
+
delete: ['afterDelete', 'afterDeleteCommit'],
|
|
155
|
+
} as const
|
|
156
|
+
|
|
157
|
+
/** Whether an `after*` hook may change the rows, so the result is read again. */
|
|
158
|
+
export function rereads(events: readonly Event[]): boolean {
|
|
159
|
+
return events.some((event) => hasHook(event.model, names[event.operation][0]))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Whether a hook follows the write, which then runs in a transaction. */
|
|
163
|
+
export function settles(events: readonly Event[]): boolean {
|
|
164
|
+
return events.some((event) =>
|
|
165
|
+
names[event.operation].some((name) => hasHook(event.model, name)),
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function factsOf(
|
|
170
|
+
store: Store,
|
|
171
|
+
caller: Caller,
|
|
172
|
+
event: Event,
|
|
173
|
+
): Promise<object> {
|
|
174
|
+
const { model, id } = event
|
|
175
|
+
if (event.operation === 'delete') {
|
|
176
|
+
return { caller, record: recordOf(model, event.before) }
|
|
177
|
+
}
|
|
178
|
+
// As written, whatever `beforeFind` hides (D-88).
|
|
179
|
+
const [row] = await store.find({
|
|
180
|
+
model: model.name,
|
|
181
|
+
where: { id: { equals: id } },
|
|
182
|
+
take: 1,
|
|
183
|
+
})
|
|
184
|
+
if (row === undefined) {
|
|
185
|
+
throw new Error(`${model.name} ${id} was deleted before its after hooks.`)
|
|
186
|
+
}
|
|
187
|
+
const record = recordOf(model, row)
|
|
188
|
+
if (event.operation === 'create') return { caller, record }
|
|
189
|
+
return { caller, record, previous: recordOf(model, event.before) }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Runs each event's `after*` hook in the transaction, and queues its
|
|
194
|
+
* `*Commit` hook, with the same facts, for after the commit (D-15, D-71).
|
|
195
|
+
*/
|
|
196
|
+
export async function settle(
|
|
197
|
+
store: Store,
|
|
198
|
+
caller: Caller,
|
|
199
|
+
events: readonly Event[],
|
|
200
|
+
commits: Commit[],
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
for (const event of events) {
|
|
203
|
+
const [now, later] = names[event.operation]
|
|
204
|
+
const afters = hooksOf(event.model, now)
|
|
205
|
+
const laters = hooksOf(event.model, later)
|
|
206
|
+
if (afters.length === 0 && laters.length === 0) continue
|
|
207
|
+
const facts = await factsOf(store, caller, event)
|
|
208
|
+
for (const hook of afters) await hook.run(facts)
|
|
209
|
+
for (const hook of laters) {
|
|
210
|
+
commits.push(async () => {
|
|
211
|
+
await hook.run(facts)
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/hosts.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Which hosts are this machine's own, for the checks that trust them.
|
|
2
|
+
|
|
3
|
+
/** A host only this machine reaches (D-66, D-105, D-115). */
|
|
4
|
+
export function isLocalHost(hostname: string): boolean {
|
|
5
|
+
return (
|
|
6
|
+
hostname === 'localhost' ||
|
|
7
|
+
hostname.endsWith('.localhost') ||
|
|
8
|
+
hostname === '127.0.0.1' ||
|
|
9
|
+
hostname === '[::1]'
|
|
10
|
+
)
|
|
11
|
+
}
|
package/src/identify.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// How a transport finds who sends a request (D-49, D-106, D-112): Master with
|
|
2
|
+
// the master key, then the first plugin that knows it, anonymous otherwise.
|
|
3
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
4
|
+
import type { Logger, MasterKey } from './app-options.ts'
|
|
5
|
+
import { PublicError, Refused } from './errors.ts'
|
|
6
|
+
import type { Complete } from './complete.ts'
|
|
7
|
+
import type { Caller, Identity } from './rules.ts'
|
|
8
|
+
import { createMaster } from './rules.ts'
|
|
9
|
+
|
|
10
|
+
/** The header a transport reads the master key from. */
|
|
11
|
+
export const masterKeyHeader = 'x-ultimabase-master-key'
|
|
12
|
+
|
|
13
|
+
export type Identify = (request: Request) => Promise<Caller>
|
|
14
|
+
/** A plugin's `identify`, bound to its context. */
|
|
15
|
+
export type PluginIdentify = (request: Request) => Promise<Identity | undefined>
|
|
16
|
+
|
|
17
|
+
const anonymous: Caller = Object.freeze({ kind: 'anonymous' })
|
|
18
|
+
const keyMaster = createMaster('key')
|
|
19
|
+
|
|
20
|
+
// Digests have one length, so the comparison takes the same time for any key.
|
|
21
|
+
const digest = (text: string): Buffer =>
|
|
22
|
+
createHash('sha256').update(text).digest()
|
|
23
|
+
|
|
24
|
+
/** Why a request sending a key is refused before it is compared, if so. */
|
|
25
|
+
function refusalOf(masterKey: MasterKey): string | undefined {
|
|
26
|
+
const key = typeof masterKey === 'object' ? masterKey.key : masterKey
|
|
27
|
+
// An empty variable is unset: an empty header must not open Master.
|
|
28
|
+
if (key === undefined || key === '') return 'No master key is configured.'
|
|
29
|
+
if (
|
|
30
|
+
process.env['NODE_ENV'] === 'production' &&
|
|
31
|
+
typeof masterKey !== 'object'
|
|
32
|
+
) {
|
|
33
|
+
return 'The master key is refused in production.'
|
|
34
|
+
}
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reads the master key and `NODE_ENV` once, when the application starts.
|
|
40
|
+
* `plugins` is read on each request: it is filled once their contexts exist.
|
|
41
|
+
* The caller a plugin knows is completed (D-121).
|
|
42
|
+
*/
|
|
43
|
+
export function identifier(
|
|
44
|
+
masterKey: MasterKey,
|
|
45
|
+
logger: Logger,
|
|
46
|
+
plugins: readonly PluginIdentify[],
|
|
47
|
+
complete: Complete,
|
|
48
|
+
): Identify {
|
|
49
|
+
const refusal = refusalOf(masterKey)
|
|
50
|
+
const key = typeof masterKey === 'object' ? masterKey.key : masterKey
|
|
51
|
+
// A header drops the spaces around a value and mangles what is not ASCII:
|
|
52
|
+
// such a key could never match.
|
|
53
|
+
if (key !== undefined && key !== '' && !/^[\x21-\x7e]+$/.test(key)) {
|
|
54
|
+
throw new Refused(
|
|
55
|
+
'The master key holds a space or a character outside ASCII, which a header cannot carry: use letters, digits and symbols only.',
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
const expected = digest(key ?? '')
|
|
59
|
+
// Outside production, the HTTP plugin's development log already shows
|
|
60
|
+
// each request (D-108); a use of the key is an audit line in production.
|
|
61
|
+
const audited = process.env['NODE_ENV'] === 'production'
|
|
62
|
+
// Nothing limits the keys a client tries: one that opens Master in
|
|
63
|
+
// production must not be guessed.
|
|
64
|
+
if (
|
|
65
|
+
audited &&
|
|
66
|
+
refusal === undefined &&
|
|
67
|
+
key !== undefined &&
|
|
68
|
+
key.length < 32
|
|
69
|
+
) {
|
|
70
|
+
throw new Refused(
|
|
71
|
+
`The master key is ${key.length} characters long; production needs 32 at least: \`openssl rand -base64 32\` gives one.`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
return async (request) => {
|
|
75
|
+
const sent = request.headers.get(masterKeyHeader)
|
|
76
|
+
if (sent === null) {
|
|
77
|
+
for (const identify of plugins) {
|
|
78
|
+
const caller = await identify(request)
|
|
79
|
+
if (caller !== undefined) return complete(caller)
|
|
80
|
+
}
|
|
81
|
+
return anonymous
|
|
82
|
+
}
|
|
83
|
+
const refused =
|
|
84
|
+
refusal ??
|
|
85
|
+
(timingSafeEqual(digest(sent), expected)
|
|
86
|
+
? undefined
|
|
87
|
+
: 'The master key is wrong.')
|
|
88
|
+
if (refused !== undefined) {
|
|
89
|
+
logger.warn(`A request sent the master key, refused: ${refused}`)
|
|
90
|
+
throw new PublicError('ACCESS_DENIED', refused)
|
|
91
|
+
}
|
|
92
|
+
if (audited) logger.info('A request acts as Master with the master key.')
|
|
93
|
+
return keyMaster
|
|
94
|
+
}
|
|
95
|
+
}
|