ts-procedures 8.1.0 → 8.2.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 +14 -0
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +3 -3
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +4 -2
- package/agent_config/copilot/copilot-instructions.md +3 -3
- package/agent_config/cursor/cursorrules +3 -3
- package/build/client/call.test.js +1 -1
- package/build/client/call.test.js.map +1 -1
- package/build/client/errors.js +0 -1
- package/build/client/errors.js.map +1 -1
- package/build/client/hooks.test.js +1 -1
- package/build/client/hooks.test.js.map +1 -1
- package/build/client/index.js +1 -1
- package/build/client/index.js.map +1 -1
- package/build/client/typed-error-dispatch.test.js +6 -3
- package/build/client/typed-error-dispatch.test.js.map +1 -1
- package/build/codegen/bin/cli.js +1 -1
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +2 -2
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/bundle-size.test.js +0 -1
- package/build/codegen/bundle-size.test.js.map +1 -1
- package/build/codegen/constants.d.ts +8 -1
- package/build/codegen/constants.js +9 -1
- package/build/codegen/constants.js.map +1 -1
- package/build/codegen/e2e.test.js +1 -1
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-types.test.js +1 -0
- package/build/codegen/emit-client-types.test.js.map +1 -1
- package/build/codegen/emit-errors.test.js +1 -1
- package/build/codegen/emit-errors.test.js.map +1 -1
- package/build/codegen/emit-index.test.js +1 -1
- package/build/codegen/emit-index.test.js.map +1 -1
- package/build/codegen/emit-scope.js +1 -1
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +1 -1
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/build/codegen/pipeline.js +1 -1
- package/build/codegen/pipeline.js.map +1 -1
- package/build/codegen/pipeline.test.js +33 -10
- package/build/codegen/pipeline.test.js.map +1 -1
- package/build/codegen/targets/_shared/write-files.d.ts +11 -5
- package/build/codegen/targets/_shared/write-files.js +49 -7
- package/build/codegen/targets/_shared/write-files.js.map +1 -1
- package/build/codegen/targets/_shared/write-files.test.js +87 -21
- package/build/codegen/targets/_shared/write-files.test.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-scope-kotlin.js +3 -2
- package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/format-kotlin.d.ts +6 -0
- package/build/codegen/targets/kotlin/format-kotlin.js +9 -0
- package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/format-kotlin.test.js +8 -1
- package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -1
- package/build/codegen/targets/swift/format-swift.js +4 -1
- package/build/codegen/targets/swift/format-swift.js.map +1 -1
- package/build/codegen/test-helpers/golden.js +0 -1
- package/build/codegen/test-helpers/golden.js.map +1 -1
- package/build/create-http-stream.d.ts +2 -2
- package/build/create-http.d.ts +3 -3
- package/build/create-http.js.map +1 -1
- package/build/create-http.test.js +1 -1
- package/build/create-http.test.js.map +1 -1
- package/build/create-stream.d.ts +2 -2
- package/build/create-stream.test.js +1 -2
- package/build/create-stream.test.js.map +1 -1
- package/build/create.d.ts +2 -2
- package/build/create.test.js +1 -2
- package/build/create.test.js.map +1 -1
- package/build/errors.d.ts +2 -2
- package/build/errors.js.map +1 -1
- package/build/implementations/http/hono/index.d.ts +2 -1
- package/build/implementations/http/hono/index.js.map +1 -1
- package/build/implementations/types.d.ts +1 -1
- package/build/index.d.ts +9 -9
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/index.test.js +0 -1
- package/build/index.test.js.map +1 -1
- package/build/schema/compute-schema.d.ts +3 -3
- package/build/schema/compute-schema.js.map +1 -1
- package/build/schema/extract-json-schema.d.ts +1 -1
- package/build/schema/parser.d.ts +1 -1
- package/build/schema/resolve-schema-lib.d.ts +2 -2
- package/build/schema/types.d.ts +2 -2
- package/build/stack-utils.test.js.map +1 -1
- package/build/types.d.ts +2 -2
- package/docs/client-and-codegen.md +2 -2
- package/docs/decisions/2026-06-02-monorepo-split-evaluation.md +80 -0
- package/docs/npm-workspaces-migration-plan.md +611 -0
- package/package.json +2 -1
- package/src/client/errors.ts +1 -1
- package/src/client/typed-error-dispatch.test.ts +1 -2
- package/src/codegen/bin/cli.test.ts +2 -2
- package/src/codegen/bin/cli.ts +1 -1
- package/src/codegen/bundle-size.test.ts +1 -1
- package/src/codegen/constants.ts +10 -1
- package/src/codegen/e2e.test.ts +1 -1
- package/src/codegen/emit-errors.test.ts +1 -1
- package/src/codegen/emit-index.test.ts +1 -1
- package/src/codegen/emit-scope.test.ts +1 -1
- package/src/codegen/pipeline.test.ts +39 -10
- package/src/codegen/pipeline.ts +1 -1
- package/src/codegen/targets/_shared/write-files.test.ts +143 -32
- package/src/codegen/targets/_shared/write-files.ts +53 -8
- package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +1 -0
- package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +3 -2
- package/src/codegen/targets/kotlin/format-kotlin.test.ts +9 -0
- package/src/codegen/targets/kotlin/format-kotlin.ts +11 -0
- package/src/codegen/targets/swift/format-swift.ts +5 -1
- package/src/codegen/test-helpers/golden.ts +1 -1
- package/src/create-http-stream.ts +2 -2
- package/src/create-http.ts +2 -3
- package/src/create-stream.test.ts +1 -1
- package/src/create-stream.ts +2 -2
- package/src/create.test.ts +1 -1
- package/src/create.ts +2 -2
- package/src/errors.test.ts +1 -1
- package/src/errors.ts +3 -2
- package/src/implementations/http/hono/index.ts +2 -1
- package/src/implementations/http/on-request-error.test.ts +1 -1
- package/src/implementations/http/route-errors.test.ts +1 -1
- package/src/implementations/types.ts +1 -1
- package/src/index.test.ts +1 -1
- package/src/index.ts +2 -2
- package/src/schema/compute-schema.ts +4 -3
- package/src/schema/extract-json-schema.ts +1 -1
- package/src/schema/parser.ts +1 -1
- package/src/schema/resolve-schema-lib.ts +2 -2
- package/src/schema/types.ts +2 -2
- package/src/stack-utils.test.ts +2 -1
- package/src/types.ts +2 -2
|
@@ -69,7 +69,7 @@ describe('bundle size budget', () => {
|
|
|
69
69
|
// This isn't a strict assertion — it surfaces the actual numbers to the
|
|
70
70
|
// test output so reviewers can update PERROUTEDELTA_BASELINE in the
|
|
71
71
|
// comment above without having to re-run locally.
|
|
72
|
-
|
|
72
|
+
|
|
73
73
|
console.log(`[bundle-size] 100 routes, scope file = ${totalChars} chars stripped, per-route = ${perRouteDelta.toFixed(1)}`)
|
|
74
74
|
expect(totalChars).toBeGreaterThan(0)
|
|
75
75
|
})
|
package/src/codegen/constants.ts
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
import pkg from '../../package.json' with { type: 'json' }
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stable, version-agnostic token embedded in every generated file (across all
|
|
5
|
+
* targets). The marker-based prune in `writeGeneratedFiles` uses this to tell
|
|
6
|
+
* generator-owned files apart from hand-written ones — so it must NOT include
|
|
7
|
+
* the package version (an orphan left by an older release must still match).
|
|
8
|
+
*/
|
|
9
|
+
export const CODEGEN_SIGNATURE = 'ts-procedures-codegen'
|
|
10
|
+
export const CODEGEN_HEADER = `// Auto-generated by ${CODEGEN_SIGNATURE} (v${pkg.version}) — do not edit`
|
package/src/codegen/e2e.test.ts
CHANGED
|
@@ -476,7 +476,7 @@ describe('E2E: generateClient full pipeline', () => {
|
|
|
476
476
|
|
|
477
477
|
for (const file of ['users.ts', 'events.ts', 'index.ts', '_errors.ts']) {
|
|
478
478
|
const content = readFileSync(join(tmpDir, file), 'utf-8')
|
|
479
|
-
expect(content).toContain('// Auto-generated by ts-procedures-codegen
|
|
479
|
+
expect(content).toContain('// Auto-generated by ts-procedures-codegen')
|
|
480
480
|
}
|
|
481
481
|
})
|
|
482
482
|
|
|
@@ -109,7 +109,7 @@ describe('emitErrorsFile', () => {
|
|
|
109
109
|
|
|
110
110
|
it('includes the auto-generated header comment', async () => {
|
|
111
111
|
const result = await emitErrorsFile([procedureErrorDoc])
|
|
112
|
-
expect(result).toContain('// Auto-generated by ts-procedures-codegen
|
|
112
|
+
expect(result).toContain('// Auto-generated by ts-procedures-codegen')
|
|
113
113
|
})
|
|
114
114
|
|
|
115
115
|
it('returns undefined when no errors have schemas', async () => {
|
|
@@ -31,7 +31,7 @@ const adminUsersGroup: ScopeGroup = {
|
|
|
31
31
|
describe('emitIndexFile', () => {
|
|
32
32
|
it('includes the auto-generated header comment', () => {
|
|
33
33
|
const output = emitIndexFile([usersGroup])
|
|
34
|
-
expect(output).toContain('// Auto-generated by ts-procedures-codegen
|
|
34
|
+
expect(output).toContain('// Auto-generated by ts-procedures-codegen')
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
it('imports createClient as a value and client types for the factory', () => {
|
|
@@ -271,7 +271,7 @@ describe('emitScopeFile', () => {
|
|
|
271
271
|
describe('RPC scope', () => {
|
|
272
272
|
it('includes the auto-generated header comment', async () => {
|
|
273
273
|
const output = await emitScopeFile(rpcGroup)
|
|
274
|
-
expect(output).toContain('// Auto-generated by ts-procedures-codegen
|
|
274
|
+
expect(output).toContain('// Auto-generated by ts-procedures-codegen')
|
|
275
275
|
})
|
|
276
276
|
|
|
277
277
|
it('imports ClientInstance and ProcedureCallOptions but not TypedStream', async () => {
|
|
@@ -7,6 +7,7 @@ import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node
|
|
|
7
7
|
import { join } from 'node:path'
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
9
9
|
import type { DocEnvelope } from '../implementations/types.js'
|
|
10
|
+
import { CODEGEN_HEADER } from './constants.js'
|
|
10
11
|
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
12
13
|
// Fixtures
|
|
@@ -140,7 +141,7 @@ describe('generateClient pipeline', () => {
|
|
|
140
141
|
|
|
141
142
|
for (const file of ['users.ts', 'billing.ts', 'index.ts']) {
|
|
142
143
|
const content = readFileSync(join(tmpDir, file), 'utf-8')
|
|
143
|
-
expect(content).toContain('// Auto-generated by ts-procedures-codegen
|
|
144
|
+
expect(content).toContain('// Auto-generated by ts-procedures-codegen')
|
|
144
145
|
}
|
|
145
146
|
})
|
|
146
147
|
|
|
@@ -172,7 +173,7 @@ describe('generateClient pipeline', () => {
|
|
|
172
173
|
expect(paths).toContain(join(dryDir, 'billing.ts'))
|
|
173
174
|
expect(paths).toContain(join(dryDir, 'index.ts'))
|
|
174
175
|
for (const file of files) {
|
|
175
|
-
expect(file.code).toContain('// Auto-generated by ts-procedures-codegen
|
|
176
|
+
expect(file.code).toContain('// Auto-generated by ts-procedures-codegen')
|
|
176
177
|
}
|
|
177
178
|
})
|
|
178
179
|
|
|
@@ -186,8 +187,8 @@ describe('generateClient pipeline', () => {
|
|
|
186
187
|
// Verify both files have auto-generated header and source hash
|
|
187
188
|
const typesFile = files.find((f) => f.path.endsWith('_types.ts'))!
|
|
188
189
|
const clientFile = files.find((f) => f.path.endsWith('_client.ts'))!
|
|
189
|
-
expect(typesFile.code).toContain('// Auto-generated by ts-procedures-codegen
|
|
190
|
-
expect(clientFile.code).toContain('// Auto-generated by ts-procedures-codegen
|
|
190
|
+
expect(typesFile.code).toContain('// Auto-generated by ts-procedures-codegen')
|
|
191
|
+
expect(clientFile.code).toContain('// Auto-generated by ts-procedures-codegen')
|
|
191
192
|
const typesLines = typesFile.code.split('\n')
|
|
192
193
|
expect(typesLines[1]).toMatch(/^\/\/ Source hash: [a-f0-9]{32}$/)
|
|
193
194
|
const clientLines = clientFile.code.split('\n')
|
|
@@ -290,30 +291,58 @@ describe('generateClient pipeline', () => {
|
|
|
290
291
|
expect(content).not.toContain('createScopeBindings')
|
|
291
292
|
})
|
|
292
293
|
|
|
293
|
-
it('cleanOutDir: true removes
|
|
294
|
+
it('cleanOutDir: true removes orphaned generated files before writing', async () => {
|
|
294
295
|
tmpDir = makeTmpDir()
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
296
|
+
// A previously-generated scope file (carries the package signature) that
|
|
297
|
+
// this envelope no longer emits.
|
|
298
|
+
const orphan = join(tmpDir, 'old-scope.ts')
|
|
299
|
+
writeFileSync(orphan, `${CODEGEN_HEADER}\n\nexport const gone = 1\n`, 'utf-8')
|
|
300
|
+
expect(existsSync(orphan)).toBe(true)
|
|
298
301
|
|
|
299
302
|
await generateClient({ envelope, outDir: tmpDir, cleanOutDir: true })
|
|
300
303
|
|
|
301
|
-
expect(existsSync(
|
|
304
|
+
expect(existsSync(orphan)).toBe(false)
|
|
302
305
|
expect(existsSync(join(tmpDir, 'index.ts'))).toBe(true)
|
|
303
306
|
expect(existsSync(join(tmpDir, 'users.ts'))).toBe(true)
|
|
304
307
|
})
|
|
305
308
|
|
|
306
|
-
it('cleanOutDir:
|
|
309
|
+
it('cleanOutDir: true never removes hand-written files lacking the signature', async () => {
|
|
307
310
|
tmpDir = makeTmpDir()
|
|
311
|
+
const handWritten = join(tmpDir, 'hand-written.ts')
|
|
312
|
+
writeFileSync(handWritten, 'export const mine = 1\n', 'utf-8')
|
|
313
|
+
|
|
314
|
+
await generateClient({ envelope, outDir: tmpDir, cleanOutDir: true })
|
|
315
|
+
|
|
316
|
+
expect(existsSync(handWritten)).toBe(true)
|
|
317
|
+
expect(existsSync(join(tmpDir, 'index.ts'))).toBe(true)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('prunes orphaned generated files by default (cleanOutDir defaults to true)', async () => {
|
|
321
|
+
tmpDir = makeTmpDir()
|
|
322
|
+
const orphan = join(tmpDir, 'old-scope.ts')
|
|
323
|
+
writeFileSync(orphan, `${CODEGEN_HEADER}\n\nexport const gone = 1\n`, 'utf-8')
|
|
324
|
+
// Hand-written file is still safe even with pruning on by default.
|
|
308
325
|
const keep = join(tmpDir, 'keep.ts')
|
|
309
326
|
writeFileSync(keep, '// keep', 'utf-8')
|
|
310
327
|
|
|
311
328
|
await generateClient({ envelope, outDir: tmpDir })
|
|
312
329
|
|
|
330
|
+
expect(existsSync(orphan)).toBe(false)
|
|
313
331
|
expect(existsSync(keep)).toBe(true)
|
|
314
332
|
expect(existsSync(join(tmpDir, 'index.ts'))).toBe(true)
|
|
315
333
|
})
|
|
316
334
|
|
|
335
|
+
it('cleanOutDir: false opts out of pruning, leaving orphaned generated files', async () => {
|
|
336
|
+
tmpDir = makeTmpDir()
|
|
337
|
+
const orphan = join(tmpDir, 'old-scope.ts')
|
|
338
|
+
writeFileSync(orphan, `${CODEGEN_HEADER}\n\nexport const gone = 1\n`, 'utf-8')
|
|
339
|
+
|
|
340
|
+
await generateClient({ envelope, outDir: tmpDir, cleanOutDir: false })
|
|
341
|
+
|
|
342
|
+
expect(existsSync(orphan)).toBe(true)
|
|
343
|
+
expect(existsSync(join(tmpDir, 'index.ts'))).toBe(true)
|
|
344
|
+
})
|
|
345
|
+
|
|
317
346
|
it('cleanOutDir works when outDir does not exist', async () => {
|
|
318
347
|
tmpDir = makeTmpDir()
|
|
319
348
|
const nested = join(tmpDir, 'nested', 'missing')
|
package/src/codegen/pipeline.ts
CHANGED
|
@@ -43,7 +43,7 @@ export type { GeneratedFile } from './targets/_shared/write-files.js'
|
|
|
43
43
|
* `_shared/write-files.ts`).
|
|
44
44
|
*/
|
|
45
45
|
export async function runPipeline(options: PipelineOptions): Promise<GeneratedFile[]> {
|
|
46
|
-
const { envelope, outDir, ajsc: ajscOpts, dryRun = false, namespaceTypes = false, selfContained = false, cleanOutDir =
|
|
46
|
+
const { envelope, outDir, ajsc: ajscOpts, dryRun = false, namespaceTypes = false, selfContained = false, cleanOutDir = true } = options
|
|
47
47
|
const serviceName = options.serviceName ?? 'Api'
|
|
48
48
|
validateServiceName(serviceName)
|
|
49
49
|
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { mkdir, readFile, rm, writeFile, stat, mkdtemp } from 'node:fs/promises'
|
|
2
|
+
import { mkdir, readFile, rm, writeFile, stat, mkdtemp, readdir } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { writeGeneratedFiles } from './write-files.js'
|
|
6
|
+
import { CODEGEN_HEADER } from '../../constants.js'
|
|
7
|
+
|
|
8
|
+
/** Build file contents that carry the package signature (a generated file). */
|
|
9
|
+
function signed(body = 'export const x = 1\n'): string {
|
|
10
|
+
return `${CODEGEN_HEADER}\n\n${body}`
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
describe('writeGeneratedFiles', () => {
|
|
8
14
|
describe('dryRun mode', () => {
|
|
@@ -32,22 +38,46 @@ describe('writeGeneratedFiles', () => {
|
|
|
32
38
|
await expect(stat(dir)).rejects.toBeInstanceOf(Error)
|
|
33
39
|
})
|
|
34
40
|
|
|
35
|
-
it('logs a clean-outDir notice when cleanOutDir is true', async () => {
|
|
36
|
-
await writeGeneratedFiles([], '/out', { dryRun: true, cleanOutDir: true })
|
|
37
|
-
expect(logSpy).toHaveBeenCalledWith('[dry-run] Would clean outDir: /out')
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('does not log clean-outDir when cleanOutDir is false', async () => {
|
|
41
|
-
await writeGeneratedFiles([{ path: '/out/x', code: 'x' }], '/out', { dryRun: true })
|
|
42
|
-
const messages = logSpy.mock.calls.map((c: unknown[]) => c[0])
|
|
43
|
-
expect(messages.find((m: unknown) => String(m).includes('Would clean'))).toBeUndefined()
|
|
44
|
-
})
|
|
45
|
-
|
|
46
41
|
it('counts bytes as utf-8', async () => {
|
|
47
42
|
// "é" is 2 bytes in utf-8.
|
|
48
43
|
await writeGeneratedFiles([{ path: '/x/e.txt', code: 'é' }], '/x', { dryRun: true })
|
|
49
44
|
expect(logSpy).toHaveBeenCalledWith('[dry-run] Would write: /x/e.txt (2 bytes)')
|
|
50
45
|
})
|
|
46
|
+
|
|
47
|
+
it('logs would-remove for an orphaned generated file under cleanOutDir, without deleting it', async () => {
|
|
48
|
+
const outDir = await mkdtemp(join(tmpdir(), 'write-files-dry-'))
|
|
49
|
+
try {
|
|
50
|
+
await writeFile(join(outDir, 'orphan.ts'), signed(), 'utf-8')
|
|
51
|
+
|
|
52
|
+
await writeGeneratedFiles(
|
|
53
|
+
[{ path: join(outDir, 'fresh.ts'), code: signed() }],
|
|
54
|
+
outDir,
|
|
55
|
+
{ dryRun: true, cleanOutDir: true },
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
59
|
+
`[dry-run] Would remove orphaned generated file: ${join(outDir, 'orphan.ts')}`,
|
|
60
|
+
)
|
|
61
|
+
// Nothing was actually removed.
|
|
62
|
+
expect(await readFile(join(outDir, 'orphan.ts'), 'utf-8')).toBe(signed())
|
|
63
|
+
} finally {
|
|
64
|
+
await rm(outDir, { recursive: true, force: true })
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('does not log a would-remove notice when cleanOutDir is false', async () => {
|
|
69
|
+
const outDir = await mkdtemp(join(tmpdir(), 'write-files-dry-'))
|
|
70
|
+
try {
|
|
71
|
+
await writeFile(join(outDir, 'orphan.ts'), signed(), 'utf-8')
|
|
72
|
+
await writeGeneratedFiles([{ path: join(outDir, 'x.ts'), code: signed() }], outDir, {
|
|
73
|
+
dryRun: true,
|
|
74
|
+
})
|
|
75
|
+
const messages = logSpy.mock.calls.map((c: unknown[]) => String(c[0]))
|
|
76
|
+
expect(messages.find((m: string) => m.includes('Would remove'))).toBeUndefined()
|
|
77
|
+
} finally {
|
|
78
|
+
await rm(outDir, { recursive: true, force: true })
|
|
79
|
+
}
|
|
80
|
+
})
|
|
51
81
|
})
|
|
52
82
|
|
|
53
83
|
describe('real write mode', () => {
|
|
@@ -79,32 +109,113 @@ describe('writeGeneratedFiles', () => {
|
|
|
79
109
|
expect(await readFile(join(nested, 'x.txt'), 'utf-8')).toBe('x')
|
|
80
110
|
})
|
|
81
111
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
112
|
+
describe('cleanOutDir (marker-based prune)', () => {
|
|
113
|
+
it('removes an orphaned generated file that is no longer emitted', async () => {
|
|
114
|
+
// A previously-generated scope file that this run does not re-emit.
|
|
115
|
+
await writeFile(join(outDir, 'orphan-scope.ts'), signed(), 'utf-8')
|
|
116
|
+
|
|
117
|
+
await writeGeneratedFiles(
|
|
118
|
+
[{ path: join(outDir, 'fresh.ts'), code: signed() }],
|
|
119
|
+
outDir,
|
|
120
|
+
{ cleanOutDir: true },
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
await expect(readFile(join(outDir, 'orphan-scope.ts'), 'utf-8')).rejects.toBeInstanceOf(
|
|
124
|
+
Error,
|
|
125
|
+
)
|
|
126
|
+
expect(await readFile(join(outDir, 'fresh.ts'), 'utf-8')).toBe(signed())
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('never deletes a file that lacks the package signature', async () => {
|
|
130
|
+
// Hand-written file the developer co-located in the output dir.
|
|
131
|
+
await writeFile(join(outDir, 'hand-written.ts'), 'export const mine = 1\n', 'utf-8')
|
|
132
|
+
// Even a file that merely imports from ts-procedures (no -codegen token).
|
|
133
|
+
await writeFile(
|
|
134
|
+
join(outDir, 'helper.ts'),
|
|
135
|
+
"import { createClient } from 'ts-procedures/client'\n",
|
|
136
|
+
'utf-8',
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
await writeGeneratedFiles(
|
|
140
|
+
[{ path: join(outDir, 'fresh.ts'), code: signed() }],
|
|
141
|
+
outDir,
|
|
142
|
+
{ cleanOutDir: true },
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
expect(await readFile(join(outDir, 'hand-written.ts'), 'utf-8')).toBe(
|
|
146
|
+
'export const mine = 1\n',
|
|
147
|
+
)
|
|
148
|
+
expect(await readFile(join(outDir, 'helper.ts'), 'utf-8')).toBe(
|
|
149
|
+
"import { createClient } from 'ts-procedures/client'\n",
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('does not delete a signed file that is part of the current output', async () => {
|
|
154
|
+
// Pre-existing signed file that IS re-emitted this run — it should be
|
|
155
|
+
// overwritten, not removed-then-rewritten in a way that loses it.
|
|
156
|
+
await writeFile(join(outDir, 'users.ts'), signed('old'), 'utf-8')
|
|
157
|
+
|
|
158
|
+
await writeGeneratedFiles(
|
|
159
|
+
[{ path: join(outDir, 'users.ts'), code: signed('new') }],
|
|
160
|
+
outDir,
|
|
161
|
+
{ cleanOutDir: true },
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
expect(await readFile(join(outDir, 'users.ts'), 'utf-8')).toBe(signed('new'))
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('prunes orphans left by a different package version (version-agnostic)', async () => {
|
|
168
|
+
// A file generated by an older release: different version in the header.
|
|
169
|
+
const oldHeader = '// Auto-generated by ts-procedures-codegen (v0.0.1) — do not edit'
|
|
170
|
+
await writeFile(join(outDir, 'legacy.ts'), `${oldHeader}\n\nexport const y = 2\n`, 'utf-8')
|
|
171
|
+
|
|
172
|
+
await writeGeneratedFiles(
|
|
173
|
+
[{ path: join(outDir, 'fresh.ts'), code: signed() }],
|
|
174
|
+
outDir,
|
|
175
|
+
{ cleanOutDir: true },
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
await expect(readFile(join(outDir, 'legacy.ts'), 'utf-8')).rejects.toBeInstanceOf(Error)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('skips subdirectories entirely', async () => {
|
|
182
|
+
const sub = join(outDir, 'nested')
|
|
183
|
+
await mkdir(sub, { recursive: true })
|
|
184
|
+
await writeFile(join(sub, 'inner.ts'), signed(), 'utf-8')
|
|
185
|
+
|
|
186
|
+
await writeGeneratedFiles(
|
|
187
|
+
[{ path: join(outDir, 'fresh.ts'), code: signed() }],
|
|
188
|
+
outDir,
|
|
189
|
+
{ cleanOutDir: true },
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// The nested dir and its signed file are untouched.
|
|
193
|
+
expect(await readFile(join(sub, 'inner.ts'), 'utf-8')).toBe(signed())
|
|
194
|
+
const entries = await readdir(outDir)
|
|
195
|
+
expect(entries).toContain('nested')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('does not throw when outDir does not exist yet', async () => {
|
|
199
|
+
const fresh = join(outDir, 'brand-new')
|
|
200
|
+
await expect(
|
|
201
|
+
writeGeneratedFiles([{ path: join(fresh, 'a.ts'), code: signed() }], fresh, {
|
|
202
|
+
cleanOutDir: true,
|
|
203
|
+
}),
|
|
204
|
+
).resolves.toBeUndefined()
|
|
205
|
+
expect(await readFile(join(fresh, 'a.ts'), 'utf-8')).toBe(signed())
|
|
206
|
+
})
|
|
95
207
|
})
|
|
96
208
|
|
|
97
|
-
it('does not remove
|
|
98
|
-
await
|
|
209
|
+
it('does not remove anything when cleanOutDir is false', async () => {
|
|
210
|
+
await writeFile(join(outDir, 'orphan.ts'), signed(), 'utf-8')
|
|
99
211
|
await writeFile(join(outDir, 'keep.txt'), 'keep', 'utf-8')
|
|
100
212
|
|
|
101
|
-
await writeGeneratedFiles(
|
|
102
|
-
[{ path: join(outDir, 'new.txt'), code: 'new' }],
|
|
103
|
-
outDir,
|
|
104
|
-
)
|
|
213
|
+
await writeGeneratedFiles([{ path: join(outDir, 'new.ts'), code: signed() }], outDir)
|
|
105
214
|
|
|
215
|
+
// Even a signed orphan stays put when cleanOutDir is not requested.
|
|
216
|
+
expect(await readFile(join(outDir, 'orphan.ts'), 'utf-8')).toBe(signed())
|
|
106
217
|
expect(await readFile(join(outDir, 'keep.txt'), 'utf-8')).toBe('keep')
|
|
107
|
-
expect(await readFile(join(outDir, 'new.
|
|
218
|
+
expect(await readFile(join(outDir, 'new.ts'), 'utf-8')).toBe(signed())
|
|
108
219
|
})
|
|
109
220
|
})
|
|
110
221
|
})
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
|
1
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { CODEGEN_SIGNATURE } from '../../constants.js'
|
|
2
4
|
|
|
3
5
|
export interface GeneratedFile {
|
|
4
6
|
path: string
|
|
@@ -8,10 +10,46 @@ export interface GeneratedFile {
|
|
|
8
10
|
export interface WriteOptions {
|
|
9
11
|
/** When true, log what would happen instead of touching the filesystem. */
|
|
10
12
|
dryRun?: boolean
|
|
11
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* When true, remove orphaned generated files from `outDir` before writing —
|
|
15
|
+
* i.e. files this run no longer emits that carry the `ts-procedures-codegen`
|
|
16
|
+
* signature. Files lacking the signature (hand-written, other tools' output)
|
|
17
|
+
* are never touched.
|
|
18
|
+
*/
|
|
12
19
|
cleanOutDir?: boolean
|
|
13
20
|
}
|
|
14
21
|
|
|
22
|
+
/** Only the head of a file is read to detect the signature; it lives at the top. */
|
|
23
|
+
const SIGNATURE_SCAN_BYTES = 512
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns the top-level files in `outDir` that carry the package signature but
|
|
27
|
+
* are NOT part of `written` — the orphans left behind by a prior run whose
|
|
28
|
+
* scope/route no longer exists. Subdirectories are skipped (targets emit flat),
|
|
29
|
+
* and any file without the signature is left untouched.
|
|
30
|
+
*/
|
|
31
|
+
async function findOrphans(outDir: string, written: Set<string>): Promise<string[]> {
|
|
32
|
+
let entries
|
|
33
|
+
try {
|
|
34
|
+
entries = await readdir(outDir, { withFileTypes: true })
|
|
35
|
+
} catch (err) {
|
|
36
|
+
// Nothing to prune if the dir doesn't exist yet (first run).
|
|
37
|
+
if ((err as { code?: string }).code === 'ENOENT') return []
|
|
38
|
+
throw err
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const orphans: string[] = []
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (!entry.isFile()) continue
|
|
44
|
+
const path = join(outDir, entry.name)
|
|
45
|
+
if (written.has(path)) continue // re-emitted this run — it will be overwritten
|
|
46
|
+
const buf = await readFile(path)
|
|
47
|
+
const head = buf.subarray(0, SIGNATURE_SCAN_BYTES).toString('utf-8')
|
|
48
|
+
if (head.includes(CODEGEN_SIGNATURE)) orphans.push(path)
|
|
49
|
+
}
|
|
50
|
+
return orphans
|
|
51
|
+
}
|
|
52
|
+
|
|
15
53
|
/**
|
|
16
54
|
* Persists the generated files to disk (or simulates the writes under
|
|
17
55
|
* `dryRun`). Centralises the dryRun / cleanOutDir / mkdir / writeFile
|
|
@@ -20,10 +58,11 @@ export interface WriteOptions {
|
|
|
20
58
|
*
|
|
21
59
|
* Behaviour:
|
|
22
60
|
* - `dryRun: true` — logs `[dry-run] Would write: <path> (<bytes> bytes)` per
|
|
23
|
-
* file, plus
|
|
24
|
-
* `cleanOutDir: true`. No filesystem mutation.
|
|
25
|
-
* - `dryRun: false` (default) — when `cleanOutDir`,
|
|
26
|
-
* `
|
|
61
|
+
* file, plus `[dry-run] Would remove orphaned generated file: <path>` for
|
|
62
|
+
* each prunable orphan when `cleanOutDir: true`. No filesystem mutation.
|
|
63
|
+
* - `dryRun: false` (default) — when `cleanOutDir`, removes orphaned generated
|
|
64
|
+
* files (signed by `ts-procedures-codegen`, no longer emitted); then ensures
|
|
65
|
+
* `outDir` exists; then writes each file as utf-8.
|
|
27
66
|
*/
|
|
28
67
|
export async function writeGeneratedFiles(
|
|
29
68
|
files: readonly GeneratedFile[],
|
|
@@ -31,10 +70,13 @@ export async function writeGeneratedFiles(
|
|
|
31
70
|
opts: WriteOptions = {},
|
|
32
71
|
): Promise<void> {
|
|
33
72
|
const { dryRun = false, cleanOutDir = false } = opts
|
|
73
|
+
const written = new Set(files.map((f) => f.path))
|
|
34
74
|
|
|
35
75
|
if (dryRun) {
|
|
36
76
|
if (cleanOutDir) {
|
|
37
|
-
|
|
77
|
+
for (const orphan of await findOrphans(outDir, written)) {
|
|
78
|
+
console.log(`[dry-run] Would remove orphaned generated file: ${orphan}`)
|
|
79
|
+
}
|
|
38
80
|
}
|
|
39
81
|
for (const f of files) {
|
|
40
82
|
const bytes = Buffer.byteLength(f.code, 'utf-8')
|
|
@@ -44,7 +86,10 @@ export async function writeGeneratedFiles(
|
|
|
44
86
|
}
|
|
45
87
|
|
|
46
88
|
if (cleanOutDir) {
|
|
47
|
-
|
|
89
|
+
for (const orphan of await findOrphans(outDir, written)) {
|
|
90
|
+
await rm(orphan, { force: true })
|
|
91
|
+
console.log(`[ts-procedures-codegen] Removed orphaned generated file: ${orphan}`)
|
|
92
|
+
}
|
|
48
93
|
}
|
|
49
94
|
await mkdir(outDir, { recursive: true })
|
|
50
95
|
for (const f of files) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ScopeGroup } from '../../group-routes.js'
|
|
2
2
|
import type { KotlinEmitter } from './ajsc-adapter.js'
|
|
3
3
|
import { emitKotlinRoute, type EmitRouteOpts } from './emit-route-kotlin.js'
|
|
4
|
-
import { kotlinPackageDecl, kotlinSourceHashHeader, kotlinImports } from './format-kotlin.js'
|
|
4
|
+
import { kotlinPackageDecl, kotlinSignatureHeader, kotlinSourceHashHeader, kotlinImports } from './format-kotlin.js'
|
|
5
5
|
import { indent } from '../_shared/indent.js'
|
|
6
6
|
import { pickDefined } from '../_shared/pick-defined.js'
|
|
7
7
|
import { pascalCase } from '../_shared/pascal-case.js'
|
|
@@ -49,9 +49,10 @@ export function emitKotlinScope(
|
|
|
49
49
|
: `object ${scopeName} {\n${innerScope}\n}`
|
|
50
50
|
|
|
51
51
|
const importsBlock = kotlinImports(allImports)
|
|
52
|
+
const headerBlock = [kotlinSignatureHeader(), kotlinSourceHashHeader(opts.sourceHash)].join('\n')
|
|
52
53
|
const parts = [
|
|
53
54
|
kotlinPackageDecl(opts.kotlinPackage),
|
|
54
|
-
|
|
55
|
+
headerBlock,
|
|
55
56
|
importsBlock,
|
|
56
57
|
scopeBlock,
|
|
57
58
|
].filter((p) => p.length > 0)
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import {
|
|
3
3
|
kotlinPackageDecl,
|
|
4
|
+
kotlinSignatureHeader,
|
|
4
5
|
kotlinSourceHashHeader,
|
|
5
6
|
kotlinImports,
|
|
6
7
|
} from './format-kotlin.js'
|
|
8
|
+
import { CODEGEN_SIGNATURE } from '../../constants.js'
|
|
7
9
|
|
|
8
10
|
describe('format-kotlin', () => {
|
|
9
11
|
it('emits a package declaration', () => {
|
|
10
12
|
expect(kotlinPackageDecl('com.example.api')).toBe('package com.example.api')
|
|
11
13
|
})
|
|
12
14
|
|
|
15
|
+
it('emits a signature header line carrying the package token', () => {
|
|
16
|
+
const header = kotlinSignatureHeader()
|
|
17
|
+
expect(header).toBe(`// ${CODEGEN_SIGNATURE} — do not edit`)
|
|
18
|
+
// The marker-based prune depends on this token being present.
|
|
19
|
+
expect(header).toContain(CODEGEN_SIGNATURE)
|
|
20
|
+
})
|
|
21
|
+
|
|
13
22
|
it('emits a source-hash header line', () => {
|
|
14
23
|
expect(kotlinSourceHashHeader('abc123')).toBe('// Source hash: abc123')
|
|
15
24
|
})
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
import { CODEGEN_SIGNATURE } from '../../constants.js'
|
|
2
|
+
|
|
1
3
|
export function kotlinPackageDecl(pkg: string): string {
|
|
2
4
|
return `package ${pkg}`
|
|
3
5
|
}
|
|
4
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Stable signature comment embedded at the top of every generated Kotlin file.
|
|
9
|
+
* Carries the version-agnostic `ts-procedures-codegen` token so the marker-based
|
|
10
|
+
* prune in `writeGeneratedFiles` can recognise generated Kotlin files as owned.
|
|
11
|
+
*/
|
|
12
|
+
export function kotlinSignatureHeader(): string {
|
|
13
|
+
return `// ${CODEGEN_SIGNATURE} — do not edit`
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
export function kotlinSourceHashHeader(hash: string): string {
|
|
6
17
|
return `// Source hash: ${hash}`
|
|
7
18
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { CODEGEN_SIGNATURE } from '../../constants.js'
|
|
2
|
+
|
|
1
3
|
export function swiftHeader(sourceHash: string): string {
|
|
2
|
-
|
|
4
|
+
// The marker-based prune in `writeGeneratedFiles` relies on CODEGEN_SIGNATURE
|
|
5
|
+
// appearing in this header.
|
|
6
|
+
return `// Generated by ${CODEGEN_SIGNATURE} — do not edit.\n// Source hash: ${sourceHash}`
|
|
3
7
|
}
|
|
4
8
|
|
|
5
9
|
export function swiftImports(imports: string[]): string {
|
|
@@ -22,7 +22,7 @@ export async function assertGoldenOrUpdate(produced: string, goldenPath: string)
|
|
|
22
22
|
'// Source hash: <PLACEHOLDER>',
|
|
23
23
|
)
|
|
24
24
|
await writeFile(goldenPath, goldenContent, 'utf-8')
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
console.log(`[golden-test] Wrote golden: ${goldenPath}`)
|
|
27
27
|
return
|
|
28
28
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ProcedureError, ProcedureRegistrationError, ProcedureValidationError, ProcedureYieldValidationError } from './errors.js'
|
|
2
2
|
import { computeSchema } from './schema/compute-schema.js'
|
|
3
|
-
import { Prettify, TSchemaLib } from './schema/types.js'
|
|
3
|
+
import type { Prettify, TSchemaLib } from './schema/types.js'
|
|
4
4
|
import { captureDefinitionInfo } from './stack-utils.js'
|
|
5
|
-
import {
|
|
5
|
+
import type {
|
|
6
6
|
HttpMethod, TBuilderConfig, TStreamContext,
|
|
7
7
|
THttpStreamProcedureRegistration, TProcedureRegistration, TStreamProcedureRegistration, THttpProcedureRegistration,
|
|
8
8
|
} from './types.js'
|
package/src/create-http.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { ProcedureError, ProcedureRegistrationError, ProcedureValidationError } from './errors.js'
|
|
2
2
|
import { computeSchema } from './schema/compute-schema.js'
|
|
3
|
-
import { Prettify, TSchemaLib } from './schema/types.js'
|
|
3
|
+
import type { Prettify, TSchemaLib } from './schema/types.js'
|
|
4
4
|
import { captureDefinitionInfo } from './stack-utils.js'
|
|
5
|
-
import {
|
|
6
|
-
HttpMethod,
|
|
5
|
+
import type {
|
|
7
6
|
TBuilderConfig,
|
|
8
7
|
TCreateHttpConfig,
|
|
9
8
|
TLocalContext,
|
package/src/create-stream.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ProcedureError, ProcedureRegistrationError, ProcedureValidationError, ProcedureYieldValidationError } from './errors.js'
|
|
2
2
|
import { computeSchema } from './schema/compute-schema.js'
|
|
3
|
-
import { Prettify, TSchemaLib } from './schema/types.js'
|
|
3
|
+
import type { Prettify, TSchemaLib } from './schema/types.js'
|
|
4
4
|
import { captureDefinitionInfo } from './stack-utils.js'
|
|
5
|
-
import {
|
|
5
|
+
import type {
|
|
6
6
|
TBuilderConfig,
|
|
7
7
|
THttpProcedureRegistration,
|
|
8
8
|
THttpStreamProcedureRegistration,
|
package/src/create.test.ts
CHANGED
package/src/create.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ProcedureError, ProcedureRegistrationError, ProcedureValidationError } from './errors.js'
|
|
2
2
|
import { computeSchema } from './schema/compute-schema.js'
|
|
3
|
-
import { Prettify, TSchemaLib } from './schema/types.js'
|
|
3
|
+
import type { Prettify, TSchemaLib } from './schema/types.js'
|
|
4
4
|
import { captureDefinitionInfo } from './stack-utils.js'
|
|
5
|
-
import {
|
|
5
|
+
import type {
|
|
6
6
|
TBuilderConfig,
|
|
7
7
|
THttpProcedureRegistration,
|
|
8
8
|
THttpStreamProcedureRegistration,
|
package/src/errors.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
ProcedureValidationError,
|
|
5
5
|
ProcedureRegistrationError,
|
|
6
6
|
} from './errors.js'
|
|
7
|
-
import { DefinitionInfo } from './stack-utils.js'
|
|
7
|
+
import type { DefinitionInfo } from './stack-utils.js'
|
|
8
8
|
|
|
9
9
|
describe('Error Classes', () => {
|
|
10
10
|
test('ProcedureError has correct properties', () => {
|