@bjornpagen/bumbledb 1.3.1 → 2.0.0-rc.1
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/COOKBOOK.md +153 -77
- package/README.md +107 -41
- package/dist/alternatives.d.ts +5 -6
- package/dist/alternatives.d.ts.map +1 -1
- package/dist/alternatives.js +5 -6
- package/dist/alternatives.js.map +1 -1
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +85 -0
- package/dist/bin.js.map +1 -0
- package/dist/capacity.d.ts +14 -17
- package/dist/capacity.d.ts.map +1 -1
- package/dist/capacity.js +7 -7
- package/dist/capacity.js.map +1 -1
- package/dist/changes.d.ts +32 -40
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +86 -104
- package/dist/changes.js.map +1 -1
- package/dist/closed.d.ts +2 -2
- package/dist/closed.d.ts.map +1 -1
- package/dist/closed.js +5 -5
- package/dist/closed.js.map +1 -1
- package/dist/codec.d.ts +13 -27
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +20 -21
- package/dist/codec.js.map +1 -1
- package/dist/compile.d.ts +26 -50
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +75 -64
- package/dist/compile.js.map +1 -1
- package/dist/database/database.d.ts +98 -0
- package/dist/database/database.d.ts.map +1 -0
- package/dist/database/database.js +249 -0
- package/dist/database/database.js.map +1 -0
- package/dist/database/driver.d.ts +40 -0
- package/dist/database/driver.d.ts.map +1 -0
- package/dist/database/driver.js +68 -0
- package/dist/database/driver.js.map +1 -0
- package/dist/database/fs.d.ts +12 -0
- package/dist/database/fs.d.ts.map +1 -0
- package/dist/database/fs.js +120 -0
- package/dist/database/fs.js.map +1 -0
- package/dist/database/generate.d.ts +26 -0
- package/dist/database/generate.d.ts.map +1 -0
- package/dist/database/generate.js +102 -0
- package/dist/database/generate.js.map +1 -0
- package/dist/database/hosted.d.ts +32 -0
- package/dist/database/hosted.d.ts.map +1 -0
- package/dist/database/hosted.js +44 -0
- package/dist/database/hosted.js.map +1 -0
- package/dist/database/io.d.ts +94 -0
- package/dist/database/io.d.ts.map +1 -0
- package/dist/database/io.js +35 -0
- package/dist/database/io.js.map +1 -0
- package/dist/database/mem.d.ts +26 -0
- package/dist/database/mem.d.ts.map +1 -0
- package/dist/database/mem.js +67 -0
- package/dist/database/mem.js.map +1 -0
- package/dist/database/migration.d.ts +55 -0
- package/dist/database/migration.d.ts.map +1 -0
- package/dist/database/migration.js +46 -0
- package/dist/database/migration.js.map +1 -0
- package/dist/database/s3.d.ts +36 -0
- package/dist/database/s3.d.ts.map +1 -0
- package/dist/database/s3.js +141 -0
- package/dist/database/s3.js.map +1 -0
- package/dist/db.d.ts +44 -128
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +163 -287
- package/dist/db.js.map +1 -1
- package/dist/engine.d.ts +7 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +2 -0
- package/dist/engine.js.map +1 -0
- package/dist/errors.d.ts +152 -46
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +107 -19
- package/dist/errors.js.map +1 -1
- package/dist/face.d.ts +5 -5
- package/dist/face.d.ts.map +1 -1
- package/dist/face.js +6 -6
- package/dist/face.js.map +1 -1
- package/dist/fields.d.ts +4 -5
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +3 -3
- package/dist/fields.js.map +1 -1
- package/dist/index.d.ts +60 -57
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -28
- package/dist/index.js.map +1 -1
- package/dist/judgment.d.ts +1 -1
- package/dist/judgment.js +1 -1
- package/dist/law.d.ts +16 -68
- package/dist/law.d.ts.map +1 -1
- package/dist/law.js +3 -3
- package/dist/law.js.map +1 -1
- package/dist/lower.d.ts +3 -3
- package/dist/lower.d.ts.map +1 -1
- package/dist/lower.js +35 -39
- package/dist/lower.js.map +1 -1
- package/dist/native/addon.d.ts +23 -0
- package/dist/native/addon.d.ts.map +1 -0
- package/dist/native/addon.js +15 -0
- package/dist/native/addon.js.map +1 -0
- package/dist/native/binding.d.ts +996 -0
- package/dist/native/json.d.ts +14 -0
- package/dist/native/json.d.ts.map +1 -0
- package/dist/native/json.js +73 -0
- package/dist/native/json.js.map +1 -0
- package/dist/native/load.d.ts +9 -0
- package/dist/native/load.d.ts.map +1 -0
- package/dist/native/load.js +30 -0
- package/dist/native/load.js.map +1 -0
- package/dist/native/op.d.ts +29 -0
- package/dist/native/op.d.ts.map +1 -0
- package/dist/native/op.js +50 -0
- package/dist/native/op.js.map +1 -0
- package/dist/query/atom.d.ts +13 -14
- package/dist/query/atom.d.ts.map +1 -1
- package/dist/query/atom.js +2 -2
- package/dist/query/atom.js.map +1 -1
- package/dist/query/compute.d.ts +4 -4
- package/dist/query/compute.d.ts.map +1 -1
- package/dist/query/compute.js +4 -4
- package/dist/query/compute.js.map +1 -1
- package/dist/query/find.d.ts +8 -8
- package/dist/query/find.d.ts.map +1 -1
- package/dist/query/find.js +1 -1
- package/dist/query/lower.d.ts +22 -17
- package/dist/query/lower.d.ts.map +1 -1
- package/dist/query/lower.js +230 -343
- package/dist/query/lower.js.map +1 -1
- package/dist/query/run.d.ts +5 -5
- package/dist/query/run.d.ts.map +1 -1
- package/dist/query/run.js +32 -15
- package/dist/query/run.js.map +1 -1
- package/dist/query/scope.d.ts +20 -28
- package/dist/query/scope.d.ts.map +1 -1
- package/dist/query/scope.js +7 -7
- package/dist/query/scope.js.map +1 -1
- package/dist/query/segments.d.ts +2 -2
- package/dist/query/segments.d.ts.map +1 -1
- package/dist/query/segments.js +3 -3
- package/dist/query/segments.js.map +1 -1
- package/dist/relation.d.ts +1 -1
- package/dist/relation.d.ts.map +1 -1
- package/dist/relation.js +4 -4
- package/dist/relation.js.map +1 -1
- package/dist/result.d.ts +15 -22
- package/dist/result.d.ts.map +1 -1
- package/dist/result.js +30 -39
- package/dist/result.js.map +1 -1
- package/dist/rows.d.ts +9 -33
- package/dist/rows.d.ts.map +1 -1
- package/dist/rows.js +8 -12
- package/dist/rows.js.map +1 -1
- package/dist/runtime.d.ts +17 -20
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +41 -205
- package/dist/runtime.js.map +1 -1
- package/dist/scalar.d.ts +4 -4
- package/dist/scalar.d.ts.map +1 -1
- package/dist/scalar.js +24 -8
- package/dist/scalar.js.map +1 -1
- package/dist/schema.d.ts +7 -18
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +18 -228
- package/dist/schema.js.map +1 -1
- package/dist/selection.d.ts +4 -4
- package/dist/selection.d.ts.map +1 -1
- package/dist/selection.js +5 -5
- package/dist/selection.js.map +1 -1
- package/dist/shape.d.ts +5 -5
- package/dist/shape.d.ts.map +1 -1
- package/dist/spec.d.ts +7 -79
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/statements.d.ts +3 -3
- package/dist/statements.d.ts.map +1 -1
- package/dist/statements.js +10 -10
- package/dist/statements.js.map +1 -1
- package/dist/uuid.d.ts +1 -1
- package/dist/uuid.d.ts.map +1 -1
- package/dist/uuid.js +1 -1
- package/dist/uuid.js.map +1 -1
- package/dist/values.d.ts +2 -2
- package/dist/values.d.ts.map +1 -1
- package/dist/values.js +2 -2
- package/dist/values.js.map +1 -1
- package/package.json +18 -18
- package/src/alternatives.ts +7 -8
- package/src/bin.ts +82 -0
- package/src/capacity.ts +20 -23
- package/src/changes.ts +134 -191
- package/src/closed.ts +5 -5
- package/src/codec.ts +35 -49
- package/src/compile.ts +91 -113
- package/src/database/database.ts +413 -0
- package/src/database/driver.ts +134 -0
- package/src/database/fs.ts +119 -0
- package/src/database/generate.ts +116 -0
- package/src/database/hosted.ts +102 -0
- package/src/database/io.ts +134 -0
- package/src/database/mem.ts +113 -0
- package/src/database/migration.ts +107 -0
- package/src/database/s3.ts +231 -0
- package/src/db.ts +290 -547
- package/src/engine.ts +14 -0
- package/src/errors.ts +118 -39
- package/src/face.ts +9 -9
- package/src/fields.ts +6 -7
- package/src/index.ts +92 -105
- package/src/judgment.ts +1 -1
- package/src/law.ts +18 -70
- package/src/lower.ts +61 -72
- package/src/native/addon.ts +54 -0
- package/src/native/binding.d.ts +996 -0
- package/src/native/json.ts +100 -0
- package/src/native/load.ts +31 -0
- package/src/native/op.ts +73 -0
- package/src/query/atom.ts +15 -16
- package/src/query/compute.ts +7 -7
- package/src/query/find.ts +8 -8
- package/src/query/lower.ts +270 -392
- package/src/query/run.ts +42 -21
- package/src/query/scope.ts +29 -37
- package/src/query/segments.ts +4 -4
- package/src/relation.ts +4 -4
- package/src/result.ts +53 -80
- package/src/rows.ts +18 -46
- package/src/runtime.ts +50 -277
- package/src/scalar.ts +32 -12
- package/src/schema.ts +23 -284
- package/src/selection.ts +6 -6
- package/src/shape.ts +5 -5
- package/src/spec.ts +7 -85
- package/src/statements.ts +11 -11
- package/src/uuid.ts +1 -1
- package/src/values.ts +4 -4
- package/dist/close.d.ts +0 -24
- package/dist/close.d.ts.map +0 -1
- package/dist/close.js +0 -34
- package/dist/close.js.map +0 -1
- package/dist/db-native.d.ts +0 -189
- package/dist/db-native.d.ts.map +0 -1
- package/dist/db-native.js +0 -13
- package/dist/db-native.js.map +0 -1
- package/dist/internal/log.d.ts +0 -26
- package/dist/internal/log.d.ts.map +0 -1
- package/dist/internal/log.js +0 -12
- package/dist/internal/log.js.map +0 -1
- package/dist/native.d.ts +0 -562
- package/dist/native.d.ts.map +0 -1
- package/dist/native.js +0 -123
- package/dist/native.js.map +0 -1
- package/dist/query/description.d.ts +0 -41
- package/dist/query/description.d.ts.map +0 -1
- package/dist/query/description.js +0 -311
- package/dist/query/description.js.map +0 -1
- package/dist/query/parse-ir.d.ts +0 -10
- package/dist/query/parse-ir.d.ts.map +0 -1
- package/dist/query/parse-ir.js +0 -280
- package/dist/query/parse-ir.js.map +0 -1
- package/dist/runtime-codes.d.ts +0 -2
- package/dist/runtime-codes.d.ts.map +0 -1
- package/dist/runtime-codes.js +0 -19
- package/dist/runtime-codes.js.map +0 -1
- package/dist/runtime-errors.d.ts +0 -138
- package/dist/runtime-errors.d.ts.map +0 -1
- package/dist/runtime-errors.js +0 -111
- package/dist/runtime-errors.js.map +0 -1
- package/dist/runtime-native.d.ts +0 -136
- package/dist/runtime-native.d.ts.map +0 -1
- package/dist/runtime-native.js +0 -5
- package/dist/runtime-native.js.map +0 -1
- package/dist/schema-file.d.ts +0 -7
- package/dist/schema-file.d.ts.map +0 -1
- package/dist/schema-file.js +0 -24
- package/dist/schema-file.js.map +0 -1
- package/pack-provenance.json +0 -7
- package/src/close.ts +0 -53
- package/src/db-native.ts +0 -235
- package/src/internal/log.ts +0 -46
- package/src/native.ts +0 -736
- package/src/query/description.ts +0 -424
- package/src/query/parse-ir.ts +0 -293
- package/src/runtime-codes.ts +0 -18
- package/src/runtime-errors.ts +0 -122
- package/src/runtime-native.ts +0 -181
- package/src/schema-file.ts +0 -38
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { DbError } from "../errors.ts"
|
|
4
|
+
import type { Body, Bucket, Created, Deleted, Fetched, Listed, Millis, ObjectStore, Reply, Target } from "./io.ts"
|
|
5
|
+
|
|
6
|
+
type Verb = "get" | "putIfAbsent" | "list" | "delete"
|
|
7
|
+
|
|
8
|
+
/** `Fail`: the call fails before it has any effect. `Lose`: the call takes effect, then fails, like a lost response. */
|
|
9
|
+
type Fault = "Fail" | "Lose"
|
|
10
|
+
|
|
11
|
+
interface MemStoreOptions {
|
|
12
|
+
/** The store clock behind `Date` and `Last-Modified`. Defaults to a counter that ticks once per call. */
|
|
13
|
+
readonly clock?: () => Millis
|
|
14
|
+
/** Picks the fault, if any, for each call. */
|
|
15
|
+
readonly fault?: (call: { readonly verb: Verb; readonly bucket: Bucket; readonly key: string }) => Fault | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Stored {
|
|
19
|
+
readonly bytes: Uint8Array
|
|
20
|
+
readonly lastModified: Millis
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** An in-memory store with the create-only semantics of S3, and deterministic fault injection for tests. */
|
|
24
|
+
interface MemStore extends ObjectStore {
|
|
25
|
+
/** A copy of the objects currently in `bucket`, by key. */
|
|
26
|
+
readonly objects: (bucket: Bucket) => ReadonlyMap<string, Uint8Array>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function failure(operation: string): DbError {
|
|
30
|
+
return new DbError({ operation, reason: { _tag: "Io", kind: "Injected" } })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function make(options: MemStoreOptions = {}): MemStore {
|
|
34
|
+
let ticks = 0n
|
|
35
|
+
const clock =
|
|
36
|
+
options.clock ??
|
|
37
|
+
(() => {
|
|
38
|
+
ticks += 1n
|
|
39
|
+
return ticks
|
|
40
|
+
})
|
|
41
|
+
const buckets: Record<Bucket, Map<string, Stored>> = { Log: new Map(), Checkpoints: new Map() }
|
|
42
|
+
|
|
43
|
+
function run<R>(
|
|
44
|
+
verb: Verb,
|
|
45
|
+
bucket: Bucket,
|
|
46
|
+
key: string,
|
|
47
|
+
body: (now: Millis) => Effect.Effect<R, DbError>
|
|
48
|
+
): Effect.Effect<Reply<R>, DbError> {
|
|
49
|
+
return Effect.suspend(() => {
|
|
50
|
+
const fault = options.fault?.({ verb, bucket, key })
|
|
51
|
+
const operation = `MemStore.${verb}`
|
|
52
|
+
if (fault === "Fail") return Effect.fail(failure(operation))
|
|
53
|
+
const now = clock()
|
|
54
|
+
return Effect.flatMap(body(now), (result) =>
|
|
55
|
+
fault === "Lose" ? Effect.fail(failure(operation)) : Effect.succeed({ date: now, result })
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const io = <A>(operation: string, f: () => Promise<A>) =>
|
|
61
|
+
Effect.tryPromise({
|
|
62
|
+
try: f,
|
|
63
|
+
catch: () => new DbError({ operation, reason: { _tag: "Io", kind: "File" } })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
get: (bucket: Bucket, key: string, target: Target) =>
|
|
68
|
+
run("get", bucket, key, (): Effect.Effect<Fetched, DbError> => {
|
|
69
|
+
const stored = buckets[bucket].get(key)
|
|
70
|
+
if (stored === undefined) return Effect.succeed({ _tag: "Missing" })
|
|
71
|
+
if (target._tag === "Memory") {
|
|
72
|
+
return Effect.succeed({ _tag: "Body", bytes: stored.bytes.slice(), lastModified: stored.lastModified })
|
|
73
|
+
}
|
|
74
|
+
return io("MemStore.get", () => fs.writeFile(target.path, stored.bytes)).pipe(
|
|
75
|
+
Effect.as<Fetched>({ _tag: "Saved", lastModified: stored.lastModified })
|
|
76
|
+
)
|
|
77
|
+
}),
|
|
78
|
+
putIfAbsent: (bucket: Bucket, key: string, body: Body) =>
|
|
79
|
+
run("putIfAbsent", bucket, key, (now): Effect.Effect<Created, DbError> => {
|
|
80
|
+
const bytes =
|
|
81
|
+
body._tag === "Bytes"
|
|
82
|
+
? Effect.succeed(body.bytes.slice())
|
|
83
|
+
: io("MemStore.putIfAbsent", () => fs.readFile(body.path)).pipe(
|
|
84
|
+
Effect.map((buffer) => new Uint8Array(buffer))
|
|
85
|
+
)
|
|
86
|
+
return Effect.map(bytes, (owned): Created => {
|
|
87
|
+
const objects = buckets[bucket]
|
|
88
|
+
if (objects.has(key)) return { _tag: "Occupied" }
|
|
89
|
+
objects.set(key, { bytes: owned, lastModified: now })
|
|
90
|
+
return { _tag: "Created" }
|
|
91
|
+
})
|
|
92
|
+
}),
|
|
93
|
+
list: (prefix: string, startAfter: string | null, maxKeys: number) =>
|
|
94
|
+
run("list", "Checkpoints", prefix, (): Effect.Effect<Listed> => {
|
|
95
|
+
const keys = [...buckets.Checkpoints.keys()]
|
|
96
|
+
.filter((key) => key.startsWith(prefix) && (startAfter === null || key > startAfter))
|
|
97
|
+
.sort()
|
|
98
|
+
.slice(0, maxKeys)
|
|
99
|
+
return Effect.succeed({ _tag: "Keys", keys })
|
|
100
|
+
}),
|
|
101
|
+
delete: (key: string) =>
|
|
102
|
+
run("delete", "Checkpoints", key, (): Effect.Effect<Deleted> => {
|
|
103
|
+
buckets.Checkpoints.delete(key)
|
|
104
|
+
return Effect.succeed({ _tag: "Deleted" })
|
|
105
|
+
}),
|
|
106
|
+
objects: (bucket: Bucket) => new Map([...buckets[bucket]].map(([key, stored]) => [key, stored.bytes.slice()]))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const MemStore = Object.freeze({ make })
|
|
111
|
+
|
|
112
|
+
export type { Fault, MemStoreOptions }
|
|
113
|
+
export { MemStore }
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Effect, Scope } from "effect"
|
|
2
|
+
import type { ChangeDraft } from "../changes.ts"
|
|
3
|
+
import type { QueryReader } from "../db.ts"
|
|
4
|
+
import type { DbError } from "../errors.ts"
|
|
5
|
+
import { AuthoringError } from "../errors.ts"
|
|
6
|
+
import type { AnySchema } from "../schema.ts"
|
|
7
|
+
import { schemaDescriptor, schemasAgree } from "../schema.ts"
|
|
8
|
+
|
|
9
|
+
/** What `populate` reads from (the database at the old schema) and writes into (rows at the new one). */
|
|
10
|
+
interface PopulateContext<From extends AnySchema, To extends AnySchema> {
|
|
11
|
+
readonly from: QueryReader<From>
|
|
12
|
+
readonly into: ChangeDraft<To>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Computes the rows a migration adds at its new schema, reading the database at the old one.
|
|
17
|
+
* Relations that keep their name and fields are copied unchanged without code.
|
|
18
|
+
*/
|
|
19
|
+
type Populate<From extends AnySchema, To extends AnySchema> = (
|
|
20
|
+
context: PopulateContext<From, To>
|
|
21
|
+
) => Effect.Effect<void, DbError, Scope.Scope>
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One bundled migration: its stable `id` (the directory name), the content `hash` that
|
|
25
|
+
* `bumbledb generate` wrote (64 hex digits), and the schema it migrates to. The first migration of a
|
|
26
|
+
* bundle has no `from`: it is the initial schema, and its `populate` seeds a newly created database
|
|
27
|
+
* once.
|
|
28
|
+
*/
|
|
29
|
+
interface Migration<From extends AnySchema | undefined = AnySchema | undefined, To extends AnySchema = AnySchema> {
|
|
30
|
+
readonly id: string
|
|
31
|
+
readonly hash: string
|
|
32
|
+
readonly from: From
|
|
33
|
+
readonly to: To
|
|
34
|
+
readonly populate?: Populate<From extends AnySchema ? From : To, To>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Any migration of a bundle; its `populate` is read back through {@link populateOf}. */
|
|
38
|
+
interface AnyMigration {
|
|
39
|
+
readonly id: string
|
|
40
|
+
readonly hash: string
|
|
41
|
+
readonly from: AnySchema | undefined
|
|
42
|
+
readonly to: AnySchema
|
|
43
|
+
readonly populate?: unknown
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A bundle: the initial migration first, then each migration from its predecessor's schema. */
|
|
47
|
+
type Migrations = readonly [AnyMigration, ...AnyMigration[]]
|
|
48
|
+
|
|
49
|
+
const HASH = /^[0-9a-f]{64}$/
|
|
50
|
+
const ID = /^[0-9]{4}_[A-Za-z0-9_]+$/
|
|
51
|
+
|
|
52
|
+
function make<To extends AnySchema, From extends AnySchema = never>(input: {
|
|
53
|
+
readonly id: string
|
|
54
|
+
readonly hash: string
|
|
55
|
+
readonly from?: From
|
|
56
|
+
readonly to: To
|
|
57
|
+
readonly populate?: Populate<[From] extends [never] ? To : From, To>
|
|
58
|
+
}): Migration<[From] extends [never] ? undefined : From, To>
|
|
59
|
+
function make(input: {
|
|
60
|
+
readonly id: string
|
|
61
|
+
readonly hash: string
|
|
62
|
+
readonly from?: AnySchema
|
|
63
|
+
readonly to: AnySchema
|
|
64
|
+
readonly populate?: Populate<AnySchema, AnySchema>
|
|
65
|
+
}): AnyMigration {
|
|
66
|
+
if (!ID.test(input.id)) throw new AuthoringError({ message: `migration ${input.id}: an id is NNNN_name` })
|
|
67
|
+
if (!HASH.test(input.hash)) throw new AuthoringError({ message: `migration ${input.id}: a hash is 64 hex digits` })
|
|
68
|
+
const migration = {
|
|
69
|
+
id: input.id,
|
|
70
|
+
hash: input.hash,
|
|
71
|
+
from: input.from === undefined ? undefined : schemaDescriptor(input.from),
|
|
72
|
+
to: schemaDescriptor(input.to),
|
|
73
|
+
...(input.populate === undefined ? {} : { populate: input.populate })
|
|
74
|
+
}
|
|
75
|
+
return Object.freeze(migration)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The populate step of a migration `make` built. */
|
|
79
|
+
function populateOf(migration: AnyMigration): Populate<AnySchema, AnySchema> | undefined {
|
|
80
|
+
return migration.populate as Populate<AnySchema, AnySchema> | undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Checks that each migration starts from its predecessor's schema and that ids are unique. */
|
|
84
|
+
function verifyBundle(migrations: Migrations): void {
|
|
85
|
+
const ids = new Set<string>()
|
|
86
|
+
migrations.forEach((migration, index) => {
|
|
87
|
+
if (ids.has(migration.id)) throw new AuthoringError({ message: `migrations: ${migration.id} appears twice` })
|
|
88
|
+
ids.add(migration.id)
|
|
89
|
+
const previous = migrations[index - 1]
|
|
90
|
+
if (previous === undefined) {
|
|
91
|
+
if (migration.from !== undefined) {
|
|
92
|
+
throw new AuthoringError({ message: `migrations: the first migration ${migration.id} has no from schema` })
|
|
93
|
+
}
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (migration.from === undefined || !schemasAgree(migration.from, previous.to)) {
|
|
97
|
+
throw new AuthoringError({
|
|
98
|
+
message: `migrations: ${migration.id} must start from the schema ${previous.id} migrates to`
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const Migration = Object.freeze({ make })
|
|
105
|
+
|
|
106
|
+
export type { AnyMigration, Migrations, Populate, PopulateContext }
|
|
107
|
+
export { Migration, populateOf, verifyBundle }
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from "node:fs"
|
|
2
|
+
import * as fs from "node:fs/promises"
|
|
3
|
+
import type { Readable } from "node:stream"
|
|
4
|
+
import { pipeline } from "node:stream/promises"
|
|
5
|
+
import { Effect } from "effect"
|
|
6
|
+
import { DbError } from "../errors.ts"
|
|
7
|
+
import type { Body, Bucket, Created, Deleted, Fetched, Listed, Millis, ObjectStore, Reply, Target } from "./io.ts"
|
|
8
|
+
|
|
9
|
+
type Sdk = typeof import("@aws-sdk/client-s3")
|
|
10
|
+
|
|
11
|
+
let sdk: Promise<Sdk> | undefined
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The part of an AWS SDK v3 `S3Client` the store calls. Declared here so the package's types do not
|
|
15
|
+
* require the optional `@aws-sdk/client-s3` peer.
|
|
16
|
+
*/
|
|
17
|
+
interface S3Sender {
|
|
18
|
+
send(command: never, options: { readonly abortSignal: AbortSignal }): Promise<unknown>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface S3StoreOptions {
|
|
22
|
+
/**
|
|
23
|
+
* The application's own `S3Client`: it owns credentials, region, endpoint and SDK retries. Its
|
|
24
|
+
* connections resolve DNS on libuv's thread pool, 4 threads by default, which serializes a cold
|
|
25
|
+
* open's parallel reads where lookups are slow.
|
|
26
|
+
*/
|
|
27
|
+
readonly client: S3Sender
|
|
28
|
+
/** The commit log bucket, ideally an S3 Express directory bucket (`name--azid--x-s3`). */
|
|
29
|
+
readonly log: { readonly bucket: string }
|
|
30
|
+
/** The checkpoint bucket: an S3 Standard bucket, whose LIST is lexicographic. */
|
|
31
|
+
readonly checkpoints: { readonly bucket: string }
|
|
32
|
+
/** Prepended to every key; empty, or ending in `/`. */
|
|
33
|
+
readonly prefix: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Answer<O> {
|
|
37
|
+
readonly output: O
|
|
38
|
+
readonly date: Millis | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Refusal {
|
|
42
|
+
readonly status: number | undefined
|
|
43
|
+
readonly date: Millis | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function dateOf(headers: unknown): Millis | null {
|
|
47
|
+
if (typeof headers !== "object" || headers === null || !("date" in headers) || typeof headers.date !== "string") {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
const millis = Date.parse(headers.date)
|
|
51
|
+
return Number.isNaN(millis) ? null : BigInt(millis)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function refusalOf(cause: unknown): Refusal {
|
|
55
|
+
if (typeof cause !== "object" || cause === null) return { status: undefined, date: null }
|
|
56
|
+
const metadata = "$metadata" in cause ? (cause.$metadata as { httpStatusCode?: number }) : undefined
|
|
57
|
+
const response = "$response" in cause ? (cause.$response as { headers?: unknown } | undefined) : undefined
|
|
58
|
+
return { status: metadata?.httpStatusCode, date: dateOf(response?.headers) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sends one command and reads the response's `Date` header, which the SDK does not surface on
|
|
63
|
+
* outputs. The middleware sits innermost in the deserialize step, so it sees every raw response.
|
|
64
|
+
*/
|
|
65
|
+
async function send<O>(
|
|
66
|
+
client: S3Sender,
|
|
67
|
+
command: { middlewareStack: { add: (...args: never[]) => void } },
|
|
68
|
+
signal: AbortSignal
|
|
69
|
+
): Promise<Answer<O>> {
|
|
70
|
+
let date: Millis | null = null
|
|
71
|
+
const capture =
|
|
72
|
+
(next: (args: unknown) => Promise<{ response: unknown }>) =>
|
|
73
|
+
async (args: unknown): Promise<{ response: unknown }> => {
|
|
74
|
+
const result = await next(args)
|
|
75
|
+
const response = result.response as { headers?: unknown } | undefined
|
|
76
|
+
date = dateOf(response?.headers) ?? date
|
|
77
|
+
return result
|
|
78
|
+
}
|
|
79
|
+
const add = command.middlewareStack.add as (middleware: unknown, options: object) => void
|
|
80
|
+
add(capture, { step: "deserialize", priority: "low", name: "bumbledbResponseDate" })
|
|
81
|
+
const output = (await client.send(command as never, { abortSignal: signal })) as O
|
|
82
|
+
return { output, date }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* S3 answers a create-only PUT that overlaps another conditional write to its key with 409. The
|
|
87
|
+
* SDK would retry it with backoff into a late 412. A log slot being written is taken for the log's
|
|
88
|
+
* purposes (it reads ownership back itself), so there the 409 is answered at once, as a refusal.
|
|
89
|
+
*/
|
|
90
|
+
const conflict = Symbol("bdb.conflict")
|
|
91
|
+
|
|
92
|
+
function answerConflicts(command: { middlewareStack: { add: (...args: never[]) => void } }): void {
|
|
93
|
+
const add = command.middlewareStack.add as (middleware: unknown, options: object) => void
|
|
94
|
+
add(
|
|
95
|
+
(next: (args: unknown) => Promise<unknown>) => async (args: unknown) => {
|
|
96
|
+
try {
|
|
97
|
+
return await next(args)
|
|
98
|
+
} catch (cause) {
|
|
99
|
+
if (refusalOf(cause).status !== 409) throw cause
|
|
100
|
+
const response = "$response" in (cause as object) ? (cause as { $response?: unknown }).$response : undefined
|
|
101
|
+
return { output: { [conflict]: refusalOf(cause) }, response }
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
{ step: "deserialize", priority: "high", name: "bumbledbConflictAnswer" }
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function millisOf(value: Date | undefined): Millis {
|
|
109
|
+
return BigInt(value?.getTime() ?? 0)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A store over the application's AWS SDK v3 client. Every key lives under `prefix`. */
|
|
113
|
+
function make(options: S3StoreOptions): ObjectStore {
|
|
114
|
+
const { client, prefix } = options
|
|
115
|
+
const bucketName = (bucket: Bucket) => (bucket === "Log" ? options.log.bucket : options.checkpoints.bucket)
|
|
116
|
+
const load = () => {
|
|
117
|
+
sdk ??= import("@aws-sdk/client-s3")
|
|
118
|
+
return sdk
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function attempt<R>(
|
|
122
|
+
operation: string,
|
|
123
|
+
run: (sdk: Sdk, signal: AbortSignal) => Promise<Reply<R>>,
|
|
124
|
+
refused: (refusal: Refusal) => Reply<R> | undefined
|
|
125
|
+
): Effect.Effect<Reply<R>, DbError> {
|
|
126
|
+
return Effect.tryPromise({
|
|
127
|
+
try: async (signal) => {
|
|
128
|
+
const module = await load()
|
|
129
|
+
try {
|
|
130
|
+
return await run(module, signal)
|
|
131
|
+
} catch (cause) {
|
|
132
|
+
const answer = refused(refusalOf(cause))
|
|
133
|
+
if (answer !== undefined) return answer
|
|
134
|
+
throw cause
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
catch: (cause) =>
|
|
138
|
+
new DbError({
|
|
139
|
+
operation,
|
|
140
|
+
reason: { _tag: "Io", kind: refusalOf(cause).status?.toString() ?? "Transport" }
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
get: (bucket: Bucket, key: string, target: Target) =>
|
|
147
|
+
attempt<Fetched>(
|
|
148
|
+
"S3Store.get",
|
|
149
|
+
async ({ GetObjectCommand }, signal) => {
|
|
150
|
+
const answer = await send<{ Body?: unknown; LastModified?: Date }>(
|
|
151
|
+
client,
|
|
152
|
+
new GetObjectCommand({ Bucket: bucketName(bucket), Key: `${prefix}${key}` }),
|
|
153
|
+
signal
|
|
154
|
+
)
|
|
155
|
+
const body = answer.output.Body as (Readable & { transformToByteArray(): Promise<Uint8Array> }) | undefined
|
|
156
|
+
const lastModified = millisOf(answer.output.LastModified)
|
|
157
|
+
if (body === undefined) throw new Error("GetObject returned no body")
|
|
158
|
+
if (target._tag === "Memory") {
|
|
159
|
+
return {
|
|
160
|
+
date: answer.date,
|
|
161
|
+
result: { _tag: "Body", bytes: await body.transformToByteArray(), lastModified }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
await pipeline(body, createWriteStream(target.path))
|
|
165
|
+
return { date: answer.date, result: { _tag: "Saved", lastModified } }
|
|
166
|
+
},
|
|
167
|
+
(refusal) => (refusal.status === 404 ? { date: refusal.date, result: { _tag: "Missing" } } : undefined)
|
|
168
|
+
),
|
|
169
|
+
putIfAbsent: (bucket: Bucket, key: string, body: Body) =>
|
|
170
|
+
attempt<Created>(
|
|
171
|
+
"S3Store.putIfAbsent",
|
|
172
|
+
async ({ PutObjectCommand }, signal) => {
|
|
173
|
+
const payload =
|
|
174
|
+
body._tag === "Bytes"
|
|
175
|
+
? { Body: body.bytes, ContentLength: body.bytes.byteLength }
|
|
176
|
+
: { Body: createReadStream(body.path), ContentLength: (await fs.stat(body.path)).size }
|
|
177
|
+
const command = new PutObjectCommand({
|
|
178
|
+
Bucket: bucketName(bucket),
|
|
179
|
+
Key: `${prefix}${key}`,
|
|
180
|
+
IfNoneMatch: "*",
|
|
181
|
+
...payload
|
|
182
|
+
})
|
|
183
|
+
if (bucket === "Log") answerConflicts(command)
|
|
184
|
+
const answer = await send<{ [conflict]?: Refusal }>(client, command, signal)
|
|
185
|
+
const refused = answer.output[conflict]
|
|
186
|
+
if (refused !== undefined) return { date: refused.date ?? answer.date, result: { _tag: "Occupied" } }
|
|
187
|
+
return { date: answer.date, result: { _tag: "Created" } }
|
|
188
|
+
},
|
|
189
|
+
(refusal) => (refusal.status === 412 ? { date: refusal.date, result: { _tag: "Occupied" } } : undefined)
|
|
190
|
+
),
|
|
191
|
+
list: (keyPrefix: string, startAfter: string | null, maxKeys: number) =>
|
|
192
|
+
attempt<Listed>(
|
|
193
|
+
"S3Store.list",
|
|
194
|
+
async ({ ListObjectsV2Command }, signal) => {
|
|
195
|
+
const answer = await send<{ Contents?: ReadonlyArray<{ Key?: string }> }>(
|
|
196
|
+
client,
|
|
197
|
+
new ListObjectsV2Command({
|
|
198
|
+
Bucket: options.checkpoints.bucket,
|
|
199
|
+
Prefix: `${prefix}${keyPrefix}`,
|
|
200
|
+
MaxKeys: maxKeys,
|
|
201
|
+
...(startAfter === null ? {} : { StartAfter: `${prefix}${startAfter}` })
|
|
202
|
+
}),
|
|
203
|
+
signal
|
|
204
|
+
)
|
|
205
|
+
const keys = (answer.output.Contents ?? []).flatMap((object) =>
|
|
206
|
+
object.Key?.startsWith(prefix) === true ? [object.Key.slice(prefix.length)] : []
|
|
207
|
+
)
|
|
208
|
+
return { date: answer.date, result: { _tag: "Keys", keys } }
|
|
209
|
+
},
|
|
210
|
+
() => undefined
|
|
211
|
+
),
|
|
212
|
+
delete: (key: string) =>
|
|
213
|
+
attempt<Deleted>(
|
|
214
|
+
"S3Store.delete",
|
|
215
|
+
async ({ DeleteObjectCommand }, signal) => {
|
|
216
|
+
const answer = await send(
|
|
217
|
+
client,
|
|
218
|
+
new DeleteObjectCommand({ Bucket: options.checkpoints.bucket, Key: `${prefix}${key}` }),
|
|
219
|
+
signal
|
|
220
|
+
)
|
|
221
|
+
return { date: answer.date, result: { _tag: "Deleted" } }
|
|
222
|
+
},
|
|
223
|
+
() => undefined
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const S3Store = Object.freeze({ make })
|
|
229
|
+
|
|
230
|
+
export type { S3Sender, S3StoreOptions }
|
|
231
|
+
export { S3Store }
|