pipework 0.274.0 → 0.276.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/REFERENCE.md +10 -3
  3. package/dist/cli/commands/check.d.ts +8 -1
  4. package/dist/cli/commands/check.d.ts.map +1 -1
  5. package/dist/cli/commands/check.js +8 -5
  6. package/dist/cli/commands/check.js.map +1 -1
  7. package/dist/cli/commands/lint-settings.d.ts +37 -0
  8. package/dist/cli/commands/lint-settings.d.ts.map +1 -0
  9. package/dist/cli/commands/lint-settings.js +196 -0
  10. package/dist/cli/commands/lint-settings.js.map +1 -0
  11. package/dist/cli/commands/lint.d.ts.map +1 -1
  12. package/dist/cli/commands/lint.js +75 -18
  13. package/dist/cli/commands/lint.js.map +1 -1
  14. package/dist/cli/index.d.ts.map +1 -1
  15. package/dist/cli/index.js +3 -1
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/data/db/pool.d.ts +6 -0
  18. package/dist/data/db/pool.d.ts.map +1 -1
  19. package/dist/data/db/pool.js +23 -0
  20. package/dist/data/db/pool.js.map +1 -1
  21. package/dist/infra/credits/schema.d.ts +1 -1
  22. package/dist/request/context/run-in-transaction.d.ts.map +1 -1
  23. package/dist/request/context/run-in-transaction.js +28 -15
  24. package/dist/request/context/run-in-transaction.js.map +1 -1
  25. package/dist/request/http/aborted.d.ts +7 -0
  26. package/dist/request/http/aborted.d.ts.map +1 -1
  27. package/dist/request/http/aborted.js +20 -0
  28. package/dist/request/http/aborted.js.map +1 -1
  29. package/dist/request/http/namespace.d.ts +1 -1
  30. package/dist/request/http/namespace.d.ts.map +1 -1
  31. package/dist/request/http/namespace.js +1 -1
  32. package/dist/request/http/namespace.js.map +1 -1
  33. package/dist/request/http/server.d.ts.map +1 -1
  34. package/dist/request/http/server.js +38 -9
  35. package/dist/request/http/server.js.map +1 -1
  36. package/dist/test/plugin.d.ts.map +1 -1
  37. package/dist/test/plugin.js +1 -0
  38. package/dist/test/plugin.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/commands/check.ts +10 -6
  41. package/src/cli/commands/lint-settings.ts +218 -0
  42. package/src/cli/commands/lint.ts +70 -19
  43. package/src/cli/index.ts +3 -1
  44. package/src/data/db/pool.ts +29 -0
  45. package/src/request/context/run-in-transaction.ts +26 -17
  46. package/src/request/http/aborted.ts +19 -0
  47. package/src/request/http/namespace.ts +1 -1
  48. package/src/request/http/server.ts +37 -9
  49. package/src/test/plugin.ts +1 -0
@@ -946,7 +946,7 @@ export function resolveEslintBin(cwd: string): string {
946
946
  // the file itself changes. Stamping the cache path with pipework's own version
947
947
  // (read here from pipework's package.json) makes a bump point eslint at a fresh
948
948
  // cache file, busting the stale verdicts. #781-followup
949
- function pipeworkVersion(): string {
949
+ export function pipeworkVersion(): string {
950
950
  // dist/cli/commands/check.js → pipework root is three levels up; mirrors the
951
951
  // builtin-preset path resolution used in lintDirectory.
952
952
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'package.json')
@@ -1060,7 +1060,7 @@ export function hasFatalParseError(stdout: string): boolean {
1060
1060
  })
1061
1061
  }
1062
1062
 
1063
- function hasEslintConfig(dir: string): boolean {
1063
+ export function hasEslintConfig(dir: string): boolean {
1064
1064
  return existsSync(join(dir, 'eslint.config.js'))
1065
1065
  || existsSync(join(dir, 'eslint.config.mjs'))
1066
1066
  || existsSync(join(dir, 'eslint.config.ts'))
@@ -1435,8 +1435,10 @@ export interface LintPopulation {
1435
1435
  readonly modules: ReadonlyArray<{ readonly path: string; readonly includes: readonly string[] | undefined }>
1436
1436
  /** Root files outside every module; empty off shard 1. */
1437
1437
  readonly root: string[]
1438
- /** Every file the run lints, repo-relative and sorted. */
1438
+ /** Every file the globs reach, repo-relative and sorted; eslint may still ignore some. */
1439
1439
  readonly files: string[]
1440
+ /** Each of `files` with the directory eslint runs in for it (a module, or the root pass). */
1441
+ readonly candidates: ReadonlyArray<{ readonly file: string; readonly dir: string }>
1440
1442
  }
1441
1443
 
1442
1444
  /**
@@ -1454,15 +1456,17 @@ export function lintPopulation(
1454
1456
  const root = shard !== undefined && shard.index !== 1
1455
1457
  ? []
1456
1458
  : rootFilesOutsideModules(cwd, [...allModules], includes.for('.'))
1457
- const files = new Set<string>(root)
1459
+ const owners = new Map<string, string>(root.map(f => [f, cwd]))
1458
1460
  for (const mod of modules) {
1459
1461
  const dir = join(cwd, mod.path)
1460
1462
  const prefix = normalizeModulePath(mod.path)
1461
1463
  for (const f of filesMatchingGlobs(dir, lintIncludeGlobs(dir, mod.includes))) {
1462
- files.add(prefix === '' ? f : `${prefix}/${f}`)
1464
+ const file = prefix === '' ? f : `${prefix}/${f}`
1465
+ if (!owners.has(file)) owners.set(file, dir)
1463
1466
  }
1464
1467
  }
1465
- return { modules, root, files: [...files].sort() }
1468
+ const files = [...owners.keys()].sort()
1469
+ return { modules, root, files, candidates: files.map(file => ({ file, dir: owners.get(file)! })) }
1466
1470
  }
1467
1471
 
1468
1472
  /**
@@ -0,0 +1,218 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+ import { lintIncludeGlobs } from '../../lint/include.js'
7
+ import { resolveLintConfig } from '../../lint/resolver.js'
8
+ import { hasEslintConfig, pipeworkVersion, type LintPopulation } from './check.js'
9
+
10
+ interface EslintApi {
11
+ isPathIgnored(file: string): Promise<boolean>
12
+ calculateConfigForFile(file: string): Promise<unknown>
13
+ }
14
+ type EslintCtor = new (options: Record<string, unknown>) => EslintApi
15
+
16
+ export interface FileCoverage {
17
+ readonly file: string
18
+ readonly covered: boolean
19
+ readonly reason?: 'ignored' | 'no matching configuration'
20
+ }
21
+
22
+ export interface ModuleDigest {
23
+ readonly module: string
24
+ readonly digest: string
25
+ readonly inputs: Record<string, unknown>
26
+ }
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url))
29
+
30
+ function resolveFrom(cwd: string, request: string): string {
31
+ return createRequire(import.meta.url).resolve(request, { paths: [cwd, here] })
32
+ }
33
+
34
+ function packageVersion(cwd: string, name: string): string {
35
+ try {
36
+ const pkg = JSON.parse(readFileSync(resolveFrom(cwd, `${name}/package.json`), 'utf-8')) as { version?: string }
37
+ return pkg.version ?? 'unknown'
38
+ } catch {
39
+ return 'unknown'
40
+ }
41
+ }
42
+
43
+ async function loadEslint(cwd: string): Promise<EslintCtor> {
44
+ const mod = await import(pathToFileURL(resolveFrom(cwd, 'eslint')).href) as { ESLint?: EslintCtor; default?: { ESLint?: EslintCtor } }
45
+ const ctor = mod.ESLint ?? mod.default?.ESLint
46
+ if (ctor === undefined) throw new Error('eslint is installed but does not export ESLint')
47
+ return ctor
48
+ }
49
+
50
+ /**
51
+ * eslint as `check` runs it in `dir`: the directory's own eslint.config.* when it has
52
+ * one, else pipework's resolved preset for that directory (what `--config preset.js`
53
+ * loads, with the same cwd).
54
+ */
55
+ async function eslintFor(ESLint: EslintCtor, dir: string): Promise<EslintApi> {
56
+ if (hasEslintConfig(dir)) return new ESLint({ cwd: dir })
57
+ return new ESLint({ cwd: dir, overrideConfigFile: true, baseConfig: await resolveLintConfig(dir) })
58
+ }
59
+
60
+ /**
61
+ * Whether eslint would lint each candidate file: not ignored, and some config block
62
+ * applies. The answer is eslint's own, asked with the config `check` uses.
63
+ */
64
+ export async function classifyCoverage(cwd: string, population: LintPopulation): Promise<FileCoverage[]> {
65
+ const ESLint = await loadEslint(cwd)
66
+ const instances = new Map<string, EslintApi>()
67
+ const out: FileCoverage[] = []
68
+ for (const { file, dir } of population.candidates) {
69
+ let eslint = instances.get(dir)
70
+ if (eslint === undefined) {
71
+ eslint = await eslintFor(ESLint, dir)
72
+ instances.set(dir, eslint)
73
+ }
74
+ const abs = resolve(cwd, file)
75
+ if (await eslint.isPathIgnored(abs)) {
76
+ out.push({ file, covered: false, reason: 'ignored' })
77
+ } else if (await eslint.calculateConfigForFile(abs) === undefined) {
78
+ out.push({ file, covered: false, reason: 'no matching configuration' })
79
+ } else {
80
+ out.push({ file, covered: true })
81
+ }
82
+ }
83
+ return out
84
+ }
85
+
86
+ function isPlainObject(value: object): boolean {
87
+ const proto = Object.getPrototypeOf(value) as unknown
88
+ return proto === Object.prototype || proto === null
89
+ }
90
+
91
+ function describeNamed(value: object): Record<string, unknown> {
92
+ const meta = (value as { meta?: { name?: unknown; version?: unknown } }).meta
93
+ const rules = (value as { rules?: unknown }).rules
94
+ return {
95
+ object: value.constructor.name,
96
+ ...(typeof meta?.name === 'string' ? { name: meta.name } : {}),
97
+ ...(typeof meta?.version === 'string' ? { version: meta.version } : {}),
98
+ ...(typeof rules === 'object' && rules !== null ? { rules: Object.keys(rules).sort() } : {}),
99
+ }
100
+ }
101
+
102
+ /**
103
+ * A JSON value with sorted keys. A function is its name, a plugin or parser object its
104
+ * `meta` and rule names: never identity, so the same settings hash the same in every run.
105
+ */
106
+ function canonical(value: unknown, seen: Set<object> = new Set()): unknown {
107
+ if (value === undefined) return null
108
+ if (typeof value === 'function') return { function: value.name }
109
+ if (typeof value === 'bigint') return String(value)
110
+ if (typeof value === 'symbol') return value.toString()
111
+ if (value instanceof RegExp) return { regexp: value.toString() }
112
+ if (typeof value !== 'object' || value === null) return value
113
+ if (seen.has(value)) return { cycle: true }
114
+ seen.add(value)
115
+ try {
116
+ if (Array.isArray(value)) return value.map(v => canonical(v, seen))
117
+ if (!isPlainObject(value)) return describeNamed(value)
118
+ const out: Record<string, unknown> = {}
119
+ for (const key of Object.keys(value).sort()) {
120
+ const item = (value as Record<string, unknown>)[key]
121
+ // `plugins` and `parser` hold live objects whose identity is meaningless.
122
+ if ((key === 'parser' || key === 'processor') && typeof item === 'object' && item !== null) out[key] = describeNamed(item)
123
+ else if (key === 'plugins' && typeof item === 'object' && item !== null && !Array.isArray(item)) {
124
+ out[key] = Object.fromEntries(Object.keys(item).sort().map(name =>
125
+ [name, describeNamed((item as Record<string, object>)[name]!)]))
126
+ } else out[key] = canonical(item, seen)
127
+ }
128
+ return out
129
+ } finally {
130
+ seen.delete(value)
131
+ }
132
+ }
133
+
134
+ function sha256(value: unknown): string {
135
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex')
136
+ }
137
+
138
+ function ignoreGlobs(blocks: readonly unknown[]): string[] {
139
+ const globs: string[] = []
140
+ for (const block of blocks) {
141
+ if (typeof block !== 'object' || block === null) continue
142
+ const b = block as { files?: unknown; ignores?: unknown }
143
+ if (b.files === undefined && Array.isArray(b.ignores)) {
144
+ for (const g of b.ignores) if (typeof g === 'string') globs.push(g)
145
+ }
146
+ }
147
+ return globs
148
+ }
149
+
150
+ /**
151
+ * The digest of what decides lint for one module directory: include globs, and the
152
+ * sorted distinct resolved configs (eslint's own `calculateConfigForFile`: rules,
153
+ * language options, settings) over every covered file, so extras, module overrides and
154
+ * per-file blocks are all in and the set does not move when a file joins a region whose
155
+ * config is already present. Global ignores, plugin names and the preset blocks are
156
+ * inputs only where pipework's preset is what runs; a directory with its own
157
+ * eslint.config.* is described by the configs alone. The tsconfig the type-aware rules
158
+ * read is not an input.
159
+ */
160
+ export async function digestModule(
161
+ cwd: string,
162
+ ESLint: EslintCtor,
163
+ label: string,
164
+ dir: string,
165
+ includes: readonly string[] | undefined,
166
+ coveredFiles: readonly string[],
167
+ ): Promise<ModuleDigest> {
168
+ const own = hasEslintConfig(dir)
169
+ const blocks = own ? undefined : await resolveLintConfig(dir)
170
+ const eslint = await eslintFor(ESLint, dir)
171
+ const configs = new Map<string, unknown>()
172
+ for (const file of coveredFiles) {
173
+ // eslint's Config is a class instance with its own toJSON (plugins and parsers by name).
174
+ const resolved = await eslint.calculateConfigForFile(resolve(cwd, file))
175
+ const config = canonical(JSON.parse(JSON.stringify(resolved ?? null)))
176
+ configs.set(JSON.stringify(config), config)
177
+ }
178
+ const inputs = canonical({
179
+ pipework: pipeworkVersion(),
180
+ eslint: packageVersion(cwd, 'eslint'),
181
+ typescriptEslint: packageVersion(cwd, '@typescript-eslint/eslint-plugin'),
182
+ include: lintIncludeGlobs(dir, includes),
183
+ configs: [...configs.keys()].sort().map(k => configs.get(k)),
184
+ ...(blocks === undefined ? {} : {
185
+ ignores: ignoreGlobs(blocks),
186
+ plugins: [...new Set(blocks.flatMap(b => typeof b === 'object' && b !== null && 'plugins' in b
187
+ ? Object.keys((b as { plugins: object }).plugins) : []))].sort(),
188
+ blocks,
189
+ }),
190
+ }) as Record<string, unknown>
191
+ return { module: label, digest: sha256(inputs), inputs }
192
+ }
193
+
194
+ export async function settingsDigests(
195
+ cwd: string,
196
+ population: LintPopulation,
197
+ includes: { for: (mod: string) => readonly string[] | undefined },
198
+ coverage: readonly FileCoverage[],
199
+ rootPass: boolean,
200
+ ): Promise<ModuleDigest[]> {
201
+ const ESLint = await loadEslint(cwd)
202
+ const coveredIn = new Map<string, string[]>()
203
+ const dirOf = new Map(population.candidates.map(c => [c.file, c.dir]))
204
+ for (const c of coverage) {
205
+ const dir = dirOf.get(c.file)
206
+ if (c.covered && dir !== undefined) coveredIn.set(dir, [...(coveredIn.get(dir) ?? []), c.file])
207
+ }
208
+ const out: ModuleDigest[] = []
209
+ const hasRoot = population.modules.some(m => m.path === '.' || m.path === '')
210
+ for (const mod of population.modules) {
211
+ const dir = join(cwd, mod.path)
212
+ out.push(await digestModule(cwd, ESLint, mod.path === '' ? '.' : mod.path, dir, mod.includes, coveredIn.get(dir) ?? []))
213
+ }
214
+ if (rootPass && !hasRoot) {
215
+ out.push(await digestModule(cwd, ESLint, '.', cwd, includes.for('.'), coveredIn.get(cwd) ?? []))
216
+ }
217
+ return out.sort((a, b) => a.module < b.module ? -1 : a.module > b.module ? 1 : 0)
218
+ }
@@ -3,7 +3,8 @@ import { dirname, relative } from 'node:path'
3
3
  import { findConfig } from '../../core/config/discover.js'
4
4
  import { error } from '../output.js'
5
5
  import { builtinConfigArgs, lintPopulation, lintTargets, loadLintIncludes, resolveEslintBin, resolveModulePaths } from './check.js'
6
- import { parseShard } from './shard.js'
6
+ import { parseShard, type Shard } from './shard.js'
7
+ import { classifyCoverage, settingsDigests } from './lint-settings.js'
7
8
 
8
9
  // eslint options that take their value as the next argument. Anything else starting
9
10
  // with `-` is a switch, so the argument after it is a file.
@@ -38,6 +39,7 @@ export function namesFiles(args: readonly string[]): boolean {
38
39
  */
39
40
  export async function lintCommand(cwd: string, args: readonly string[]): Promise<number> {
40
41
  if (args.includes('--list-files')) return listFiles(cwd, args)
42
+ if (args.includes('--settings-digest')) return settingsDigest(cwd, args)
41
43
  const eslintBin = resolveEslintBin(cwd)
42
44
  const configPath = findConfig(cwd)
43
45
  const root = configPath === null ? cwd : dirname(configPath)
@@ -64,32 +66,81 @@ export async function lintCommand(cwd: string, args: readonly string[]): Promise
64
66
  }
65
67
 
66
68
  /**
67
- * `pipework lint --list-files [--shard i/n]`: the repo-relative files `check`'s lint
68
- * pass covers, one per line, read from pipework.config.ts. Nothing is linted.
69
+ * `--list-files` and `--settings-digest` take only `--shard <i>/<n>` and `--format json`.
70
+ * Returns the parsed options, or null after reporting the problem.
69
71
  */
70
- async function listFiles(cwd: string, args: readonly string[]): Promise<number> {
71
- const rest = args.filter(a => a !== '--list-files')
72
- let shard
73
- if (rest.length === 2 && rest[0] === '--shard') {
74
- try {
75
- shard = parseShard(rest[1]!)
76
- } catch (err) {
77
- error(err instanceof Error ? err.message : String(err))
78
- return 1
72
+ function parseReportArgs(flag: string, args: readonly string[]): { shard: Shard | undefined; json: boolean } | null {
73
+ let shard: Shard | undefined
74
+ let json = false
75
+ const rest = args.filter(a => a !== flag)
76
+ const bad = (): null => {
77
+ error(`${flag} takes only --shard <i>/<n> and --format json; got ${rest.join(' ')}. It does not run eslint.`)
78
+ return null
79
+ }
80
+ for (let i = 0; i < rest.length; i++) {
81
+ const arg = rest[i]!
82
+ if (arg === '--shard' && i + 1 < rest.length) {
83
+ try {
84
+ shard = parseShard(rest[++i]!)
85
+ } catch (err) {
86
+ error(err instanceof Error ? err.message : String(err))
87
+ return null
88
+ }
89
+ } else if ((arg === '--format' && rest[i + 1] === 'json') || arg === '--format=json') {
90
+ if (arg === '--format') i++
91
+ json = true
92
+ } else {
93
+ return bad()
79
94
  }
80
- } else if (rest.length > 0) {
81
- error(`--list-files takes only --shard <i>/<n>; got ${rest.join(' ')}. It lists files and does not run eslint.`)
82
- return 1
83
95
  }
96
+ return { shard, json }
97
+ }
84
98
 
99
+ async function reportPopulation(cwd: string, flag: string, args: readonly string[]) {
100
+ const opts = parseReportArgs(flag, args)
101
+ if (opts === null) return null
85
102
  const configPath = findConfig(cwd)
86
103
  const root = configPath === null ? cwd : dirname(configPath)
87
104
  const scope = await resolveModulePaths(root)
88
105
  if (scope.configError !== undefined) {
89
- error(`Cannot list lint files: pipework.config.ts failed to load: ${scope.configError.message}`)
90
- return 1
106
+ error(`Cannot ${flag === '--list-files' ? 'list lint files' : 'digest lint settings'}: pipework.config.ts failed to load: ${scope.configError.message}`)
107
+ return null
91
108
  }
92
- const population = lintPopulation(root, scope.paths, await loadLintIncludes(root), shard)
93
- if (population.files.length > 0) process.stdout.write(population.files.join('\n') + '\n')
109
+ const includes = await loadLintIncludes(root)
110
+ const population = lintPopulation(root, scope.paths, includes, opts.shard)
111
+ return { ...opts, root, includes, population }
112
+ }
113
+
114
+ /**
115
+ * `pipework lint --list-files [--shard i/n] [--format json]`: the repo-relative files
116
+ * `check`'s lint pass covers and eslint will lint, one per line. Files the include globs
117
+ * reach but the lint config ignores are left out; `--format json` lists every candidate
118
+ * with `covered` and, when false, why. Nothing is linted.
119
+ */
120
+ async function listFiles(cwd: string, args: readonly string[]): Promise<number> {
121
+ const ctx = await reportPopulation(cwd, '--list-files', args)
122
+ if (ctx === null) return 1
123
+ const coverage = await classifyCoverage(ctx.root, ctx.population)
124
+ if (ctx.json) {
125
+ process.stdout.write(JSON.stringify(coverage, null, 2) + '\n')
126
+ return 0
127
+ }
128
+ const covered = coverage.filter(c => c.covered).map(c => c.file)
129
+ if (covered.length > 0) process.stdout.write(covered.join('\n') + '\n')
130
+ return 0
131
+ }
132
+
133
+ /**
134
+ * `pipework lint --settings-digest [--shard i/n] [--format json]`: one sha256 per
135
+ * module (the root pass is `.`) of what decides lint there. Nothing is linted.
136
+ */
137
+ async function settingsDigest(cwd: string, args: readonly string[]): Promise<number> {
138
+ const ctx = await reportPopulation(cwd, '--settings-digest', args)
139
+ if (ctx === null) return 1
140
+ const coverage = await classifyCoverage(ctx.root, ctx.population)
141
+ const rootPass = ctx.shard === undefined || ctx.shard.index === 1
142
+ const digests = await settingsDigests(ctx.root, ctx.population, ctx.includes, coverage, rootPass)
143
+ if (ctx.json) process.stdout.write(JSON.stringify(digests, null, 2) + '\n')
144
+ else if (digests.length > 0) process.stdout.write(digests.map(d => `${d.module} ${d.digest}`).join('\n') + '\n')
94
145
  return 0
95
146
  }
package/src/cli/index.ts CHANGED
@@ -24,7 +24,8 @@ ${bold('Commands:')}
24
24
  cardinality [files] @downstream cardinality rules alone (same options)
25
25
  startup Startup validation alone: pipework.config.ts loaded, no database
26
26
  lint [eslint args] Lint the current module (the workspace at its root) with check's config; every argument goes to eslint
27
- lint --list-files [--shard i/n]: print the files check's lint covers, lint nothing
27
+ lint --list-files [--shard i/n] [--format json]: print the files eslint will lint (json: every candidate, covered or not), lint nothing
28
+ lint --settings-digest [--shard i/n] [--format json]: print a digest per module of the resolved lint settings, lint nothing
28
29
  codemod Fill missing @downstream cardinality across the corpus
29
30
  db Database lifecycle: setup, teardown, reset
30
31
  alerts unit-fault Record an abnormal systemd stop (ExecStopPost=pipework alerts unit-fault --unit %n)
@@ -93,6 +94,7 @@ ${bold('Examples:')}
93
94
  pipework lint # the module you are in, exactly as check lints it
94
95
  pipework lint --format json --output-file r.json src/a.ts # only src/a.ts
95
96
  pipework lint --list-files --shard 1/4 # the files check's lint pass covers for part 1 of 4, from pipework.config.ts
97
+ pipework lint --settings-digest --format json # per-module digest of what decides lint, for a lint-result cache key
96
98
  pipework codemod
97
99
  pipework codemod --dry-run
98
100
  pipework alerts unit-fault --unit subseam-core.service # reads $SERVICE_RESULT, $EXIT_CODE, $EXIT_STATUS
@@ -43,9 +43,32 @@ export interface ManagedConnection {
43
43
  * without it, throws.
44
44
  */
45
45
  connectionIds(): Promise<ReadonlyArray<{ connectionId: number; backendPid: number }>>
46
+ /**
47
+ * Cancels the statement running on server backend `pid` (`pg_cancel_backend`),
48
+ * over a connection reserved for cancels (opened on first use, closed with
49
+ * this connection) so it never waits on the pool the statement runs in.
50
+ */
51
+ cancelBackend?(pid: number): Promise<void>
46
52
  close(options?: { timeoutSeconds?: number }): Promise<void>
47
53
  }
48
54
 
55
+ /** A lazily opened single-connection client that only sends cancels. */
56
+ function createCanceller(url: string): { cancel(pid: number): Promise<void>; close(): Promise<void> } {
57
+ let client: postgres.Sql | undefined
58
+ let closed = false
59
+ return {
60
+ async cancel(pid) {
61
+ if (closed) return
62
+ client ??= postgres(url, { max: 1, idle_timeout: 3, onnotice: () => {}, fetch_types: false })
63
+ await client`SELECT pg_cancel_backend(${pid})`
64
+ },
65
+ async close() {
66
+ closed = true
67
+ await client?.end({ timeout: 1 })
68
+ },
69
+ }
70
+ }
71
+
49
72
  /** Options for `tap()` — pool size (default 1), idle timeout in seconds, and session settings (GUCs) sent in every connection's startup packet. */
50
73
  export interface ConnectOptions {
51
74
  readonly max?: number
@@ -121,13 +144,16 @@ export function tap(url: string, options?: ConnectOptions): ManagedConnection {
121
144
  })
122
145
 
123
146
  const db = splitLargeInserts(drizzle(client))
147
+ const canceller = createCanceller(url)
124
148
 
125
149
  return {
126
150
  name: 'manual',
127
151
  client,
128
152
  drizzle: db,
129
153
  connectionIds: () => probeConnectionIds(client, undefined),
154
+ cancelBackend: (pid) => canceller.cancel(pid),
130
155
  async close(options) {
156
+ await canceller.close()
131
157
  await client.end(endOptions(options))
132
158
  },
133
159
  }
@@ -165,6 +191,7 @@ export function createManagedConnection(
165
191
  ? createStatementWatch({ database: config.name, url: config.url, slowQueryMs: observability.slowQueryMs, inFlightQueryMs: observability.inFlightQueryMs })
166
192
  : undefined
167
193
  const db = splitLargeInserts(drizzle(client, statementWatch ? { statementWatch: statementWatch.watch } : {}))
194
+ const canceller = createCanceller(config.url)
168
195
 
169
196
  return {
170
197
  name: config.name,
@@ -174,8 +201,10 @@ export function createManagedConnection(
174
201
  },
175
202
  drizzle: db,
176
203
  connectionIds: () => probeConnectionIds(client, slot),
204
+ cancelBackend: (pid) => canceller.cancel(pid),
177
205
  async close(options) {
178
206
  onClose()
207
+ await canceller.close()
179
208
  await statementWatch?.close()
180
209
  await client.end(endOptions(options))
181
210
  },
@@ -48,14 +48,14 @@ export async function executeInTransaction<T>(
48
48
 
49
49
  await propagateLocals(tx, ctx, tenant)
50
50
 
51
- const disarm = await armCancellation(conn, tx, signal)
51
+ const cancellation = await armCancellation(conn, tx, signal)
52
52
  try {
53
53
  const result = await runInContext(childCtx, run)
54
54
  // Idle at the abort, nothing failed: throwing here rolls back instead of committing.
55
55
  signal?.throwIfAborted()
56
56
  return result
57
57
  } finally {
58
- disarm()
58
+ await cancellation.settle()
59
59
  }
60
60
  })
61
61
  }
@@ -90,10 +90,15 @@ export async function executeInTransaction<T>(
90
90
  conn.drizzle.transaction(async (tx) => {
91
91
  childCtx.transactions.set(dbName, bindTransaction(ctx, dbName, tx, signal))
92
92
  await propagateLocals(tx, ctx, tenant)
93
- disarms.push(await armCancellation(conn, tx, signal))
93
+ const cancellation = await armCancellation(conn, tx, signal)
94
+ disarms.push(cancellation.disarm)
94
95
  readyDatabases.add(dbName)
95
96
  signalReady()
96
- await donePromise
97
+ try {
98
+ await donePromise
99
+ } finally {
100
+ await cancellation.settle()
101
+ }
97
102
  }),
98
103
  )
99
104
  }
@@ -142,34 +147,38 @@ export async function executeInTransaction<T>(
142
147
  * Arms server-side cancellation for one transaction: on abort, a separate
143
148
  * pooled connection issues `pg_cancel_backend` against the backend this
144
149
  * transaction holds, so an in-flight query fails with `query_canceled` and the
145
- * transaction unwinds. Returns a disarm function — always call it, or the
146
- * listener outlives the transaction and cancels a backend the pool has since
147
- * handed to someone else.
150
+ * transaction unwinds. Always call `settle()` before the transaction ends: it
151
+ * removes the listener and waits for a cancel already sent to be delivered, so
152
+ * none reaches a backend the pool has since handed to someone else. `disarm()`
153
+ * only removes the listener.
148
154
  */
149
155
  async function armCancellation(
150
156
  conn: ManagedConnection,
151
157
  tx: DB,
152
158
  signal: AbortSignal | undefined,
153
- ): Promise<() => void> {
154
- if (signal === undefined) return () => {}
159
+ ): Promise<{ disarm: () => void; settle: () => Promise<void> }> {
160
+ const none = { disarm: () => {}, settle: async () => {} }
161
+ if (signal === undefined) return none
155
162
 
156
163
  const [row] = await tx.execute(sql`SELECT pg_backend_pid() AS pid`)
157
164
  const pid = Number((row as { pid?: unknown } | undefined)?.pid)
158
- if (!Number.isInteger(pid)) return () => {}
165
+ if (!Number.isInteger(pid)) return none
159
166
 
167
+ let pending: Promise<void> | undefined
160
168
  const cancel = (): void => {
161
169
  // Best effort: the backend may already have finished, and a failed cancel
162
170
  // must not replace the abort the caller is about to see.
163
- void conn.drizzle.execute(sql`SELECT pg_cancel_backend(${pid})`).catch(() => {})
171
+ pending = (conn.cancelBackend?.(pid) ?? conn.drizzle.execute(sql`SELECT pg_cancel_backend(${pid})`)).then(() => {}, () => {})
164
172
  }
165
-
166
- if (signal.aborted) {
167
- cancel()
168
- return () => {}
173
+ const disarm = (): void => { signal.removeEventListener('abort', cancel) }
174
+ const settle = async (): Promise<void> => {
175
+ disarm()
176
+ await pending
169
177
  }
170
178
 
171
- signal.addEventListener('abort', cancel, { once: true })
172
- return () => { signal.removeEventListener('abort', cancel) }
179
+ if (signal.aborted) cancel()
180
+ else signal.addEventListener('abort', cancel, { once: true })
181
+ return { disarm, settle }
173
182
  }
174
183
 
175
184
  /**
@@ -13,3 +13,22 @@ export class RequestAbortedError extends PipeworkError {
13
13
  super(`[pipework] The client closed the connection before the response was sent (${method} ${route}).`, { method, route })
14
14
  }
15
15
  }
16
+
17
+ /**
18
+ * Whether `error` is something the abort itself caused: anywhere in its `cause`
19
+ * chain, the signal's reason, a `RequestAbortedError`, an `AbortError`, or a
20
+ * Postgres `query_canceled` (57014) / `in_failed_sql_transaction` (25P02).
21
+ * Any other error after an abort is a real failure.
22
+ */
23
+ export function isAbortCaused(error: unknown, signal: AbortSignal): boolean {
24
+ const seen = new Set<unknown>()
25
+ let current: unknown = error
26
+ while (current !== null && current !== undefined && !seen.has(current)) {
27
+ seen.add(current)
28
+ if (current === signal.reason || current instanceof RequestAbortedError) return true
29
+ const { name, code, cause } = current as { name?: unknown; code?: unknown; cause?: unknown }
30
+ if (name === 'AbortError' || code === '57014' || code === '25P02') return true
31
+ current = cause
32
+ }
33
+ return false
34
+ }
@@ -3,5 +3,5 @@ import { detectMediaType, resolveMediaType, SNIFF_BYTES } from './sniff.js'
3
3
 
4
4
  /** HTTP namespace: `http.file()` builds a file response; `http.detectMediaType()` names bytes the way uploads do; the server itself is managed by manifold.start().
5
5
  *
6
- * A client that disconnects before its response is finished aborts the request: the in-flight statement is cancelled, the transaction rolled back and the connection released. The context's `signal` (`flow.require().signal`) aborts with a `RequestAbortedError` (499, `REQUEST_ABORTED`); pass it to the handler's own outbound calls (`fetch(url, { signal })`, a `quota.call` send). The aborted request is not an internal error: `onInternalError` is not called and nothing is sent. A route opts out with `.route(method, path, { cancelOnDisconnect: false })`, e.g. a webhook receiver whose sender abandons slow deliveries and does not redeliver: its signal never aborts and the transaction commits after the socket closes. */
6
+ * A client that disconnects before its response is finished aborts the request: the in-flight statement is cancelled, the transaction rolled back and the connection released. The context's `signal` (`flow.require().signal`) aborts with a `RequestAbortedError` (499, `REQUEST_ABORTED`); pass it to the handler's own outbound calls (`fetch(url, { signal })`, a `quota.call` send). Nothing is sent, whether the handler then throws or completes (an injected call rejects with `AbortError`). What the abort caused (its reason, a cancelled statement) is not an internal error and `onInternalError` is not called; any other error thrown after the abort still reaches it. A route opts out with `.route(method, path, { cancelOnDisconnect: false })`, e.g. a webhook receiver whose sender abandons slow deliveries and does not redeliver: its signal never aborts and the transaction commits after the socket closes. */
7
7
  export const http = { file, detectMediaType, resolveMediaType, SNIFF_BYTES } as const