@workerstack/database 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -30
- package/docs/meta-contract.md +1 -1
- package/package.json +3 -2
- package/src/cli/args.ts +4 -2
- package/src/cli/codegen.ts +2 -2
- package/src/cli/comb-config.ts +8 -3
- package/src/cli/file-path.ts +25 -0
- package/src/cli/migrate-apply.ts +5 -21
- package/src/cli/migrate-d1.ts +5 -1
- package/src/cli/migrate-json.ts +48 -0
- package/src/cli/migrate.ts +26 -4
- package/src/formats/bcp47.ts +12 -0
- package/src/formats/catalog.ts +27 -4
- package/src/index.ts +1 -0
- package/src/migrate/core/apply.ts +63 -22
- package/src/migrate/core/atlas-apply-runs.ts +15 -26
- package/src/migrate/core/cleanup.ts +22 -10
- package/src/migrate/core/d1-engine-child.mjs +27 -11
- package/src/migrate/core/d1-history.ts +23 -26
- package/src/migrate/core/d1-wrangler-error.ts +24 -1
- package/src/migrate/core/pg-replay-commit.ts +41 -38
- package/src/migrate/core/pg-replay-drops.ts +17 -32
- package/src/migrate/core/pg-replay-hidden.ts +75 -0
- package/src/migrate/core/pg-replay-roles.ts +2 -4
- package/src/migrate/core/pg-replay.ts +21 -19
- package/src/migrate/core/pg-server-snapshot.ts +4 -24
- package/src/migrate/core/proof-readers.ts +80 -0
- package/src/migrate/core/proof-run.ts +19 -5
- package/src/migrate/core/proof-scope.ts +27 -14
- package/src/migrate/core/sql-code.ts +36 -5
- package/src/migrate/core/sql-scan.ts +7 -1
- package/src/migrate/core/sqlite-keywords.ts +70 -0
- package/src/migrate/core/stopped.ts +70 -0
- package/src/migrate/core/trigger-write.d.mts +7 -0
- package/src/migrate/core/trigger-write.mjs +42 -0
- package/src/query/cursor-mint.ts +11 -18
- package/src/query/cursor-refusals.ts +22 -11
- package/src/query/descriptions.ts +4 -9
- package/src/query/filter-parse.ts +80 -22
- package/src/query/filter-validate.ts +10 -13
- package/src/query/filter.ts +40 -18
- package/src/query/list-extras.ts +7 -10
- package/src/query/list-schema.ts +0 -2
- package/src/query/merged.ts +3 -2
- package/src/query/pagination.ts +30 -6
- package/src/query/rounded-key.ts +5 -8
- package/src/query/schema-params.ts +12 -5
- package/src/query/select-validate.ts +7 -4
- package/src/query/sqlite/cursor-fit.ts +10 -2
- package/src/query/trusted.ts +7 -2
- package/types/cli/args.d.ts.map +1 -1
- package/types/cli/comb-config.d.ts +5 -2
- package/types/cli/comb-config.d.ts.map +1 -1
- package/types/cli/file-path.d.ts +11 -0
- package/types/cli/file-path.d.ts.map +1 -0
- package/types/cli/migrate-apply.d.ts +1 -0
- package/types/cli/migrate-apply.d.ts.map +1 -1
- package/types/cli/migrate-d1.d.ts.map +1 -1
- package/types/cli/migrate-json.d.ts +4 -0
- package/types/cli/migrate-json.d.ts.map +1 -0
- package/types/cli/migrate.d.ts.map +1 -1
- package/types/formats/bcp47.d.ts +9 -0
- package/types/formats/bcp47.d.ts.map +1 -0
- package/types/formats/catalog.d.ts.map +1 -1
- package/types/index.d.ts +1 -0
- package/types/index.d.ts.map +1 -1
- package/types/migrate/core/apply.d.ts +3 -2
- package/types/migrate/core/apply.d.ts.map +1 -1
- package/types/migrate/core/atlas-apply-runs.d.ts +6 -0
- package/types/migrate/core/atlas-apply-runs.d.ts.map +1 -1
- package/types/migrate/core/cleanup.d.ts +16 -4
- package/types/migrate/core/cleanup.d.ts.map +1 -1
- package/types/migrate/core/d1-history.d.ts.map +1 -1
- package/types/migrate/core/d1-wrangler-error.d.ts +11 -0
- package/types/migrate/core/d1-wrangler-error.d.ts.map +1 -1
- package/types/migrate/core/pg-replay-commit.d.ts +14 -9
- package/types/migrate/core/pg-replay-commit.d.ts.map +1 -1
- package/types/migrate/core/pg-replay-drops.d.ts +7 -12
- package/types/migrate/core/pg-replay-drops.d.ts.map +1 -1
- package/types/migrate/core/pg-replay-hidden.d.ts +36 -0
- package/types/migrate/core/pg-replay-hidden.d.ts.map +1 -0
- package/types/migrate/core/pg-replay-roles.d.ts +2 -4
- package/types/migrate/core/pg-replay-roles.d.ts.map +1 -1
- package/types/migrate/core/pg-replay.d.ts.map +1 -1
- package/types/migrate/core/pg-server-snapshot.d.ts +4 -11
- package/types/migrate/core/pg-server-snapshot.d.ts.map +1 -1
- package/types/migrate/core/proof-readers.d.ts +19 -0
- package/types/migrate/core/proof-readers.d.ts.map +1 -0
- package/types/migrate/core/proof-run.d.ts.map +1 -1
- package/types/migrate/core/proof-scope.d.ts +15 -7
- package/types/migrate/core/proof-scope.d.ts.map +1 -1
- package/types/migrate/core/sql-code.d.ts +7 -3
- package/types/migrate/core/sql-code.d.ts.map +1 -1
- package/types/migrate/core/sql-scan.d.ts.map +1 -1
- package/types/migrate/core/sqlite-keywords.d.ts +11 -0
- package/types/migrate/core/sqlite-keywords.d.ts.map +1 -0
- package/types/migrate/core/stopped.d.ts +23 -0
- package/types/migrate/core/stopped.d.ts.map +1 -0
- package/types/query/cursor-mint.d.ts.map +1 -1
- package/types/query/cursor-refusals.d.ts +17 -1
- package/types/query/cursor-refusals.d.ts.map +1 -1
- package/types/query/descriptions.d.ts +1 -1
- package/types/query/descriptions.d.ts.map +1 -1
- package/types/query/filter-parse.d.ts +15 -4
- package/types/query/filter-parse.d.ts.map +1 -1
- package/types/query/filter-validate.d.ts +1 -1
- package/types/query/filter-validate.d.ts.map +1 -1
- package/types/query/filter.d.ts +9 -1
- package/types/query/filter.d.ts.map +1 -1
- package/types/query/list-extras.d.ts.map +1 -1
- package/types/query/list-schema.d.ts.map +1 -1
- package/types/query/merged.d.ts.map +1 -1
- package/types/query/pagination.d.ts +13 -1
- package/types/query/pagination.d.ts.map +1 -1
- package/types/query/rounded-key.d.ts +2 -11
- package/types/query/rounded-key.d.ts.map +1 -1
- package/types/query/schema-params.d.ts.map +1 -1
- package/types/query/select-validate.d.ts.map +1 -1
- package/types/query/sqlite/cursor-fit.d.ts.map +1 -1
- package/types/query/trusted.d.ts +1 -1
- package/types/query/trusted.d.ts.map +1 -1
package/src/cli/migrate.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** `comb migrate`: init, diff, check, apply, baseline and status. */
|
|
2
2
|
import fs from "node:fs"
|
|
3
3
|
import path from "node:path"
|
|
4
|
-
import { parseArgs, refuseFurther } from "./args.ts"
|
|
4
|
+
import { parseArgs, refuseFurther, usageOf } from "./args.ts"
|
|
5
5
|
import { binOf } from "../migrate/core/optional-peer.ts"
|
|
6
6
|
import { declaredFts, ftsSql } from "./comb-config.ts"
|
|
7
7
|
import {
|
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
} from "./drizzle-dialect.ts"
|
|
15
15
|
import { migrateApply, needPeers } from "./migrate-apply.ts"
|
|
16
16
|
import { type MigrateTarget, refuseBlank, refuseNoDir, resolveMigrateTarget } from "./migrate-target.ts"
|
|
17
|
-
import {
|
|
17
|
+
import { refusePathName } from "./file-path.ts"
|
|
18
|
+
import { CliError, type CliContext, ShowHelp } from "./output.ts"
|
|
18
19
|
import { scratchDir } from "../migrate/core/cleanup.ts"
|
|
19
20
|
|
|
20
21
|
const DRIZZLE_CONFIG = `import { defineConfig } from "drizzle-kit"
|
|
@@ -100,10 +101,12 @@ function migrateInit(argv: readonly string[], io: CliContext): void {
|
|
|
100
101
|
* database's dialect, directory and drizzle config come from it; without, it is SQLite in
|
|
101
102
|
* ./atlas-migrations.
|
|
102
103
|
*/
|
|
103
|
-
async function
|
|
104
|
+
async function runDiff(argv: readonly string[], io: CliContext): Promise<void> {
|
|
104
105
|
const args = parseArgs(argv, "migrate diff")
|
|
105
106
|
/* parseArgs refuses a diff that names no database */
|
|
106
107
|
const [db, name] = args.positionals as [string, (string | undefined)?]
|
|
108
|
+
/* Atlas makes the file's name from it: a path would land outside the directory, or nowhere */
|
|
109
|
+
if (name !== undefined) refusePathName(name, "Migration name", usageOf("migrate diff"))
|
|
107
110
|
const env = args.options["env"] as string | undefined
|
|
108
111
|
const { d1, expected, exported, fts, migrationsDir, pg, provider, target } = await diffTarget(db, env, io)
|
|
109
112
|
/* a diff starts a missing directory, and refuses a file before anything runs */
|
|
@@ -229,7 +232,26 @@ function reportDrift(db: string, files: readonly string[], io: CliContext): neve
|
|
|
229
232
|
io.out(`-- ${path.basename(file)}`)
|
|
230
233
|
io.out(fs.readFileSync(file, "utf8"))
|
|
231
234
|
}
|
|
232
|
-
throw new
|
|
235
|
+
throw new Drifted(`${db} has drifted from its migrations: comb migrate diff ${db} would write the SQL above`)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A dry run's refusal for drift, which exits 1 */
|
|
239
|
+
class Drifted extends CliError {}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `migrate diff`. A dry run is a drift gate, so it exits 1 for drift alone, and 2 for every failure
|
|
243
|
+
* that kept it from checking, the argv's own refusal among them: --dry-run is read from argv, as
|
|
244
|
+
* the CLI reads --json, so that refusal knows it is a dry run's
|
|
245
|
+
*/
|
|
246
|
+
async function migrateDiff(argv: readonly string[], io: CliContext): Promise<void> {
|
|
247
|
+
if (!argv.includes("--dry-run")) return runDiff(argv, io)
|
|
248
|
+
try {
|
|
249
|
+
await runDiff(argv, io)
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (error instanceof Drifted || error instanceof ShowHelp) throw error
|
|
252
|
+
/* an Error: comb throws only Errors, and a config's load wraps whatever the config throws (migrate/config.ts) */
|
|
253
|
+
throw new CliError((error as Error).message, 2, { cause: error })
|
|
254
|
+
}
|
|
233
255
|
}
|
|
234
256
|
|
|
235
257
|
export async function cmdMigrate(argv: readonly string[], io: CliContext, help: string): Promise<void> {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { formatTakes } from "./catalog.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether `value` is a well-formed BCP 47 language tag, RFC 5646 §2.1's Language-Tag, exactly as
|
|
5
|
+
* written: a langtag such as zh-Hant-TW or es-419, a private-use tag such as x-private, or a
|
|
6
|
+
* grandfathered tag such as i-klingon. It takes what a `format: "bcp47"` column takes before its
|
|
7
|
+
* trim, from the same grammar: it never trims or folds case, so " en" is no tag. A value that is no
|
|
8
|
+
* string at runtime is refused, and this never throws.
|
|
9
|
+
*/
|
|
10
|
+
export function isBcp47(value: string): boolean {
|
|
11
|
+
return typeof value === "string" && formatTakes("bcp47", value)
|
|
12
|
+
}
|
package/src/formats/catalog.ts
CHANGED
|
@@ -98,13 +98,28 @@ const LOWERCASE: Transforms = ["trim", "lowercase"]
|
|
|
98
98
|
const UPPERCASE: Transforms = ["trim", "uppercase"]
|
|
99
99
|
|
|
100
100
|
/*
|
|
101
|
-
* RFC 5646 §2.1's
|
|
102
|
-
* and private use,
|
|
101
|
+
* RFC 5646 §2.1's Language-Tag, each class case-insensitive: a langtag (language with up to three
|
|
102
|
+
* extlangs, script, region, variants, extensions and private use), a private-use tag alone, or an
|
|
103
|
+
* irregular grandfathered tag; the regular grandfathered tags, such as zh-min-nan, are langtags
|
|
103
104
|
*/
|
|
104
105
|
const LANGUAGE = "(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8})"
|
|
105
106
|
const VARIANTS = "(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*"
|
|
106
107
|
const EXTENSIONS = "(?:-[0-9A-WYZa-wyz](?:-[A-Za-z0-9]{2,8})+)*"
|
|
107
|
-
const
|
|
108
|
+
const PRIVATE_USE = "[Xx](?:-[A-Za-z0-9]{1,8})+"
|
|
109
|
+
const LANGTAG = `${LANGUAGE}(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?${VARIANTS}${EXTENSIONS}(?:-${PRIVATE_USE})?`
|
|
110
|
+
/* RFC 5646 §2.1's irregular grandfathered tags, which match no langtag */
|
|
111
|
+
const IRREGULAR =
|
|
112
|
+
"en-GB-oed i-ami i-bnn i-default i-enochian i-hak i-klingon i-lux i-mingo i-navajo i-pwn i-tao i-tay i-tsu sgn-BE-FR sgn-BE-NL sgn-CH-DE"
|
|
113
|
+
|
|
114
|
+
/** `tag` with each letter a class of its two cases, as the rest of the grammar reads them */
|
|
115
|
+
const anyCase = (tag: string): string =>
|
|
116
|
+
tag.replace(/[A-Za-z]/gu, (letter) => `[${letter.toUpperCase()}${letter.toLowerCase()}]`)
|
|
117
|
+
|
|
118
|
+
/** RFC 5646 §2.1's Language-Tag, anchored: langtag / privateuse / grandfathered */
|
|
119
|
+
function languageTagSource(): string {
|
|
120
|
+
const grandfathered = IRREGULAR.split(" ").map(anyCase).join("|")
|
|
121
|
+
return `^(?:${LANGTAG}|${PRIVATE_USE}|${grandfathered})$`
|
|
122
|
+
}
|
|
108
123
|
|
|
109
124
|
/* semver.org 2.0.0's version pattern, its groups non-capturing */
|
|
110
125
|
const NUMBER = "(?:0|[1-9][0-9]*)"
|
|
@@ -194,7 +209,15 @@ function specs(): { readonly [F in TextFormat]: FormatSpec } {
|
|
|
194
209
|
return {
|
|
195
210
|
base64url: spec(["RFC 4648 §5 base64url, unpadded", UNPADDED, 0, null, TRIM, null, "dGVzdA"]),
|
|
196
211
|
/* RFC 5646 §4.4.1's 35 is the buffer an implementation must hold, not a cap on a tag */
|
|
197
|
-
bcp47: spec([
|
|
212
|
+
bcp47: spec([
|
|
213
|
+
"BCP 47 language tag, RFC 5646 §2.1 well-formed",
|
|
214
|
+
languageTagSource(),
|
|
215
|
+
2,
|
|
216
|
+
null,
|
|
217
|
+
TRIM,
|
|
218
|
+
null,
|
|
219
|
+
"zh-Hant-TW",
|
|
220
|
+
]),
|
|
198
221
|
e164: spec(["ITU-T E.164 phone number, + and up to 15 digits", "^\\+[1-9][0-9]{1,14}$", 3, 16, TRIM, null, PHONE]),
|
|
199
222
|
"iana-time-zone": spec(["IANA time zone name, tzdata 2026f", ZONE_NAME, 1, 64, TRIM, "isIanaTimeZone", ZONE]),
|
|
200
223
|
ip: spec([ADDRESS, `^(?:${ipv4}|${ipv6})$`, 2, 45, LOWERCASE, null, IPV6]),
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { isCidr } from "./cidr.ts"
|
|
2
|
+
export { isBcp47 } from "./formats/bcp47.ts"
|
|
2
3
|
export type { IntegerFormat, TextFormat } from "./formats/catalog.ts"
|
|
3
4
|
export { isIanaTimeZone } from "./formats/iana-time-zone.ts"
|
|
4
5
|
export { isIso3166Alpha2, isIso3166Alpha3 } from "./formats/iso3166-1.ts"
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { chainText } from "../../error-chain.ts"
|
|
8
8
|
import type { Connection } from "../types.ts"
|
|
9
|
+
import { pastStop } from "./cleanup.ts"
|
|
9
10
|
import { HASH_VERSION } from "./parse.ts"
|
|
10
11
|
import { Refused } from "./refused.ts"
|
|
12
|
+
import { whileStoppable } from "./stopped.ts"
|
|
11
13
|
import { isTransient } from "./transient.ts"
|
|
12
14
|
import { type RetryOptions, withRetry } from "./utils.ts"
|
|
13
15
|
|
|
@@ -24,19 +26,39 @@ const TABLE = `(
|
|
|
24
26
|
hash_v INTEGER
|
|
25
27
|
)`
|
|
26
28
|
|
|
27
|
-
/** Whether a unique index of comb's history holds
|
|
28
|
-
|
|
29
|
+
/** Whether a unique index of comb's history holds `column` alone */
|
|
30
|
+
async function uniqueOn(conn: Connection, column: string): Promise<boolean> {
|
|
29
31
|
const list = await conn.execute("PRAGMA index_list(__drizzle_migrations)")
|
|
30
32
|
const unique = list.rows.filter((row) => Number(row[list.columns.indexOf("unique")]) === 1)
|
|
31
33
|
for (const index of unique) {
|
|
32
34
|
const name = String(index[list.columns.indexOf("name")])
|
|
33
35
|
const info = await conn.execute(`PRAGMA index_info("${name.replaceAll('"', '""')}")`)
|
|
34
36
|
const columns = info.rows.map((row) => String(row[info.columns.indexOf("name")]))
|
|
35
|
-
if (columns.length === 1 && columns[0] ===
|
|
37
|
+
if (columns.length === 1 && columns[0] === column) return true
|
|
36
38
|
}
|
|
37
39
|
return false
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/** Whether a unique index of comb's history holds its hash alone, as comb's table did before 0.13 */
|
|
43
|
+
export const hashUnique = (conn: Connection): Promise<boolean> => uniqueOn(conn, "hash")
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A name recorded once, whoever made the table: a history drizzle's migrator made gains `name` by ALTER
|
|
47
|
+
* and no UNIQUE with it, so nothing would refuse a second run's record of a file the first applied.
|
|
48
|
+
* Refused, naming the files, when the history already holds one twice
|
|
49
|
+
*/
|
|
50
|
+
async function keepNameUnique(conn: Connection): Promise<void> {
|
|
51
|
+
if (await uniqueOn(conn, "name")) return
|
|
52
|
+
const twice = await conn.execute(
|
|
53
|
+
"SELECT name FROM __drizzle_migrations WHERE name IS NOT NULL GROUP BY name HAVING count(*) > 1 ORDER BY name",
|
|
54
|
+
)
|
|
55
|
+
if (twice.rows.length > 0)
|
|
56
|
+
throw new Error(
|
|
57
|
+
`__drizzle_migrations records ${twice.rows.map((row) => String(row[0])).join(", ")} more than once, so comb cannot keep a file from being recorded twice: delete the extra rows of each (keep the first), then run it again`,
|
|
58
|
+
)
|
|
59
|
+
await conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS __drizzle_migrations_name ON __drizzle_migrations (name)")
|
|
60
|
+
}
|
|
61
|
+
|
|
40
62
|
/**
|
|
41
63
|
* comb's history, made where it is missing, named from 0.11, and from a history before 0.13
|
|
42
64
|
* rebuilt in one batch: every row and id kept, each row `renamed` lists taking its new name,
|
|
@@ -55,7 +77,7 @@ export async function ensureMigrationsTable(
|
|
|
55
77
|
/* a history 0.20 or earlier wrote gains it too, every row it holds left without it */
|
|
56
78
|
if (!has("hash_v")) await conn.execute("ALTER TABLE __drizzle_migrations ADD COLUMN hash_v INTEGER")
|
|
57
79
|
/* a history from before 0.13 holds each hash once: rebuilt, in one batch, with every row and id kept */
|
|
58
|
-
if (!(await hashUnique(conn))) return
|
|
80
|
+
if (!(await hashUnique(conn))) return keepNameUnique(conn)
|
|
59
81
|
/* a history made without AUTOINCREMENT has no counter; its largest id is where the new one starts */
|
|
60
82
|
const sequenced = await conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'sqlite_sequence'")
|
|
61
83
|
const counted =
|
|
@@ -151,7 +173,8 @@ class Unconfirmed extends Error {}
|
|
|
151
173
|
* record it wrote says it ran, so a retry reads the history first and runs nothing again.
|
|
152
174
|
* When no read of `history` answers after a batch that failed transiently, the failure is an Unconfirmed.
|
|
153
175
|
* The record goes first, so a batch whose file another run recorded while it waited for the lock
|
|
154
|
-
* fails on the name at once and runs nothing
|
|
176
|
+
* fails on the name's unique index at once and runs nothing (ensureMigrationsTable keeps one on every
|
|
177
|
+
* history, whoever made the table); the history then says so, and it is that run's.
|
|
155
178
|
* Returns who applied it.
|
|
156
179
|
*/
|
|
157
180
|
export async function applyRecorded(
|
|
@@ -207,24 +230,42 @@ export async function applyEach(
|
|
|
207
230
|
{ history, print, retry }: ApplyOptions & { print: (line: string) => void },
|
|
208
231
|
): Promise<void> {
|
|
209
232
|
const names = migrations.map(({ filename }) => filename)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
233
|
+
/* the files that committed, and the batch under way, which a stop waits for: it commits or fails whole */
|
|
234
|
+
let done = 0
|
|
235
|
+
let batching: Promise<unknown> = Promise.resolve()
|
|
236
|
+
const read = async () => {
|
|
237
|
+
await batching.catch(() => {
|
|
238
|
+
// reason: a batch that failed committed nothing, which the count it left already says
|
|
239
|
+
})
|
|
240
|
+
return { applied: names.slice(0, done), pending: names.slice(done) }
|
|
241
|
+
}
|
|
242
|
+
await whileStoppable({ names, print, read }, async () => {
|
|
243
|
+
for (const [at, migration] of migrations.entries()) {
|
|
244
|
+
/* a stop parks the run here, whichever connection the next file would open */
|
|
245
|
+
await pastStop()
|
|
246
|
+
print(`Apply: ${migration.filename}`)
|
|
214
247
|
try {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
248
|
+
const conn = await open()
|
|
249
|
+
try {
|
|
250
|
+
/* counted with the batch, so a stop that waited for it reads the count it made */
|
|
251
|
+
const batch = applyRecorded(conn, migration, { history, retry }).then((who) => {
|
|
252
|
+
done++
|
|
253
|
+
return who
|
|
254
|
+
})
|
|
255
|
+
batching = batch
|
|
256
|
+
if ((await batch) === "another run") print(`Skip (applied by another run): ${migration.filename}`)
|
|
257
|
+
} finally {
|
|
258
|
+
await conn.close()
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
const applied = names.slice(0, at)
|
|
262
|
+
if (applied.length > 0) print(`Applied before the failure: ${applied.join(", ")}`)
|
|
263
|
+
/* withRetry throws an Error, whatever the batch threw; so does a connection that would not open */
|
|
264
|
+
const message = (error as Error).message
|
|
265
|
+
if (error instanceof Unconfirmed)
|
|
266
|
+
throw new Refused(message, { applied, pending: names.slice(at + 1), unknown: [migration.filename] })
|
|
267
|
+
throw new Refused(message, { applied, pending: names.slice(at) })
|
|
219
268
|
}
|
|
220
|
-
} catch (error) {
|
|
221
|
-
const done = names.slice(0, at)
|
|
222
|
-
if (done.length > 0) print(`Applied before the failure: ${done.join(", ")}`)
|
|
223
|
-
/* withRetry throws an Error, whatever the batch threw; so does a connection that would not open */
|
|
224
|
-
const message = (error as Error).message
|
|
225
|
-
if (error instanceof Unconfirmed)
|
|
226
|
-
throw new Refused(message, { applied: done, pending: names.slice(at + 1), unknown: [migration.filename] })
|
|
227
|
-
throw new Refused(message, { applied: done, pending: names.slice(at) })
|
|
228
269
|
}
|
|
229
|
-
}
|
|
270
|
+
})
|
|
230
271
|
}
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* reading of a file proves what it leaves on its session: a trigger, a default, a DO block's EXECUTE
|
|
5
5
|
* or code the target holds can each run a SET that outlives its COMMIT.
|
|
6
6
|
*/
|
|
7
|
-
import { hold } from "./cleanup.ts"
|
|
8
7
|
import { type Foreign, ownHistory } from "./atlas-foreign.ts"
|
|
9
8
|
import { type AtlasFile, type AtlasStatus, checkHistory } from "./atlas-history.ts"
|
|
10
9
|
import { APPLY_FORMAT, NON_LINEAR, reported } from "./atlas-report.ts"
|
|
11
10
|
import { atlas, type AtlasCommand, atlasStatus } from "./atlas-status.ts"
|
|
11
|
+
import { whileStoppable } from "./stopped.ts"
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* After an Atlas call that ran no file and said nothing, once this apply ran `applied`: done once
|
|
@@ -29,27 +29,17 @@ async function ranNothing(
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
33
|
-
* runs a child's step before this
|
|
34
|
-
* files it records and which it does not, printed; no further call starts, and the process then ends
|
|
35
|
-
* by the signal
|
|
32
|
+
* The approved files the history records and those it does not, read once Atlas's call ended after a
|
|
33
|
+
* stop signal (cleanup.ts runs a child's step before this apply's): what stopped.ts reports
|
|
36
34
|
*/
|
|
37
|
-
const
|
|
35
|
+
const found =
|
|
38
36
|
(options: AtlasCommand, copy: string, { approved, foreign }: { approved: ReadonlySet<string>; foreign: Foreign }) =>
|
|
39
|
-
async (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
[...approved].filter((name) => left.has(name)),
|
|
46
|
-
]
|
|
47
|
-
options.print(
|
|
48
|
-
`Stopped by ${signal}: applied ${ran.join(", ") || "none"}; not applied ${not.join(", ") || "none"}`,
|
|
49
|
-
)
|
|
50
|
-
} catch (error) {
|
|
51
|
-
// reason: a history read that fails must not hide the stop: the line says what comb could not read, and the process still ends by the signal
|
|
52
|
-
options.print(`Stopped by ${signal}: comb could not read what was applied: ${(error as Error).message}`)
|
|
37
|
+
async (): Promise<{ applied: string[]; pending: string[] }> => {
|
|
38
|
+
const { pending } = ownHistory(await atlasStatus(options, copy), foreign, copy)
|
|
39
|
+
const left = new Set(pending.map((file) => file.Name))
|
|
40
|
+
return {
|
|
41
|
+
applied: [...approved].filter((name) => !left.has(name)),
|
|
42
|
+
pending: [...approved].filter((name) => left.has(name)),
|
|
53
43
|
}
|
|
54
44
|
}
|
|
55
45
|
|
|
@@ -67,12 +57,11 @@ async function applyRuns(
|
|
|
67
57
|
{ foreign, report, status }: { foreign: Foreign; report: Parameters<typeof checkHistory>[2]; status: AtlasStatus },
|
|
68
58
|
): Promise<string[]> {
|
|
69
59
|
const approved = new Set(status.pending.map((file) => file.Name))
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
60
|
+
/* no further call starts, and the process then ends by the signal, after the report: a stop parks this loop */
|
|
61
|
+
return whileStoppable(
|
|
62
|
+
{ names: [...approved], print: options.print, read: found(options, copy, { approved, foreign }) },
|
|
63
|
+
() => callEach(options, copy, { approved, foreign, report }),
|
|
64
|
+
)
|
|
76
65
|
}
|
|
77
66
|
|
|
78
67
|
/** applyRuns' calls, each of the first file still pending, until every approved file ran */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* comb's one cleanup rule: what a run makes outside itself (a scratch
|
|
3
|
-
* directory, a replay database) is held here until the run lets it go, and
|
|
3
|
+
* directory, a replay database) is held here until the run lets it go, and a stop signal runs
|
|
4
4
|
* every step still held before the process ends as the signal would. A scratch directory is named
|
|
5
5
|
* for the process that made it, so a later run removes one a killed process left at once, by
|
|
6
6
|
* whether that process still runs.
|
|
@@ -11,14 +11,25 @@ import fs from "node:fs"
|
|
|
11
11
|
import os from "node:os"
|
|
12
12
|
import path from "node:path"
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* A step a signal runs, told which signal came and what the steps that ran before it returned, in
|
|
16
|
+
* that order: a step held first runs last, and reads what the steps held after it found out
|
|
17
|
+
*/
|
|
18
|
+
type Step = (signal: NodeJS.Signals, earlier: readonly unknown[]) => unknown
|
|
16
19
|
|
|
17
20
|
// per-process-constant: what this process holds now
|
|
18
21
|
const held = /* @__PURE__ */ new Set<Step>()
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The signals a held step runs on: every one whose default action ends the process and that a
|
|
25
|
+
* terminal or a supervisor sends. A Ctrl-C (SIGINT), a Ctrl-\ (SIGQUIT), a container stop or a CI
|
|
26
|
+
* step timeout (SIGTERM), and a terminal's hangup, as an SSH drop sends it (SIGHUP): a child in a
|
|
27
|
+
* group of its own gets none of them from the terminal, so comb's forward is its only signal.
|
|
28
|
+
*/
|
|
29
|
+
export const STOP_SIGNALS = ["SIGHUP", "SIGINT", "SIGQUIT", "SIGTERM"] as const
|
|
30
|
+
|
|
31
|
+
/** One of STOP_SIGNALS */
|
|
32
|
+
export type StopSignal = (typeof STOP_SIGNALS)[number]
|
|
22
33
|
|
|
23
34
|
// per-process-constant: the steps a signal is running, so a step's own work goes on while what it stops waits
|
|
24
35
|
const inStep = /* @__PURE__ */ new AsyncLocalStorage<true>()
|
|
@@ -45,9 +56,10 @@ function newGate(): Gate {
|
|
|
45
56
|
|
|
46
57
|
/** Every step, last held first, each once the one before ends: a child stops before the scratch it used goes */
|
|
47
58
|
async function runSteps(steps: readonly Step[], signal: NodeJS.Signals): Promise<void> {
|
|
59
|
+
const returned: unknown[] = []
|
|
48
60
|
for (const step of steps.toReversed()) {
|
|
49
61
|
try {
|
|
50
|
-
await step(signal)
|
|
62
|
+
returned.push(await step(signal, [...returned]))
|
|
51
63
|
} catch {
|
|
52
64
|
// reason: one step that fails must not keep the rest from cleaning up; the process ends either way
|
|
53
65
|
}
|
|
@@ -77,7 +89,7 @@ function stop(signal: NodeJS.Signals): void {
|
|
|
77
89
|
gate = undefined
|
|
78
90
|
if (process.listenerCount(signal) > 1) return current.let()
|
|
79
91
|
/* both gone before the signal comes again, so it ends the process, and what waits for this stop waits on */
|
|
80
|
-
for (const each of
|
|
92
|
+
for (const each of STOP_SIGNALS) process.off(each, stop)
|
|
81
93
|
process.kill(process.pid, signal)
|
|
82
94
|
})
|
|
83
95
|
}
|
|
@@ -101,11 +113,11 @@ export function pastStop(): Promise<void> {
|
|
|
101
113
|
}
|
|
102
114
|
|
|
103
115
|
/**
|
|
104
|
-
* Hold `step` until the release this returns, so
|
|
116
|
+
* Hold `step` until the release this returns, so a stop signal runs it before the process ends.
|
|
105
117
|
* The listener is read from the process, not remembered: a host that removed every listener gets it back.
|
|
106
118
|
*/
|
|
107
119
|
export function hold(step: Step): () => void {
|
|
108
|
-
for (const signal of
|
|
120
|
+
for (const signal of STOP_SIGNALS) if (!process.listeners(signal).includes(stop)) process.on(signal, stop)
|
|
109
121
|
held.add(step)
|
|
110
122
|
return () => void held.delete(step)
|
|
111
123
|
}
|
|
@@ -199,7 +211,7 @@ export type Scratch = { dir: string; remove(): void }
|
|
|
199
211
|
|
|
200
212
|
/**
|
|
201
213
|
* A new directory under the temp directory that only this user can open (0700), named `prefix`, this
|
|
202
|
-
* process's pid and host mark, and removed by `remove`, or by
|
|
214
|
+
* process's pid and host mark, and removed by `remove`, or by a stop signal before it. A
|
|
203
215
|
* process's first scratch of a prefix sweeps what killed runs left.
|
|
204
216
|
*/
|
|
205
217
|
export function scratchDir(prefix: string): Scratch {
|
|
@@ -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
|
|
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
|
|
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) =>
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|