@workerstack/database 0.21.0 → 0.22.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 (192) hide show
  1. package/README.md +243 -40
  2. package/docs/meta-contract.md +55 -1
  3. package/package.json +3 -7
  4. package/src/cli/args.ts +4 -2
  5. package/src/cli/codegen.ts +2 -2
  6. package/src/cli/comb-config.ts +8 -3
  7. package/src/cli/file-path.ts +25 -0
  8. package/src/cli/migrate-apply.ts +5 -21
  9. package/src/cli/migrate-d1.ts +5 -1
  10. package/src/cli/migrate-json.ts +48 -0
  11. package/src/cli/migrate.ts +26 -4
  12. package/src/codegen/analyzer-types.ts +6 -1
  13. package/src/codegen/annotations.ts +66 -9
  14. package/src/codegen/format-constraints.ts +114 -0
  15. package/src/codegen/generators/dtos/entity-file.ts +2 -1
  16. package/src/codegen/generators/dtos/example-meta.ts +22 -2
  17. package/src/codegen/generators/dtos/field-schema.ts +31 -8
  18. package/src/codegen/generators/dtos/ip-patterns.ts +25 -11
  19. package/src/codegen/generators/dtos/number-range.ts +22 -3
  20. package/src/codegen/generators/dtos/schema-aliases.ts +5 -3
  21. package/src/codegen/generators/dtos/text-format.ts +63 -0
  22. package/src/codegen/generators/dtos/text-schema.ts +49 -2
  23. package/src/codegen/generators/manifest.ts +9 -1
  24. package/src/codegen/runtime/read-comb.ts +71 -3
  25. package/src/codegen/runtime/read-tables.ts +31 -19
  26. package/src/codegen/runtime/snapshot-types.ts +3 -1
  27. package/src/codegen/runtime/to-meta.ts +6 -3
  28. package/src/formats/catalog.ts +255 -0
  29. package/src/formats/iana-time-zone.ts +117 -0
  30. package/src/formats/iso3166-1.ts +78 -0
  31. package/src/formats/iso4217.ts +42 -0
  32. package/src/formats/iso639-1.ts +41 -0
  33. package/src/formats/uts35.ts +143 -0
  34. package/src/index.ts +5 -0
  35. package/src/meta/column.ts +22 -9
  36. package/src/migrate/core/apply.ts +63 -22
  37. package/src/migrate/core/atlas-apply-runs.ts +15 -26
  38. package/src/migrate/core/cleanup.ts +22 -10
  39. package/src/migrate/core/d1-engine-child.mjs +27 -11
  40. package/src/migrate/core/d1-history.ts +23 -26
  41. package/src/migrate/core/d1-wrangler-error.ts +24 -1
  42. package/src/migrate/core/pg-replay-commit.ts +41 -38
  43. package/src/migrate/core/pg-replay-drops.ts +17 -32
  44. package/src/migrate/core/pg-replay-hidden.ts +75 -0
  45. package/src/migrate/core/pg-replay-roles.ts +2 -4
  46. package/src/migrate/core/pg-replay.ts +21 -19
  47. package/src/migrate/core/pg-server-snapshot.ts +4 -24
  48. package/src/migrate/core/proof-readers.ts +80 -0
  49. package/src/migrate/core/proof-run.ts +19 -5
  50. package/src/migrate/core/proof-scope.ts +27 -14
  51. package/src/migrate/core/sql-code.ts +36 -5
  52. package/src/migrate/core/sql-scan.ts +7 -1
  53. package/src/migrate/core/sqlite-keywords.ts +70 -0
  54. package/src/migrate/core/stopped.ts +70 -0
  55. package/src/migrate/core/trigger-write.d.mts +7 -0
  56. package/src/migrate/core/trigger-write.mjs +42 -0
  57. package/src/pg/columns.ts +6 -4
  58. package/src/query/cursor-mint.ts +11 -18
  59. package/src/query/cursor-refusals.ts +22 -11
  60. package/src/query/descriptions.ts +4 -9
  61. package/src/query/filter-parse.ts +80 -22
  62. package/src/query/filter-validate.ts +10 -13
  63. package/src/query/filter.ts +40 -18
  64. package/src/query/list-extras.ts +7 -10
  65. package/src/query/list-schema.ts +0 -2
  66. package/src/query/merged.ts +3 -2
  67. package/src/query/pagination.ts +30 -6
  68. package/src/query/rounded-key.ts +5 -8
  69. package/src/query/schema-params.ts +12 -5
  70. package/src/query/select-validate.ts +7 -4
  71. package/src/query/sqlite/cursor-fit.ts +10 -2
  72. package/src/query/trusted.ts +7 -2
  73. package/src/sqlite/columns.ts +6 -4
  74. package/src/sqlite/constraints.ts +120 -6
  75. package/types/cli/args.d.ts.map +1 -1
  76. package/types/cli/comb-config.d.ts +5 -2
  77. package/types/cli/comb-config.d.ts.map +1 -1
  78. package/types/cli/file-path.d.ts +11 -0
  79. package/types/cli/file-path.d.ts.map +1 -0
  80. package/types/cli/migrate-apply.d.ts +1 -0
  81. package/types/cli/migrate-apply.d.ts.map +1 -1
  82. package/types/cli/migrate-d1.d.ts.map +1 -1
  83. package/types/cli/migrate-json.d.ts +4 -0
  84. package/types/cli/migrate-json.d.ts.map +1 -0
  85. package/types/cli/migrate.d.ts.map +1 -1
  86. package/types/codegen/analyzer-types.d.ts +6 -1
  87. package/types/codegen/analyzer-types.d.ts.map +1 -1
  88. package/types/codegen/annotations.d.ts +12 -7
  89. package/types/codegen/annotations.d.ts.map +1 -1
  90. package/types/codegen/format-constraints.d.ts +22 -0
  91. package/types/codegen/format-constraints.d.ts.map +1 -0
  92. package/types/codegen/generators/dtos/entity-file.d.ts.map +1 -1
  93. package/types/codegen/generators/dtos/example-meta.d.ts.map +1 -1
  94. package/types/codegen/generators/dtos/field-schema.d.ts.map +1 -1
  95. package/types/codegen/generators/dtos/ip-patterns.d.ts +7 -0
  96. package/types/codegen/generators/dtos/ip-patterns.d.ts.map +1 -1
  97. package/types/codegen/generators/dtos/number-range.d.ts +8 -0
  98. package/types/codegen/generators/dtos/number-range.d.ts.map +1 -1
  99. package/types/codegen/generators/dtos/schema-aliases.d.ts +1 -1
  100. package/types/codegen/generators/dtos/schema-aliases.d.ts.map +1 -1
  101. package/types/codegen/generators/dtos/text-format.d.ts +31 -0
  102. package/types/codegen/generators/dtos/text-format.d.ts.map +1 -0
  103. package/types/codegen/generators/dtos/text-schema.d.ts +8 -1
  104. package/types/codegen/generators/dtos/text-schema.d.ts.map +1 -1
  105. package/types/codegen/generators/manifest.d.ts +5 -0
  106. package/types/codegen/generators/manifest.d.ts.map +1 -1
  107. package/types/codegen/runtime/read-comb.d.ts +17 -3
  108. package/types/codegen/runtime/read-comb.d.ts.map +1 -1
  109. package/types/codegen/runtime/read-tables.d.ts.map +1 -1
  110. package/types/codegen/runtime/snapshot-types.d.ts +3 -1
  111. package/types/codegen/runtime/snapshot-types.d.ts.map +1 -1
  112. package/types/codegen/runtime/to-meta.d.ts.map +1 -1
  113. package/types/formats/catalog.d.ts +51 -0
  114. package/types/formats/catalog.d.ts.map +1 -0
  115. package/types/formats/iana-time-zone.d.ts +7 -0
  116. package/types/formats/iana-time-zone.d.ts.map +1 -0
  117. package/types/formats/iso3166-1.d.ts +12 -0
  118. package/types/formats/iso3166-1.d.ts.map +1 -0
  119. package/types/formats/iso4217.d.ts +7 -0
  120. package/types/formats/iso4217.d.ts.map +1 -0
  121. package/types/formats/iso639-1.d.ts +6 -0
  122. package/types/formats/iso639-1.d.ts.map +1 -0
  123. package/types/formats/uts35.d.ts +13 -0
  124. package/types/formats/uts35.d.ts.map +1 -0
  125. package/types/index.d.ts +5 -0
  126. package/types/index.d.ts.map +1 -1
  127. package/types/meta/column.d.ts +7 -1
  128. package/types/meta/column.d.ts.map +1 -1
  129. package/types/migrate/core/apply.d.ts +3 -2
  130. package/types/migrate/core/apply.d.ts.map +1 -1
  131. package/types/migrate/core/atlas-apply-runs.d.ts +6 -0
  132. package/types/migrate/core/atlas-apply-runs.d.ts.map +1 -1
  133. package/types/migrate/core/cleanup.d.ts +16 -4
  134. package/types/migrate/core/cleanup.d.ts.map +1 -1
  135. package/types/migrate/core/d1-history.d.ts.map +1 -1
  136. package/types/migrate/core/d1-wrangler-error.d.ts +11 -0
  137. package/types/migrate/core/d1-wrangler-error.d.ts.map +1 -1
  138. package/types/migrate/core/pg-replay-commit.d.ts +14 -9
  139. package/types/migrate/core/pg-replay-commit.d.ts.map +1 -1
  140. package/types/migrate/core/pg-replay-drops.d.ts +7 -12
  141. package/types/migrate/core/pg-replay-drops.d.ts.map +1 -1
  142. package/types/migrate/core/pg-replay-hidden.d.ts +36 -0
  143. package/types/migrate/core/pg-replay-hidden.d.ts.map +1 -0
  144. package/types/migrate/core/pg-replay-roles.d.ts +2 -4
  145. package/types/migrate/core/pg-replay-roles.d.ts.map +1 -1
  146. package/types/migrate/core/pg-replay.d.ts.map +1 -1
  147. package/types/migrate/core/pg-server-snapshot.d.ts +4 -11
  148. package/types/migrate/core/pg-server-snapshot.d.ts.map +1 -1
  149. package/types/migrate/core/proof-readers.d.ts +19 -0
  150. package/types/migrate/core/proof-readers.d.ts.map +1 -0
  151. package/types/migrate/core/proof-run.d.ts.map +1 -1
  152. package/types/migrate/core/proof-scope.d.ts +15 -7
  153. package/types/migrate/core/proof-scope.d.ts.map +1 -1
  154. package/types/migrate/core/sql-code.d.ts +7 -3
  155. package/types/migrate/core/sql-code.d.ts.map +1 -1
  156. package/types/migrate/core/sql-scan.d.ts.map +1 -1
  157. package/types/migrate/core/sqlite-keywords.d.ts +11 -0
  158. package/types/migrate/core/sqlite-keywords.d.ts.map +1 -0
  159. package/types/migrate/core/stopped.d.ts +23 -0
  160. package/types/migrate/core/stopped.d.ts.map +1 -0
  161. package/types/pg/columns.d.ts +3 -3
  162. package/types/pg/columns.d.ts.map +1 -1
  163. package/types/query/cursor-mint.d.ts.map +1 -1
  164. package/types/query/cursor-refusals.d.ts +17 -1
  165. package/types/query/cursor-refusals.d.ts.map +1 -1
  166. package/types/query/descriptions.d.ts +1 -1
  167. package/types/query/descriptions.d.ts.map +1 -1
  168. package/types/query/filter-parse.d.ts +15 -4
  169. package/types/query/filter-parse.d.ts.map +1 -1
  170. package/types/query/filter-validate.d.ts +1 -1
  171. package/types/query/filter-validate.d.ts.map +1 -1
  172. package/types/query/filter.d.ts +9 -1
  173. package/types/query/filter.d.ts.map +1 -1
  174. package/types/query/list-extras.d.ts.map +1 -1
  175. package/types/query/list-schema.d.ts.map +1 -1
  176. package/types/query/merged.d.ts.map +1 -1
  177. package/types/query/pagination.d.ts +13 -1
  178. package/types/query/pagination.d.ts.map +1 -1
  179. package/types/query/rounded-key.d.ts +2 -11
  180. package/types/query/rounded-key.d.ts.map +1 -1
  181. package/types/query/schema-params.d.ts.map +1 -1
  182. package/types/query/select-validate.d.ts.map +1 -1
  183. package/types/query/sqlite/cursor-fit.d.ts.map +1 -1
  184. package/types/query/trusted.d.ts +1 -1
  185. package/types/query/trusted.d.ts.map +1 -1
  186. package/types/sqlite/columns.d.ts +3 -3
  187. package/types/sqlite/columns.d.ts.map +1 -1
  188. package/types/sqlite/constraints.d.ts +117 -5
  189. package/types/sqlite/constraints.d.ts.map +1 -1
  190. package/src/sqlite/patterns.ts +0 -19
  191. package/types/sqlite/patterns.d.ts +0 -19
  192. package/types/sqlite/patterns.d.ts.map +0 -1
@@ -9,10 +9,12 @@
9
9
  * Then each file as wrangler sends it, the file and the history row it appends, through wrangler's
10
10
  * `splitSqlQuery`, in one batch. D1 resolves a function in a trigger body, a view or a generated
11
11
  * column only when a statement uses it, so EXPLAIN then compiles each view, and each table's INSERT,
12
- * UPDATE and DELETE, the file made or changed. What D1 refuses comes back in D1's own words.
12
+ * UPDATE and DELETE, the file made or changed; a view takes only the writes its INSTEAD OF triggers fire
13
+ * on (trigger-write.mjs). What D1 refuses comes back in D1's own words.
13
14
  */
14
15
  import { readFileSync } from "node:fs"
15
16
  import { pathToFileURL } from "node:url"
17
+ import { EVENTS, quote, triggerEvent, writeOf } from "./trigger-write.mjs"
16
18
 
17
19
  /**
18
20
  * @typedef {{ all(): Promise<{ results?: Array<Record<string, unknown>> | undefined }> }} D1Statement
@@ -27,9 +29,6 @@ import { pathToFileURL } from "node:url"
27
29
  * @typedef {import("./d1-engine-child.d.mts").ProofResult} ProofResult
28
30
  */
29
31
 
30
- /** @param {string} name */
31
- const quote = (name) => `"${name.replaceAll('"', '""')}"`
32
-
33
32
  /**
34
33
  * D1's own words for a failure: the cause wrangler's client wraps, when there is one
35
34
  * @param {unknown} error
@@ -87,7 +86,8 @@ async function isRowTable(db, table) {
87
86
  * @returns {Promise<string[]>}
88
87
  */
89
88
  async function probes(db, before) {
90
- const changed = [...(await master(db))].filter(([key, object]) => before.get(key)?.sql !== object.sql)
89
+ const after = [...(await master(db))]
90
+ const changed = after.filter(([key, object]) => before.get(key)?.sql !== object.sql)
91
91
  const views = changed.filter(([, object]) => object.type === "view").map(([, object]) => object.table)
92
92
  const tables = new Set(changed.filter(([, object]) => object.type === "trigger").map(([, object]) => object.table))
93
93
  for (const [, object] of changed)
@@ -96,16 +96,32 @@ async function probes(db, before) {
96
96
  const writes = []
97
97
  for (const table of tables) {
98
98
  const { results = [] } = await db.prepare(`PRAGMA table_xinfo(${quote(table)})`).all()
99
- const columns = results.filter((row) => row["hidden"] === 0).map((row) => quote(String(row["name"])))
100
- writes.push(
101
- `EXPLAIN INSERT INTO ${quote(table)} DEFAULT VALUES`,
102
- `EXPLAIN UPDATE ${quote(table)} SET ${columns.map((column) => `${column} = ${column}`).join(", ")}`,
103
- `EXPLAIN DELETE FROM ${quote(table)}`,
104
- )
99
+ const columns = results.filter((row) => row["hidden"] === 0).map((row) => String(row["name"]))
100
+ for (const event of eventsOf(after, table)) writes.push(`EXPLAIN ${writeOf(table, event, columns)}`)
105
101
  }
106
102
  return [...views.map((view) => `EXPLAIN SELECT * FROM ${quote(view)}`), ...writes]
107
103
  }
108
104
 
105
+ /**
106
+ * `name` as SQLite compares names, ASCII letters folded alone: a trigger's tbl_name is spelled as its ON clause was
107
+ * @param {string} name
108
+ */
109
+ const fold = (name) => name.replace(/[A-Z]/g, (letter) => letter.toLowerCase())
110
+
111
+ /**
112
+ * The writes `table` takes in the schema `objects`: a table every one, a view each its triggers fire on
113
+ * @param {ReadonlyArray<[string, SchemaObject]>} objects
114
+ * @param {string} table
115
+ */
116
+ function eventsOf(objects, table) {
117
+ const on = objects.filter(([, object]) => fold(object.table) === fold(table))
118
+ if (!on.some(([, object]) => object.type === "view")) return EVENTS
119
+ const fired = new Set(
120
+ on.filter(([, object]) => object.type === "trigger").map(([, object]) => triggerEvent(object.sql)),
121
+ )
122
+ return EVENTS.filter((event) => fired.has(event))
123
+ }
124
+
109
125
  /**
110
126
  * The scratch D1 a proof runs on: what it holds is replayed on a fresh one when workerd crashes,
111
127
  * which a disconnect shows ("other side closed"), not a D1 error
@@ -15,7 +15,7 @@ import { executeAll } from "../drivers/d1.ts"
15
15
  import { lockedOut } from "../drivers/d1-lock.ts"
16
16
  import { assertLocalExists, type D1Target, localMissing, type ReadConfig, resolveBinding } from "./d1-binding.ts"
17
17
  import { d1Files, refuseSwallowing } from "./d1-files.ts"
18
- import { applyFailure, wranglerError } from "./d1-wrangler-error.ts"
18
+ import { refuseUnlike } from "./d1-wrangler-error.ts"
19
19
  import { D1_HASH_INTENT_TABLE, D1_HASH_TABLE, DRIZZLE_HISTORY_TABLE } from "./d1-history-tables.ts"
20
20
  import {
21
21
  type Hashed,
@@ -37,8 +37,9 @@ import { orphanMessage } from "./history-match.ts"
37
37
  import { generateMigrationHash, migrationHashes } from "./parse.ts"
38
38
  import { proveOnD1 } from "./d1-engine.ts"
39
39
  import { planApply } from "./plan-apply.ts"
40
- import { runPeer } from "./process.ts"
40
+ import { type RunResult, runPeer } from "./process.ts"
41
41
  import { Refused } from "./refused.ts"
42
+ import { whileStoppable } from "./stopped.ts"
42
43
 
43
44
  /** The files `__drizzle_migrations` records that the migrations table does not */
44
45
  function recordedByDrizzle(
@@ -129,27 +130,6 @@ async function appliedAfter(
129
130
  }
130
131
  }
131
132
 
132
- /** Refuse a failed run, or one whose table gained (`added`) other than the `names` checked, naming a file never checked */
133
- function refuseUnlike(
134
- ran: { code: number; stderr: string; stdout: string },
135
- { added, locked, names, table }: { added: string[]; locked: boolean; names: string[]; table: string },
136
- ): void {
137
- const unchecked = added.filter((name) => !names.includes(name))
138
- const rest = names.filter((name) => !added.includes(name))
139
- const also =
140
- unchecked.length === 0
141
- ? ""
142
- : `; wrangler also applied ${unchecked.join(", ")}, which comb never checked and holds no hash for: check each against git, then run comb migrate baseline <db> --hashes`
143
- const now = { applied: added, pending: rest }
144
- if (ran.code !== 0) throw new Refused(`wrangler d1 migrations apply failed: ${applyFailure(ran, locked)}${also}`, now)
145
- if (unchecked.length > 0 || rest.length > 0) {
146
- throw new Refused(
147
- `wrangler recorded ${added.join(", ") || "nothing"} in ${table}, where comb checked ${names.join(", ")}; wrangler said: ${wranglerError(ran)}${also}`,
148
- now,
149
- )
150
- }
151
- }
152
-
153
133
  type D1Apply = {
154
134
  approve(migration: ParsedMigration): Promise<Approval>
155
135
  /** only check: apply nothing */
@@ -158,6 +138,25 @@ type D1Apply = {
158
138
  read: ReadConfig
159
139
  }
160
140
 
141
+ /**
142
+ * wrangler's apply of `names`, each announced first. A stop signal ends its run, and the table then
143
+ * says which of them it recorded (stopped.ts)
144
+ */
145
+ async function wranglerApply(
146
+ target: D1Target,
147
+ { print, read }: D1Apply,
148
+ { names, table }: { names: string[]; table: string },
149
+ ): Promise<RunResult> {
150
+ for (const name of names) print(`Apply: ${name}`)
151
+ const found = async () => {
152
+ const recorded = await appliedAfter(target, read, { names, ok: false, table })
153
+ return { applied: names.filter((name) => recorded.has(name)), pending: names.filter((name) => !recorded.has(name)) }
154
+ }
155
+ return whileStoppable({ names, print, read: found }, () =>
156
+ runPeer(target.exec, target.wrangler)(["d1", "migrations", "apply", target.name, ...target.flags], { input: "" }),
157
+ )
158
+ }
159
+
161
160
  /** Check the pending migrations and, unless only checking, apply them through wrangler; returns them */
162
161
  async function applyD1(target: D1Target, options: D1Apply): Promise<string[]> {
163
162
  const { applied, hasIntents, marks, settle, table } = await history(options.read, target, {
@@ -193,9 +192,7 @@ async function applyD1(target: D1Target, options: D1Apply): Promise<string[]> {
193
192
  /* the intents go first: a lost record then fails before wrangler applies anything */
194
193
  const hashed = pending.map(({ migration, name }) => ({ hash: migration.hash, marked: true, name }))
195
194
  await target.conn.batch([...settleStatements(settle, hasIntents, marks), ...intentStatements(hashed)])
196
- for (const name of names) options.print(`Apply: ${name}`)
197
- const apply = ["d1", "migrations", "apply", target.name, ...target.flags]
198
- const ran = await runPeer(target.exec, target.wrangler)(apply, { input: "" })
195
+ const ran = await wranglerApply(target, options, { names, table })
199
196
  /* asked before anything else runs, while the lock that failed the apply is most likely still held */
200
197
  const locked = ran.code !== 0 && target.flags.includes("--local") && (await lockedOut(ran, target.held))
201
198
  const after = await appliedAfter(target, options.read, { names, ok: ran.code === 0, table })
@@ -1,7 +1,9 @@
1
1
  /**
2
- * What a failed `wrangler d1 migrations apply` says, read for the refusal comb gives.
2
+ * What a failed `wrangler d1 migrations apply` says, read for the refusal comb gives, and the refusal
3
+ * of a run whose table records other than the files comb checked.
3
4
  */
4
5
  import { LOCKER } from "../drivers/d1-lock.ts"
6
+ import { Refused } from "./refused.ts"
5
7
 
6
8
  type Ran = { stderr: string; stdout: string }
7
9
 
@@ -23,3 +25,24 @@ export function applyFailure(ran: Ran, locked: boolean): string {
23
25
  ? `the local D1 database was locked by ${LOCKER} while wrangler applied: let it finish and rerun`
24
26
  : wranglerError(ran)
25
27
  }
28
+
29
+ /** Refuse a failed run, or one whose table gained (`added`) other than the `names` checked, naming a file never checked */
30
+ export function refuseUnlike(
31
+ ran: { code: number; stderr: string; stdout: string },
32
+ { added, locked, names, table }: { added: string[]; locked: boolean; names: string[]; table: string },
33
+ ): void {
34
+ const unchecked = added.filter((name) => !names.includes(name))
35
+ const rest = names.filter((name) => !added.includes(name))
36
+ const also =
37
+ unchecked.length === 0
38
+ ? ""
39
+ : `; wrangler also applied ${unchecked.join(", ")}, which comb never checked and holds no hash for: check each against git, then run comb migrate baseline <db> --hashes`
40
+ const now = { applied: added, pending: rest }
41
+ if (ran.code !== 0) throw new Refused(`wrangler d1 migrations apply failed: ${applyFailure(ran, locked)}${also}`, now)
42
+ if (unchecked.length > 0 || rest.length > 0) {
43
+ throw new Refused(
44
+ `wrangler recorded ${added.join(", ") || "nothing"} in ${table}, where comb checked ${names.join(", ")}; wrangler said: ${wranglerError(ran)}${also}`,
45
+ now,
46
+ )
47
+ }
48
+ }
@@ -2,12 +2,14 @@
2
2
  * The replay's transactions and the reads around them (pg-replay.ts): a file's or comb's own
3
3
  * statement's transaction, refused before its COMMIT when this backend's counts show it wrote past
4
4
  * its database, and the server's shared catalogs read as committed around it and around
5
- * every bare statement, a change there the file's when it is to an object the file names;
5
+ * every bare statement, a change there the file's when it is to the replay's own database, or to an
6
+ * object the file names beside a statement that can run code comb cannot read;
6
7
  * a `txmode none` file's statement in a transaction of its own first, bare only when
7
8
  * Postgres runs it outside a block alone.
8
9
  */
9
10
  import { adopt, bareAgain, type Run } from "./pg-replay-roles.ts"
10
- import { added, type Snapshot, snapshot, snapshotChanges, subscriptionsIn } from "./pg-server-snapshot.ts"
11
+ import { type Hidden, runsHidden } from "./pg-replay-hidden.ts"
12
+ import { type Snapshot, snapshot, snapshotChanges, subscriptionsIn } from "./pg-server-snapshot.ts"
11
13
  import {
12
14
  type Change,
13
15
  catalogsOf,
@@ -31,12 +33,13 @@ async function begin(run: Run): Promise<void> {
31
33
  }
32
34
 
33
35
  /**
34
- * Where a replayed transaction or bare statement is: its file's name and the names it spells, the
35
- * statement, if bare, and what a refusal stops; told each role the transaction adds that the replay
36
- * adopts
36
+ * Where a replayed transaction or bare statement is: its file's name and the names it spells, what
37
+ * its statements so far can run that comb cannot read (`hidden`), the statement, if bare, and what a
38
+ * refusal stops; told each role the transaction adds that the replay adopts
37
39
  */
38
40
  type At = {
39
41
  adopted?: ((role: string) => void) | undefined
42
+ hidden: Hidden
40
43
  name: string
41
44
  names: ReadonlySet<string>
42
45
  sent: string | undefined
@@ -46,52 +49,52 @@ type At = {
46
49
  /* the words that name devUrl's role in a file, as namesIn folds them: CURRENT_USER and its kin */
47
50
  const SESSION_ROLE = ["current_role", "current_user", "session_user", "user"]
48
51
 
52
+ /** The objects the file `names`, devUrl's role among them where it names that by keyword */
53
+ function spelled(run: Run, names: ReadonlySet<string>): Set<string> {
54
+ const its = new Set(names)
55
+ if (SESSION_ROLE.some((word) => names.has(word))) its.add(run.role)
56
+ return its
57
+ }
58
+
49
59
  /**
50
- * The server's shared catalogs read as committed against the last read: a change since to an object
51
- * the file `name` names (`names`), while `sent` or the whole file ran, is refused, and a role or
52
- * database it added is the cleanup's; a change to any other is another session's. The
53
- * replay's own database is the file's whatever it spells, as only this replay runs in it, and is
54
- * the cleanup's too. `read` is that read when the caller took it
60
+ * The server's shared catalogs read as committed against the last read: a change since to the
61
+ * replay's own database, or to what it holds, is the file's, as only this replay runs in it, and the
62
+ * cleanup drops it. A change to an object the file names is the file's when its statements so far
63
+ * can run code comb cannot read (pg-replay-hidden.ts), the one way a change past the database gets by
64
+ * the counts, and is refused, but no cleanup drops it: Postgres keeps no creator for a role or a
65
+ * database, and another session may have made it. A change to any other object, or beside statements
66
+ * that run only what their parse says, is another session's. `read` is that read when the caller took it
55
67
  */
56
68
  async function window(run: Run, at: At, read?: Snapshot): Promise<void> {
57
- strayed(run, at, read ?? (await snapshot(run.admin)))
69
+ const now = read ?? (await snapshot(run.admin))
70
+ strayed(run, { ...at, hid: await hides(run, at, now) }, now)
58
71
  }
59
72
 
73
+ /* whether a change in `now` to an object the file names may be the file's, as its statements can run code comb cannot read */
74
+ const hides = (run: Run, { hidden, names }: At, now: Snapshot): boolean | Promise<boolean> =>
75
+ snapshotChanges(run.seen, now, spelled(run, names)).length > 0 && runsHidden(run.sql, hidden)
76
+
60
77
  /**
61
78
  * window's verdict on `now`, the server's shared catalogs as committed after the file or `sent`,
62
- * against the last read; kept apart from window's await, as v8 miscounts a branch after an await
79
+ * against the last read, the objects the file names counted when it `hid` code comb cannot read;
80
+ * kept apart from window's await, as v8 miscounts a branch after an await
63
81
  */
64
- function strayed(run: Run, { name, names, sent, stops }: At, now: Snapshot): void {
65
- const its = new Set([...names, run.database])
66
- if (SESSION_ROLE.some((word) => names.has(word))) its.add(run.role)
67
- const changes = snapshotChanges(run.seen, now, its)
68
- const { databases, roles, slots } = added(run.seen, now, its)
82
+ function strayed(run: Run, { hid, name, names, sent, stops }: At & { hid: boolean }, now: Snapshot): void {
83
+ const replays = new Set([run.database])
84
+ const changes = snapshotChanges(run.seen, now, hid ? new Set([...spelled(run, names), ...replays]) : replays)
85
+ const own = new Set(snapshotChanges(run.seen, now, replays))
69
86
  const within = subscriptionsIn(run.seen, now, run.database)
70
87
  run.seen = now
71
88
  if (changes.length === 0) return
72
- run.strays.databases.push(...databases)
73
- run.strays.roles.push(...roles)
74
- run.strays.slots.push(...slots)
75
- const own = `database ${run.database} `
76
- const strays = [
77
- ...roles.map((role) => `role ${role}`),
78
- ...databases.map((database) => `database ${database} unless a session is in it`),
79
- ...slots.map((slot) => `replication slot ${slot} unless a session holds it`),
80
- ]
81
- const replays =
82
- changes.some((change) => change.startsWith(own)) || within.length > 0
89
+ const dropped =
90
+ own.size > 0
83
91
  ? `; comb drops the database it replays in${within.length === 0 ? "" : `, and ${within.join(", ")} in it`}`
84
92
  : ""
85
- const dropped = `${strays.length === 0 ? "" : `; comb drops ${strays.join(", ")}, which the file added`}${replays}`
86
- /* the read is committed, so a change it shows to an object the file did not add stays */
87
- const dropping = new Set([
88
- ...roles.map((role) => `role ${role} added`),
89
- ...databases.map((d) => `database ${d} added`),
90
- ...slots.map((slot) => `replication slot ${slot} added`),
91
- ...within.map((subject) => `${subject} added`),
92
- ])
93
- const kept = changes.filter((change) => !dropping.has(change) && !change.startsWith(own))
94
- const left = kept.length === 0 ? "" : `; devUrl's server keeps ${kept.join(", ")}, which comb cannot undo`
93
+ const kept = changes.filter((change) => !own.has(change))
94
+ const left =
95
+ kept.length === 0
96
+ ? ""
97
+ : `; comb leaves ${kept.join(", ")} on devUrl's server, as another session may have made ${kept.length === 1 ? "that change" : "those changes"}: undo there each that is the file's`
95
98
  throw new ReplayFailed(
96
99
  `${name}: devUrl's server changed past the database the replay runs in while ${sent === undefined ? "the file" : JSON.stringify(sent)} ran: ${changes.join(", ")}. A change made there, from inside a DO block, a function, EXECUTE or CALL, or over a second connection such as dblink's, apply would make on the target's server, so comb refuses the file; write the change as a statement of its own, which the replay leaves out and apply runs${dropped}${left}; ${stops}`,
97
100
  )
@@ -4,8 +4,8 @@
4
4
  * which the next replay or apply sweeps. Postgres drops no database that holds a subscription or a
5
5
  * logical slot, so both go first, and only from the replay databases it drops: a subscription from
6
6
  * a session in its database, the only one that can name it. A drop that fails never decides the
7
- * verdict, and is said, never left unsaid: what it left, dev's error, and whether a later sweep takes it, as a
8
- * stray of a refused window carries no tag a sweep finds. The replay database is dropped WITH
7
+ * verdict, and is said, never left unsaid: what it left and dev's error, which a later sweep takes. The
8
+ * replay database is dropped WITH
9
9
  * (FORCE), so a backend that came in after the cleanup ended the others cannot hold it.
10
10
  */
11
11
  import { sqlIdentifier } from "../../codegen/utils/case.ts"
@@ -24,14 +24,11 @@ async function settle(step: () => Promise<unknown>): Promise<void> {
24
24
  }
25
25
  }
26
26
 
27
- /** What a drop that failed left on devUrl's server, a database, role or slot by name, and whether a later sweep takes it */
28
- type Left = { swept: boolean; what: string }
29
-
30
- /** How a drop that failed is said: what it `left`, with dev's error */
31
- type Leave = (left: Left, error: unknown) => void
27
+ /** How a drop that failed is said: what it left on devUrl's server, a database, role or slot by name, with dev's error */
28
+ type Leave = (left: string, error: unknown) => void
32
29
 
33
30
  /** `drop` run, a failure `leave`d: cleaning up never replaces the replay's verdict, and never leaves something unsaid */
34
- async function dropOr(leave: Leave, left: Left, drop: () => Promise<unknown>): Promise<void> {
31
+ async function dropOr(leave: Leave, left: string, drop: () => Promise<unknown>): Promise<void> {
35
32
  try {
36
33
  await drop()
37
34
  } catch (error) {
@@ -45,9 +42,9 @@ type Drops = { into: (database: string) => Sql; leave: Leave }
45
42
 
46
43
  /** A `leave` that prints, `failed` naming devUrl in dev's error */
47
44
  function leaving(print: (line: string) => void, failed: Parameters<typeof failureOf>[1]): Leave {
48
- return ({ swept, what }, error) =>
45
+ return (left, error) =>
49
46
  print(
50
- `comb could not drop ${what}, which a replay left on devUrl's server: ${failureOf(error, failed)}; ${swept ? "the next check or apply that reaches that server drops it" : "drop it there"}`,
47
+ `comb could not drop ${left}, which a replay left on devUrl's server: ${failureOf(error, failed)}; the next check or apply that reaches that server drops it`,
51
48
  )
52
49
  }
53
50
 
@@ -80,14 +77,13 @@ async function emptyDatabase(admin: Sql, database: string, { into, leave }: Drop
80
77
  if (subscriptions.length > 0) {
81
78
  const sql = into(database)
82
79
  try {
83
- for (const name of subscriptions)
84
- await dropOr(leave, { swept: true, what: `subscription ${name}` }, () => dropSubscription(sql, name))
80
+ for (const name of subscriptions) await dropOr(leave, `subscription ${name}`, () => dropSubscription(sql, name))
85
81
  } finally {
86
82
  await settle(() => sql.end({ timeout: 0 }))
87
83
  }
88
84
  }
89
85
  for (const slot of (holds?.["slots"] ?? []) as string[])
90
- await dropOr(leave, { swept: true, what: `replication slot ${slot}` }, () => dropSlot(admin, slot))
86
+ await dropOr(leave, `replication slot ${slot}`, () => dropSlot(admin, slot))
91
87
  }
92
88
 
93
89
  /**
@@ -132,11 +128,9 @@ async function sweep(admin: Sql, now: number, drops: Drops): Promise<void> {
132
128
  await settle(() => emptyDatabase(admin, name, drops))
133
129
  /* Postgres drops no template, which a committing DO can make the replay's database */
134
130
  if (template) await settle(() => admin.unsafe(`ALTER DATABASE ${sqlIdentifier(name)} IS_TEMPLATE false`, []))
135
- await dropOr(leave, { swept: true, what: `database ${name}` }, () =>
136
- admin.unsafe(`DROP DATABASE IF EXISTS ${sqlIdentifier(name)}`, []),
137
- )
131
+ await dropOr(leave, `database ${name}`, () => admin.unsafe(`DROP DATABASE IF EXISTS ${sqlIdentifier(name)}`, []))
138
132
  }
139
- for (const role of roles) await dropOr(leave, { swept: true, what: `role ${role}` }, () => dropRole(admin, role))
133
+ for (const role of roles) await dropOr(leave, `role ${role}`, () => dropRole(admin, role))
140
134
  }
141
135
 
142
136
  /* why comb loads postgres.js in an apply */
@@ -196,10 +190,10 @@ async function sweepDev(
196
190
 
197
191
  /**
198
192
  * `run`'s cleanup, each drop that fails `leave`d: its connection closed, a query in flight with it,
199
- * any backend still in its database ended, the slots a refused window added, which a drop waits on,
200
- * the database's subscriptions and slots, which keep it from its drop, the database made no
201
- * template and dropped, forcing out a backend that came in since, each role it made, and that
202
- * window's roles and idle databases, which no sweep takes
193
+ * any backend still in its database ended, the database's subscriptions and slots, which keep it
194
+ * from its drop, the database made no template and dropped, forcing out a backend that came in
195
+ * since, and each role it made or adopted. It drops nothing else a refused window saw, as Postgres
196
+ * keeps no creator for a role, a database or a slot, and another session may have made it
203
197
  */
204
198
  async function cleanUp(run: Run, drops: Drops): Promise<void> {
205
199
  const { leave } = drops
@@ -210,21 +204,12 @@ async function cleanUp(run: Run, drops: Drops): Promise<void> {
210
204
  [run.database],
211
205
  ),
212
206
  )
213
- for (const slot of run.strays.slots)
214
- await dropOr(leave, { swept: false, what: `replication slot ${slot}` }, () => dropSlot(run.admin, slot))
215
207
  await settle(() => emptyDatabase(run.admin, run.database, drops))
216
208
  await settle(() => run.admin.unsafe(`ALTER DATABASE ${run.database} IS_TEMPLATE false`, []))
217
- await dropOr(leave, { swept: true, what: `database ${run.database}` }, () =>
209
+ await dropOr(leave, `database ${run.database}`, () =>
218
210
  run.admin.unsafe(`DROP DATABASE IF EXISTS ${run.database} WITH (FORCE)`, []),
219
211
  )
220
- for (const role of run.created)
221
- await dropOr(leave, { swept: true, what: `role ${role}` }, () => dropMade(run.admin, role))
222
- for (const name of run.strays.databases)
223
- await dropOr(leave, { swept: false, what: `database ${name}` }, () =>
224
- run.admin.unsafe(`DROP DATABASE IF EXISTS ${sqlIdentifier(name)}`, []),
225
- )
226
- for (const role of run.strays.roles)
227
- await dropOr(leave, { swept: false, what: `role ${role}` }, () => dropRole(run.admin, role))
212
+ for (const role of run.created) await dropOr(leave, `role ${role}`, () => dropMade(run.admin, role))
228
213
  }
229
214
 
230
215
  export { cleanUp, type Drops, dropMade, dropOr, dropRole, dropSlot, type Leave, leaving, settle, sweep, sweepDev }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Whether the statements a replay's window covers can run code comb cannot read, the only way one
3
+ * makes a change past its database that this backend's counts miss (pg-replay-commit.ts): a DO
4
+ * block, a CALL, an EXECUTE, or a call of a function outside pg_catalog, dblink's among them. A
5
+ * statement whose tree holds none of those runs only what its parse says, and BEYOND and SERVER_CALLS
6
+ * (pg-outside-transaction.ts, pg-server-calls.ts) leave out every such statement that reaches past
7
+ * the database, so a change the server's read shows beside it is another session's. A function
8
+ * called by a bare name is pg_catalog's only where the replay's database holds no other of that name,
9
+ * as an overload in a schema on the search path can take the call, and the file makes none of it.
10
+ * Code Postgres runs for a statement on its own, a trigger, a default, an operator or a cast that
11
+ * calls a function an earlier file defines, is no call in the tree, so it is not read here; what it
12
+ * writes in the replay's transaction its counts still see.
13
+ */
14
+ import type { Sql } from "./pg-replay-database.ts"
15
+
16
+ /**
17
+ * What the statements so far can run that comb cannot read: `code` when one surely can, the
18
+ * functions they call by a bare name (`calls`), which may be pg_catalog's, and those the file makes
19
+ * (`made`). A file's every later window counts what one ran, as a change a DO block makes in the
20
+ * file's own block commits at its COMMIT
21
+ */
22
+ type Hidden = { calls: Set<string>; code: boolean; made: Set<string> }
23
+
24
+ /** Nothing run yet */
25
+ const nothingHidden = (): Hidden => ({ calls: new Set(), code: false, made: new Set() })
26
+
27
+ // lookup-table: the statements that run code the parse does not hold: a DO block's body, a procedure, a prepared statement
28
+ const RUNS_CODE: ReadonlySet<string> = /* @__PURE__ */ new Set(["CallStmt", "DoStmt", "ExecuteStmt"])
29
+
30
+ /** The name parts of a FuncCall's or a CREATE FUNCTION's `funcname`, each a String node, the name last */
31
+ const partsOf = (funcname: unknown): string[] =>
32
+ (funcname as Array<{ String: { sval: string } }>).map((part) => part.String.sval)
33
+
34
+ /** `tree`, a parse tree or a part of one, read `into` */
35
+ function readTree(tree: unknown, into: Hidden): void {
36
+ if (tree === null || typeof tree !== "object") return
37
+ for (const [key, value] of Object.entries(tree)) {
38
+ if (RUNS_CODE.has(key)) into.code = true
39
+ else if (key === "FuncCall" || key === "CreateFunctionStmt") {
40
+ const parts = partsOf((value as { funcname: unknown }).funcname)
41
+ /* a funcname holds its name at least */
42
+ const name = parts.at(-1) as string
43
+ if (key === "CreateFunctionStmt") into.made.add(name)
44
+ else if (parts.length === 1) into.calls.add(name)
45
+ else if (parts.length > 2 || parts[0] !== "pg_catalog") into.code = true
46
+ }
47
+ readTree(value, into)
48
+ }
49
+ }
50
+
51
+ /** A statement the replay runs, read `into`: one the parser cannot read may run anything */
52
+ function readStatement(tree: unknown, into: Hidden): void {
53
+ if (tree === undefined) into.code = true
54
+ else readTree(tree, into)
55
+ }
56
+
57
+ /* of the functions a JSON array names, whether one is no pg_catalog function, or also one outside it */
58
+ const NOT_CATALOG = `SELECT EXISTS (
59
+ SELECT FROM jsonb_array_elements_text($1::text::jsonb) f(name)
60
+ WHERE NOT EXISTS (SELECT FROM pg_proc p WHERE p.proname = f.name AND p.pronamespace = 'pg_catalog'::regnamespace)
61
+ OR EXISTS (SELECT FROM pg_proc p WHERE p.proname = f.name AND p.pronamespace <> 'pg_catalog'::regnamespace)
62
+ ) AS hidden`
63
+
64
+ /**
65
+ * Whether what `hidden` holds can run code comb cannot read, a bare name resolved on `sql`, the
66
+ * replay's connection, which sees what its open transaction made
67
+ */
68
+ async function runsHidden(sql: Sql, { calls, code, made }: Hidden): Promise<boolean> {
69
+ if (code || [...calls].some((name) => made.has(name))) return true
70
+ if (calls.size === 0) return false
71
+ const rows = await sql.unsafe(NOT_CATALOG, [JSON.stringify([...calls])])
72
+ return rows.some((row) => row["hidden"] === true)
73
+ }
74
+
75
+ export { type Hidden, nothingHidden, readStatement, runsHidden }
@@ -18,7 +18,7 @@
18
18
  import { sqlIdentifier } from "../../codegen/utils/case.ts"
19
19
  import { codeOf, literal, type Sql, TAG } from "./pg-replay-database.ts"
20
20
  import { addWrites, catalogsOf, counts, type Rows, subjectRows, type Writes, writesSince } from "./pg-server-writes.ts"
21
- import type { Snapshot, Strays } from "./pg-server-snapshot.ts"
21
+ import type { Snapshot } from "./pg-server-snapshot.ts"
22
22
  import { flag } from "./pg-nodes.ts"
23
23
 
24
24
  /**
@@ -26,8 +26,7 @@ import { flag } from "./pg-nodes.ts"
26
26
  * that owns it, its role (`role`, which CURRENT_USER and its kin name in a file), whether that role
27
27
  * is a superuser, which reads pg_authid, whether it has CREATEROLE (`createrole`), and the roles it made; the
28
28
  * open transaction's own writes, comb's CREATE ROLEs', with the rows they left; the shared catalogs
29
- * as last read committed (`seen`), and the roles, databases and replication slots a refused window
30
- * added (`strays`), which the cleanup drops
29
+ * as last read committed (`seen`)
31
30
  */
32
31
  type Run = {
33
32
  admin: Sql
@@ -39,7 +38,6 @@ type Run = {
39
38
  role: string
40
39
  seen: Snapshot
41
40
  sql: Sql
42
- strays: Strays
43
41
  /** the tablespaces it left out the CREATE of, which a statement then runs in pg_default instead */
44
42
  tablespaces: Set<string>
45
43
  superuser: boolean
@@ -3,11 +3,10 @@
3
3
  * catalog query: Postgres, not a model of it, decides what a cascade, a rename or a
4
4
  * move leaves, and the two sides compare as one read of two databases.
5
5
  *
6
- * The replay runs as Atlas applies: each file on a session of its own, as apply
7
- * gives each file an Atlas call, and in a transaction of its own, a `--
8
- * atlas:txmode none` file statement by statement, each in a transaction of its own first and bare,
9
- * as Atlas sends it, where Postgres runs it only so, in a database
10
- * made for it and dropped after (pg-replay-database.ts). One transaction for the whole history
6
+ * The replay runs as Atlas applies: each file on a session of its own, as apply gives each file an
7
+ * Atlas call, and in a transaction of its own, a `-- atlas:txmode none` file statement by statement,
8
+ * each in a transaction of its own first and bare, as Atlas sends it, where Postgres runs it only
9
+ * so, in a database made for it and dropped after (pg-replay-database.ts). One transaction for the whole history
11
10
  * filled Postgres's lock table and left a seed's deferred trigger events pending, and devUrl's own
12
11
  * database lent the history what it held. Committed, a statement whose scope is the server or
13
12
  * another database would outlive the replay on devUrl's server, the target's own when they share
@@ -29,19 +28,19 @@
29
28
  * Bare runs only a statement a block refuses (an outside-only class, a DO block or CALL that
30
29
  * commits), one only a block runs (a LOCK, a SAVEPOINT, a cursor not held) and the file's own
31
30
  * block, and a change one makes past its database has committed when the read sees it: the refusal
32
- * names what devUrl's server keeps. The read is the server's, another session's work in it too, so
33
- * a change there is the file's only when the role, database or other object it is to is one the
34
- * file names: a name it spells, unquoted and folded as Postgres folds it, quoted and exact, or
35
- * inside a string a DO body, dblink_exec or format() may run (namesIn in sql-code.ts). That change
36
- * is refused, naming it, and a role it added is dropped with the cleanup, as is a database it added
37
- * that no session is in; a change to any other object is another session's and passes. A name the
38
- * file builds at run time, by concatenation, from a query, or in a function another file defines,
39
- * is none it spells, so comb neither refuses nor drops that change. A role's password reads from
40
- * pg_authid alone, so a password a bare statement changes is seen by a superuser's devUrl only.
31
+ * names what devUrl's server keeps. The read is the server's, another session's work in it too, and
32
+ * Postgres keeps no creator for a role or a database, so a change there is the file's only beside a
33
+ * statement that can run code comb cannot read (pg-replay-hidden.ts), and only when it is to an
34
+ * object the file names: a name it spells, unquoted and folded as Postgres folds it, quoted and
35
+ * exact, or inside a string a DO body, dblink_exec or format() may run (namesIn in sql-code.ts).
36
+ * That change is refused, naming it, and left, as another session may have made it; any other is
37
+ * another session's and passes. A name the file builds at run time, by concatenation, from a query,
38
+ * or in a function another file defines, is none it spells, so comb does not refuse that change. A
39
+ * role's password reads from pg_authid alone, so a bare statement's is seen by a superuser's only.
41
40
  *
42
- * A SIGINT or a SIGTERM, a Ctrl-C or a cancelled CI job, skips a `finally`, so either runs the
43
- * replay's cleanup first, once, and then ends the process as the signal would; a run
44
- * killed past it is swept by the next replay or apply on the server.
41
+ * A SIGINT or a SIGTERM, a Ctrl-C or a cancelled CI job, skips a `finally`, so either runs the replay's
42
+ * cleanup first, once, and then ends the process as the signal would; a run killed past it is swept
43
+ * by the next replay or apply on the server.
45
44
  */
46
45
  import { sqlIdentifier } from "../../codegen/utils/case.ts"
47
46
  import { type PgTarget, refuseUrlProblems } from "./atlas-target.ts"
@@ -65,6 +64,7 @@ import { prepareSnapshot, snapshot } from "./pg-server-snapshot.ts"
65
64
  import { refuseUncounted } from "./pg-server-writes.ts"
66
65
  import { pgRead } from "./pg-split.ts"
67
66
  import { namesIn } from "./sql-code.ts"
67
+ import { nothingHidden, readStatement } from "./pg-replay-hidden.ts"
68
68
 
69
69
  /** A history file as baseline reads it */
70
70
  type HistoryFile = { name: string; sql: string }
@@ -92,12 +92,14 @@ async function replayFile(run: Run, { name, sql: text }: HistoryFile, replay: Re
92
92
  const inFile = !outsideTransaction(text)
93
93
  const file = {
94
94
  adopted: (role: string) => replay.adopted?.(name, role),
95
+ hidden: nothingHidden(),
95
96
  name,
96
97
  names: namesIn(text),
97
98
  stops: replay.stops,
98
99
  }
99
100
  const statements = pgRead(text).statements.map(({ sent, tree }) => ({
100
101
  sent,
102
+ tree,
101
103
  ...(tree === undefined ? { node: {}, type: "" } : (([type, node]) => ({ node, type }))(nodeOf(tree))),
102
104
  }))
103
105
  const locks = lockReader(
@@ -110,13 +112,14 @@ async function replayFile(run: Run, { name, sql: text }: HistoryFile, replay: Re
110
112
  await begin(run)
111
113
  await locks.began()
112
114
  }
113
- for (const [index, { node, sent, type }] of statements.entries()) {
115
+ for (const [index, { node, sent, tree, type }] of statements.entries()) {
114
116
  const beyond = (await runsOnMade(run, type, node)) ? undefined : beyondDatabase(type, node)
115
117
  if (beyond !== undefined) {
116
118
  leaveOutTablespace(run.tablespaces, type, node)
117
119
  replay.leftOut(name, sent, beyond)
118
120
  continue
119
121
  }
122
+ readStatement(tree, file.hidden)
120
123
  await locks.next(index, type, node)
121
124
  const send = () => runStatement(run, { name, node, sent, type }, replay)
122
125
  if (inFile) await send()
@@ -206,7 +209,6 @@ async function opening(
206
209
  owner,
207
210
  seen: new Map(),
208
211
  sql: open(),
209
- strays: { databases: [], roles: [], slots: [] },
210
212
  tablespaces: new Set(),
211
213
  ...(await devRoleOf(admin)),
212
214
  }