@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.
Files changed (290) hide show
  1. package/COOKBOOK.md +153 -77
  2. package/README.md +107 -41
  3. package/dist/alternatives.d.ts +5 -6
  4. package/dist/alternatives.d.ts.map +1 -1
  5. package/dist/alternatives.js +5 -6
  6. package/dist/alternatives.js.map +1 -1
  7. package/dist/bin.d.ts +3 -0
  8. package/dist/bin.d.ts.map +1 -0
  9. package/dist/bin.js +85 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/capacity.d.ts +14 -17
  12. package/dist/capacity.d.ts.map +1 -1
  13. package/dist/capacity.js +7 -7
  14. package/dist/capacity.js.map +1 -1
  15. package/dist/changes.d.ts +32 -40
  16. package/dist/changes.d.ts.map +1 -1
  17. package/dist/changes.js +86 -104
  18. package/dist/changes.js.map +1 -1
  19. package/dist/closed.d.ts +2 -2
  20. package/dist/closed.d.ts.map +1 -1
  21. package/dist/closed.js +5 -5
  22. package/dist/closed.js.map +1 -1
  23. package/dist/codec.d.ts +13 -27
  24. package/dist/codec.d.ts.map +1 -1
  25. package/dist/codec.js +20 -21
  26. package/dist/codec.js.map +1 -1
  27. package/dist/compile.d.ts +26 -50
  28. package/dist/compile.d.ts.map +1 -1
  29. package/dist/compile.js +75 -64
  30. package/dist/compile.js.map +1 -1
  31. package/dist/database/database.d.ts +98 -0
  32. package/dist/database/database.d.ts.map +1 -0
  33. package/dist/database/database.js +249 -0
  34. package/dist/database/database.js.map +1 -0
  35. package/dist/database/driver.d.ts +40 -0
  36. package/dist/database/driver.d.ts.map +1 -0
  37. package/dist/database/driver.js +68 -0
  38. package/dist/database/driver.js.map +1 -0
  39. package/dist/database/fs.d.ts +12 -0
  40. package/dist/database/fs.d.ts.map +1 -0
  41. package/dist/database/fs.js +120 -0
  42. package/dist/database/fs.js.map +1 -0
  43. package/dist/database/generate.d.ts +26 -0
  44. package/dist/database/generate.d.ts.map +1 -0
  45. package/dist/database/generate.js +102 -0
  46. package/dist/database/generate.js.map +1 -0
  47. package/dist/database/hosted.d.ts +32 -0
  48. package/dist/database/hosted.d.ts.map +1 -0
  49. package/dist/database/hosted.js +44 -0
  50. package/dist/database/hosted.js.map +1 -0
  51. package/dist/database/io.d.ts +94 -0
  52. package/dist/database/io.d.ts.map +1 -0
  53. package/dist/database/io.js +35 -0
  54. package/dist/database/io.js.map +1 -0
  55. package/dist/database/mem.d.ts +26 -0
  56. package/dist/database/mem.d.ts.map +1 -0
  57. package/dist/database/mem.js +67 -0
  58. package/dist/database/mem.js.map +1 -0
  59. package/dist/database/migration.d.ts +55 -0
  60. package/dist/database/migration.d.ts.map +1 -0
  61. package/dist/database/migration.js +46 -0
  62. package/dist/database/migration.js.map +1 -0
  63. package/dist/database/s3.d.ts +36 -0
  64. package/dist/database/s3.d.ts.map +1 -0
  65. package/dist/database/s3.js +141 -0
  66. package/dist/database/s3.js.map +1 -0
  67. package/dist/db.d.ts +44 -128
  68. package/dist/db.d.ts.map +1 -1
  69. package/dist/db.js +163 -287
  70. package/dist/db.js.map +1 -1
  71. package/dist/engine.d.ts +7 -0
  72. package/dist/engine.d.ts.map +1 -0
  73. package/dist/engine.js +2 -0
  74. package/dist/engine.js.map +1 -0
  75. package/dist/errors.d.ts +152 -46
  76. package/dist/errors.d.ts.map +1 -1
  77. package/dist/errors.js +107 -19
  78. package/dist/errors.js.map +1 -1
  79. package/dist/face.d.ts +5 -5
  80. package/dist/face.d.ts.map +1 -1
  81. package/dist/face.js +6 -6
  82. package/dist/face.js.map +1 -1
  83. package/dist/fields.d.ts +4 -5
  84. package/dist/fields.d.ts.map +1 -1
  85. package/dist/fields.js +3 -3
  86. package/dist/fields.js.map +1 -1
  87. package/dist/index.d.ts +60 -57
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +27 -28
  90. package/dist/index.js.map +1 -1
  91. package/dist/judgment.d.ts +1 -1
  92. package/dist/judgment.js +1 -1
  93. package/dist/law.d.ts +16 -68
  94. package/dist/law.d.ts.map +1 -1
  95. package/dist/law.js +3 -3
  96. package/dist/law.js.map +1 -1
  97. package/dist/lower.d.ts +3 -3
  98. package/dist/lower.d.ts.map +1 -1
  99. package/dist/lower.js +35 -39
  100. package/dist/lower.js.map +1 -1
  101. package/dist/native/addon.d.ts +23 -0
  102. package/dist/native/addon.d.ts.map +1 -0
  103. package/dist/native/addon.js +15 -0
  104. package/dist/native/addon.js.map +1 -0
  105. package/dist/native/binding.d.ts +996 -0
  106. package/dist/native/json.d.ts +14 -0
  107. package/dist/native/json.d.ts.map +1 -0
  108. package/dist/native/json.js +73 -0
  109. package/dist/native/json.js.map +1 -0
  110. package/dist/native/load.d.ts +9 -0
  111. package/dist/native/load.d.ts.map +1 -0
  112. package/dist/native/load.js +30 -0
  113. package/dist/native/load.js.map +1 -0
  114. package/dist/native/op.d.ts +29 -0
  115. package/dist/native/op.d.ts.map +1 -0
  116. package/dist/native/op.js +50 -0
  117. package/dist/native/op.js.map +1 -0
  118. package/dist/query/atom.d.ts +13 -14
  119. package/dist/query/atom.d.ts.map +1 -1
  120. package/dist/query/atom.js +2 -2
  121. package/dist/query/atom.js.map +1 -1
  122. package/dist/query/compute.d.ts +4 -4
  123. package/dist/query/compute.d.ts.map +1 -1
  124. package/dist/query/compute.js +4 -4
  125. package/dist/query/compute.js.map +1 -1
  126. package/dist/query/find.d.ts +8 -8
  127. package/dist/query/find.d.ts.map +1 -1
  128. package/dist/query/find.js +1 -1
  129. package/dist/query/lower.d.ts +22 -17
  130. package/dist/query/lower.d.ts.map +1 -1
  131. package/dist/query/lower.js +230 -343
  132. package/dist/query/lower.js.map +1 -1
  133. package/dist/query/run.d.ts +5 -5
  134. package/dist/query/run.d.ts.map +1 -1
  135. package/dist/query/run.js +32 -15
  136. package/dist/query/run.js.map +1 -1
  137. package/dist/query/scope.d.ts +20 -28
  138. package/dist/query/scope.d.ts.map +1 -1
  139. package/dist/query/scope.js +7 -7
  140. package/dist/query/scope.js.map +1 -1
  141. package/dist/query/segments.d.ts +2 -2
  142. package/dist/query/segments.d.ts.map +1 -1
  143. package/dist/query/segments.js +3 -3
  144. package/dist/query/segments.js.map +1 -1
  145. package/dist/relation.d.ts +1 -1
  146. package/dist/relation.d.ts.map +1 -1
  147. package/dist/relation.js +4 -4
  148. package/dist/relation.js.map +1 -1
  149. package/dist/result.d.ts +15 -22
  150. package/dist/result.d.ts.map +1 -1
  151. package/dist/result.js +30 -39
  152. package/dist/result.js.map +1 -1
  153. package/dist/rows.d.ts +9 -33
  154. package/dist/rows.d.ts.map +1 -1
  155. package/dist/rows.js +8 -12
  156. package/dist/rows.js.map +1 -1
  157. package/dist/runtime.d.ts +17 -20
  158. package/dist/runtime.d.ts.map +1 -1
  159. package/dist/runtime.js +41 -205
  160. package/dist/runtime.js.map +1 -1
  161. package/dist/scalar.d.ts +4 -4
  162. package/dist/scalar.d.ts.map +1 -1
  163. package/dist/scalar.js +24 -8
  164. package/dist/scalar.js.map +1 -1
  165. package/dist/schema.d.ts +7 -18
  166. package/dist/schema.d.ts.map +1 -1
  167. package/dist/schema.js +18 -228
  168. package/dist/schema.js.map +1 -1
  169. package/dist/selection.d.ts +4 -4
  170. package/dist/selection.d.ts.map +1 -1
  171. package/dist/selection.js +5 -5
  172. package/dist/selection.js.map +1 -1
  173. package/dist/shape.d.ts +5 -5
  174. package/dist/shape.d.ts.map +1 -1
  175. package/dist/spec.d.ts +7 -79
  176. package/dist/spec.d.ts.map +1 -1
  177. package/dist/spec.js.map +1 -1
  178. package/dist/statements.d.ts +3 -3
  179. package/dist/statements.d.ts.map +1 -1
  180. package/dist/statements.js +10 -10
  181. package/dist/statements.js.map +1 -1
  182. package/dist/uuid.d.ts +1 -1
  183. package/dist/uuid.d.ts.map +1 -1
  184. package/dist/uuid.js +1 -1
  185. package/dist/uuid.js.map +1 -1
  186. package/dist/values.d.ts +2 -2
  187. package/dist/values.d.ts.map +1 -1
  188. package/dist/values.js +2 -2
  189. package/dist/values.js.map +1 -1
  190. package/package.json +18 -18
  191. package/src/alternatives.ts +7 -8
  192. package/src/bin.ts +82 -0
  193. package/src/capacity.ts +20 -23
  194. package/src/changes.ts +134 -191
  195. package/src/closed.ts +5 -5
  196. package/src/codec.ts +35 -49
  197. package/src/compile.ts +91 -113
  198. package/src/database/database.ts +413 -0
  199. package/src/database/driver.ts +134 -0
  200. package/src/database/fs.ts +119 -0
  201. package/src/database/generate.ts +116 -0
  202. package/src/database/hosted.ts +102 -0
  203. package/src/database/io.ts +134 -0
  204. package/src/database/mem.ts +113 -0
  205. package/src/database/migration.ts +107 -0
  206. package/src/database/s3.ts +231 -0
  207. package/src/db.ts +290 -547
  208. package/src/engine.ts +14 -0
  209. package/src/errors.ts +118 -39
  210. package/src/face.ts +9 -9
  211. package/src/fields.ts +6 -7
  212. package/src/index.ts +92 -105
  213. package/src/judgment.ts +1 -1
  214. package/src/law.ts +18 -70
  215. package/src/lower.ts +61 -72
  216. package/src/native/addon.ts +54 -0
  217. package/src/native/binding.d.ts +996 -0
  218. package/src/native/json.ts +100 -0
  219. package/src/native/load.ts +31 -0
  220. package/src/native/op.ts +73 -0
  221. package/src/query/atom.ts +15 -16
  222. package/src/query/compute.ts +7 -7
  223. package/src/query/find.ts +8 -8
  224. package/src/query/lower.ts +270 -392
  225. package/src/query/run.ts +42 -21
  226. package/src/query/scope.ts +29 -37
  227. package/src/query/segments.ts +4 -4
  228. package/src/relation.ts +4 -4
  229. package/src/result.ts +53 -80
  230. package/src/rows.ts +18 -46
  231. package/src/runtime.ts +50 -277
  232. package/src/scalar.ts +32 -12
  233. package/src/schema.ts +23 -284
  234. package/src/selection.ts +6 -6
  235. package/src/shape.ts +5 -5
  236. package/src/spec.ts +7 -85
  237. package/src/statements.ts +11 -11
  238. package/src/uuid.ts +1 -1
  239. package/src/values.ts +4 -4
  240. package/dist/close.d.ts +0 -24
  241. package/dist/close.d.ts.map +0 -1
  242. package/dist/close.js +0 -34
  243. package/dist/close.js.map +0 -1
  244. package/dist/db-native.d.ts +0 -189
  245. package/dist/db-native.d.ts.map +0 -1
  246. package/dist/db-native.js +0 -13
  247. package/dist/db-native.js.map +0 -1
  248. package/dist/internal/log.d.ts +0 -26
  249. package/dist/internal/log.d.ts.map +0 -1
  250. package/dist/internal/log.js +0 -12
  251. package/dist/internal/log.js.map +0 -1
  252. package/dist/native.d.ts +0 -562
  253. package/dist/native.d.ts.map +0 -1
  254. package/dist/native.js +0 -123
  255. package/dist/native.js.map +0 -1
  256. package/dist/query/description.d.ts +0 -41
  257. package/dist/query/description.d.ts.map +0 -1
  258. package/dist/query/description.js +0 -311
  259. package/dist/query/description.js.map +0 -1
  260. package/dist/query/parse-ir.d.ts +0 -10
  261. package/dist/query/parse-ir.d.ts.map +0 -1
  262. package/dist/query/parse-ir.js +0 -280
  263. package/dist/query/parse-ir.js.map +0 -1
  264. package/dist/runtime-codes.d.ts +0 -2
  265. package/dist/runtime-codes.d.ts.map +0 -1
  266. package/dist/runtime-codes.js +0 -19
  267. package/dist/runtime-codes.js.map +0 -1
  268. package/dist/runtime-errors.d.ts +0 -138
  269. package/dist/runtime-errors.d.ts.map +0 -1
  270. package/dist/runtime-errors.js +0 -111
  271. package/dist/runtime-errors.js.map +0 -1
  272. package/dist/runtime-native.d.ts +0 -136
  273. package/dist/runtime-native.d.ts.map +0 -1
  274. package/dist/runtime-native.js +0 -5
  275. package/dist/runtime-native.js.map +0 -1
  276. package/dist/schema-file.d.ts +0 -7
  277. package/dist/schema-file.d.ts.map +0 -1
  278. package/dist/schema-file.js +0 -24
  279. package/dist/schema-file.js.map +0 -1
  280. package/pack-provenance.json +0 -7
  281. package/src/close.ts +0 -53
  282. package/src/db-native.ts +0 -235
  283. package/src/internal/log.ts +0 -46
  284. package/src/native.ts +0 -736
  285. package/src/query/description.ts +0 -424
  286. package/src/query/parse-ir.ts +0 -293
  287. package/src/runtime-codes.ts +0 -18
  288. package/src/runtime-errors.ts +0 -122
  289. package/src/runtime-native.ts +0 -181
  290. 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 }