ts-procedures 10.2.2 → 10.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/build/adapters/hono/index.d.ts +2 -0
- package/build/adapters/hono/index.js +14 -0
- package/build/adapters/hono/index.js.map +1 -1
- package/build/adapters/hono/index.test.js +30 -0
- package/build/adapters/hono/index.test.js.map +1 -1
- package/build/codegen/emit/route-shared.d.ts +1 -5
- package/build/codegen/emit/route-shared.js +3 -10
- package/build/codegen/emit/route-shared.js.map +1 -1
- package/build/codegen/group-routes.d.ts +2 -7
- package/build/codegen/group-routes.js +4 -13
- package/build/codegen/group-routes.js.map +1 -1
- package/build/codegen/naming.d.ts +2 -2
- package/build/codegen/naming.js +4 -7
- package/build/codegen/naming.js.map +1 -1
- package/build/core/create-http-stream.js +1 -0
- package/build/core/create-http-stream.js.map +1 -1
- package/build/core/create-http.js +1 -0
- package/build/core/create-http.js.map +1 -1
- package/build/core/create-stream.js +1 -0
- package/build/core/create-stream.js.map +1 -1
- package/build/core/create.js +1 -0
- package/build/core/create.js.map +1 -1
- package/build/core/types.d.ts +9 -0
- package/build/naming.d.ts +22 -0
- package/build/naming.js +39 -0
- package/build/naming.js.map +1 -0
- package/build/server/index.d.ts +1 -0
- package/build/server/index.js +1 -0
- package/build/server/index.js.map +1 -1
- package/build/server/scope-name-collision.d.ts +17 -0
- package/build/server/scope-name-collision.js +99 -0
- package/build/server/scope-name-collision.js.map +1 -0
- package/build/server/scope-name-collision.test.d.ts +1 -0
- package/build/server/scope-name-collision.test.js +134 -0
- package/build/server/scope-name-collision.test.js.map +1 -0
- package/docs/client-and-codegen.md +1 -1
- package/package.json +1 -1
- package/src/adapters/hono/index.test.ts +36 -0
- package/src/adapters/hono/index.ts +16 -0
- package/src/codegen/emit/route-shared.ts +3 -9
- package/src/codegen/group-routes.ts +5 -12
- package/src/codegen/naming.ts +4 -7
- package/src/core/create-http-stream.ts +1 -0
- package/src/core/create-http.ts +1 -0
- package/src/core/create-stream.ts +1 -0
- package/src/core/create.ts +1 -0
- package/src/core/types.ts +9 -0
- package/src/naming.ts +38 -0
- package/src/server/index.ts +1 -0
- package/src/server/scope-name-collision.test.ts +151 -0
- package/src/server/scope-name-collision.ts +112 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { Procedures } from '../core/procedures.js'
|
|
3
|
+
import { ProcedureRegistrationError } from '../core/errors.js'
|
|
4
|
+
import { toPascalCase, versionedPascal } from '../naming.js'
|
|
5
|
+
import type { RPCConfig } from './types.js'
|
|
6
|
+
import type { AnyProcedureRegistration } from '../core/types.js'
|
|
7
|
+
import { assertNoScopeNameCollisions, generatedRouteName } from './scope-name-collision.js'
|
|
8
|
+
|
|
9
|
+
type Reg = AnyProcedureRegistration<any, any>
|
|
10
|
+
|
|
11
|
+
describe('generatedRouteName — per-kind dispatch', () => {
|
|
12
|
+
// generatedRouteName routes each kind to the right shared naming primitive
|
|
13
|
+
// (versionedPascal for rpc/rpc-stream, toPascalCase for http kinds) — the same
|
|
14
|
+
// primitives the codegen emitters use, so the guard and codegen can't disagree.
|
|
15
|
+
test('rpc / rpc-stream match versionedPascal(name, version)', () => {
|
|
16
|
+
const cases: { name: string; version: number }[] = [
|
|
17
|
+
{ name: 'get-task', version: 1 },
|
|
18
|
+
{ name: 'getTask', version: 2 },
|
|
19
|
+
{ name: 'log_out', version: 3 },
|
|
20
|
+
{ name: 'Foo', version: 1 },
|
|
21
|
+
]
|
|
22
|
+
for (const { name, version } of cases) {
|
|
23
|
+
const rpc = { name, kind: 'rpc', config: { version } } as unknown as Reg
|
|
24
|
+
const stream = { name, kind: 'rpc-stream', config: { version } } as unknown as Reg
|
|
25
|
+
expect(generatedRouteName(rpc)).toBe(versionedPascal(name, version))
|
|
26
|
+
expect(generatedRouteName(stream)).toBe(versionedPascal(name, version))
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('http / http-stream match toPascalCase(name), ignoring any version', () => {
|
|
31
|
+
for (const name of ['get-user', 'getUser', 'log out', 'list_items']) {
|
|
32
|
+
const http = { name, kind: 'http', config: { method: 'get', path: '/x', version: 9 } } as unknown as Reg
|
|
33
|
+
const httpStream = { name, kind: 'http-stream', config: { method: 'get', path: '/x' } } as unknown as Reg
|
|
34
|
+
expect(generatedRouteName(http)).toBe(toPascalCase(name))
|
|
35
|
+
expect(generatedRouteName(httpStream)).toBe(toPascalCase(name))
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('version 1 produces no V suffix; version > 1 does', () => {
|
|
40
|
+
const v1 = { name: 'Thing', kind: 'rpc', config: { version: 1 } } as unknown as Reg
|
|
41
|
+
const v2 = { name: 'Thing', kind: 'rpc', config: { version: 2 } } as unknown as Reg
|
|
42
|
+
expect(generatedRouteName(v1)).toBe('Thing')
|
|
43
|
+
expect(generatedRouteName(v2)).toBe('ThingV2')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('assertNoScopeNameCollisions', () => {
|
|
48
|
+
test('no-op when every procedure generates a distinct scoped name', () => {
|
|
49
|
+
const P = Procedures<object, RPCConfig>()
|
|
50
|
+
P.Create('GetTask', { scope: 'tasks', version: 1 }, async () => ({}))
|
|
51
|
+
P.Create('ListTasks', { scope: 'tasks', version: 1 }, async () => ({}))
|
|
52
|
+
expect(() => assertNoScopeNameCollisions(P.getProcedures())).not.toThrow()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('same name in DIFFERENT scopes is allowed (separate scope files)', () => {
|
|
56
|
+
const A = Procedures<object, RPCConfig>()
|
|
57
|
+
A.Create('Get', { scope: 'users', version: 1 }, async () => ({}))
|
|
58
|
+
const B = Procedures<object, RPCConfig>()
|
|
59
|
+
B.Create('Get', { scope: 'orders', version: 1 }, async () => ({}))
|
|
60
|
+
expect(() => assertNoScopeNameCollisions([...A.getProcedures(), ...B.getProcedures()])).not.toThrow()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('detects names that pascalize to the same identifier in one scope', () => {
|
|
64
|
+
const P = Procedures<object, RPCConfig>()
|
|
65
|
+
// distinct raw names → both register on the core registry...
|
|
66
|
+
P.Create('get-task', { scope: 'tasks', version: 1 }, async () => ({}))
|
|
67
|
+
P.Create('getTask', { scope: 'tasks', version: 1 }, async () => ({}))
|
|
68
|
+
// ...but both pascalize to "GetTask".
|
|
69
|
+
expect(() => assertNoScopeNameCollisions(P.getProcedures())).toThrow(ProcedureRegistrationError)
|
|
70
|
+
expect(() => assertNoScopeNameCollisions(P.getProcedures())).toThrow(/GetTask/)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('detects the same (scope, name) contributed by two separate factories', () => {
|
|
74
|
+
const A = Procedures<object, RPCConfig>()
|
|
75
|
+
A.Create('LogOut', { scope: 'auth', version: 1 }, async () => ({}))
|
|
76
|
+
const B = Procedures<object, RPCConfig>()
|
|
77
|
+
B.Create('LogOut', { scope: 'auth', version: 1 }, async () => ({}))
|
|
78
|
+
|
|
79
|
+
const all = [...A.getProcedures(), ...B.getProcedures()]
|
|
80
|
+
expect(() => assertNoScopeNameCollisions(all)).toThrow(/Scope "auth".*LogOut/s)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('version disambiguates: Foo@v1 and Foo@v2 (across factories) do not collide', () => {
|
|
84
|
+
// Same raw name can't register twice in one factory, but two factories can
|
|
85
|
+
// each contribute a "Foo" — distinct versions keep their generated names apart.
|
|
86
|
+
const A = Procedures<object, RPCConfig>()
|
|
87
|
+
A.Create('Foo', { scope: 's', version: 1 }, async () => ({}))
|
|
88
|
+
const B = Procedures<object, RPCConfig>()
|
|
89
|
+
B.Create('Foo', { scope: 's', version: 2 }, async () => ({}))
|
|
90
|
+
expect(() => assertNoScopeNameCollisions([...A.getProcedures(), ...B.getProcedures()])).not.toThrow()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('version collision: Foo@v2 and FooV2@v1 both generate "FooV2"', () => {
|
|
94
|
+
const P = Procedures<object, RPCConfig>()
|
|
95
|
+
P.Create('Foo', { scope: 's', version: 2 }, async () => ({}))
|
|
96
|
+
P.Create('FooV2', { scope: 's', version: 1 }, async () => ({}))
|
|
97
|
+
expect(() => assertNoScopeNameCollisions(P.getProcedures())).toThrow(/FooV2/)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('rpc and http procedures collide when they share scope + generated name', () => {
|
|
101
|
+
const A = Procedures<object, RPCConfig>()
|
|
102
|
+
A.Create('Thing', { scope: 'mix', version: 1 }, async () => ({}))
|
|
103
|
+
const B = Procedures<object, RPCConfig>()
|
|
104
|
+
B.CreateHttp('Thing', { scope: 'mix', path: '/thing', method: 'get' }, async () => undefined)
|
|
105
|
+
expect(() => assertNoScopeNameCollisions([...A.getProcedures(), ...B.getProcedures()])).toThrow(/Thing/)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('procedures with no scope are grouped under "default" and can collide', () => {
|
|
109
|
+
const P = Procedures<object, RPCConfig>()
|
|
110
|
+
P.CreateHttp('do-thing', { path: '/a', method: 'get' }, async () => undefined)
|
|
111
|
+
P.CreateHttp('doThing', { path: '/b', method: 'get' }, async () => undefined)
|
|
112
|
+
expect(() => assertNoScopeNameCollisions(P.getProcedures())).toThrow(/Scope "default".*DoThing/s)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('error message names both procedures and includes their definition sites', () => {
|
|
116
|
+
const P = Procedures<object, RPCConfig>()
|
|
117
|
+
P.Create('get-task', { scope: 'tasks', version: 1 }, async () => ({}))
|
|
118
|
+
P.Create('getTask', { scope: 'tasks', version: 1 }, async () => ({}))
|
|
119
|
+
|
|
120
|
+
let caught: ProcedureRegistrationError | undefined
|
|
121
|
+
try {
|
|
122
|
+
assertNoScopeNameCollisions(P.getProcedures())
|
|
123
|
+
} catch (err) {
|
|
124
|
+
caught = err as ProcedureRegistrationError
|
|
125
|
+
}
|
|
126
|
+
expect(caught).toBeInstanceOf(ProcedureRegistrationError)
|
|
127
|
+
expect(caught!.message).toContain('"get-task"')
|
|
128
|
+
expect(caught!.message).toContain('"getTask"')
|
|
129
|
+
expect(caught!.message).toContain('GetTask')
|
|
130
|
+
// definition site captured at registration flows into the trace
|
|
131
|
+
expect(caught!.message).toContain('scope-name-collision.test.ts')
|
|
132
|
+
expect(caught!.getDefinitionLocation()).toContain('scope-name-collision.test.ts')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('reports every colliding group in one error', () => {
|
|
136
|
+
const P = Procedures<object, RPCConfig>()
|
|
137
|
+
P.Create('a-one', { scope: 's', version: 1 }, async () => ({}))
|
|
138
|
+
P.Create('aOne', { scope: 's', version: 1 }, async () => ({}))
|
|
139
|
+
P.Create('b-two', { scope: 's', version: 1 }, async () => ({}))
|
|
140
|
+
P.Create('bTwo', { scope: 's', version: 1 }, async () => ({}))
|
|
141
|
+
|
|
142
|
+
let message = ''
|
|
143
|
+
try {
|
|
144
|
+
assertNoScopeNameCollisions(P.getProcedures())
|
|
145
|
+
} catch (err) {
|
|
146
|
+
message = (err as Error).message
|
|
147
|
+
}
|
|
148
|
+
expect(message).toContain('AOne')
|
|
149
|
+
expect(message).toContain('BTwo')
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-start guard that mirrors the codegen TS emitter's per-scope name
|
|
3
|
+
* collision check (`src/codegen/emit/scope-file.ts`).
|
|
4
|
+
*
|
|
5
|
+
* The core registry only enforces that raw procedure NAMES are unique within a
|
|
6
|
+
* single factory. That misses two cases the client codegen rejects — and which
|
|
7
|
+
* therefore produce a server that boots happily but can never have a working
|
|
8
|
+
* generated client:
|
|
9
|
+
*
|
|
10
|
+
* 1. Two names in the same scope that PascalCase to the same identifier
|
|
11
|
+
* (e.g. `get-task` and `getTask` → `GetTask`), and
|
|
12
|
+
* 2. The same `(scope, name)` registered across two different factories that
|
|
13
|
+
* are mounted on one app (each factory has its own registry, so neither
|
|
14
|
+
* duplicate check fires).
|
|
15
|
+
*
|
|
16
|
+
* Both collapse to a single generated type + callable key in the emitted client
|
|
17
|
+
* (duplicate identifier / duplicate object key), which is exactly what
|
|
18
|
+
* `emitScopeFile` throws on. We surface the same failure at server start, with a
|
|
19
|
+
* trace pointing at every colliding definition site, so it's caught in the
|
|
20
|
+
* server developer's own codebase instead of downstream at codegen time.
|
|
21
|
+
*/
|
|
22
|
+
import { ProcedureRegistrationError } from '../core/errors.js'
|
|
23
|
+
import type { DefinitionInfo } from '../core/definition-site.js'
|
|
24
|
+
import type { AnyProcedureRegistration } from '../core/types.js'
|
|
25
|
+
import { normalizeScope, toPascalCase, versionedPascal } from '../naming.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The identifier a procedure generates in the client. Built from the same
|
|
29
|
+
* shared naming primitives the codegen emitters use, so the two cannot disagree:
|
|
30
|
+
* - `rpc` / `rpc-stream` → `versionedPascal(name, version)` (V{n} when version > 1)
|
|
31
|
+
* - `http` / `http-stream` → `toPascalCase(name)` (no version suffix)
|
|
32
|
+
*/
|
|
33
|
+
export function generatedRouteName(procedure: AnyProcedureRegistration<any, any>): string {
|
|
34
|
+
if (procedure.kind === 'rpc' || procedure.kind === 'rpc-stream') {
|
|
35
|
+
return versionedPascal(procedure.name, (procedure.config as { version?: number }).version)
|
|
36
|
+
}
|
|
37
|
+
return toPascalCase(procedure.name)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The scope grouping key codegen would file this procedure under. */
|
|
41
|
+
function scopeKeyOf(procedure: AnyProcedureRegistration<any, any>): string {
|
|
42
|
+
return normalizeScope((procedure.config as { scope?: string | string[] }).scope)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function definedAtString(info: DefinitionInfo | undefined): string {
|
|
46
|
+
const at = info?.definedAt
|
|
47
|
+
return at ? ` (defined at ${at.file}:${at.line}:${at.column})` : ''
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Throws a {@link ProcedureRegistrationError} if any two procedures collapse to
|
|
52
|
+
* the same `(scope, generatedName)`. No-op when every procedure is distinct.
|
|
53
|
+
*
|
|
54
|
+
* Call this once with every procedure that will be served on a single app —
|
|
55
|
+
* including procedures contributed by separate factories — since cross-factory
|
|
56
|
+
* collisions are the case the per-factory registry can't see.
|
|
57
|
+
*/
|
|
58
|
+
export function assertNoScopeNameCollisions(
|
|
59
|
+
procedures: Iterable<AnyProcedureRegistration<any, any>>,
|
|
60
|
+
): void {
|
|
61
|
+
// scopeKey → generatedName → procedures that produced it
|
|
62
|
+
const byScope = new Map<string, Map<string, AnyProcedureRegistration<any, any>[]>>()
|
|
63
|
+
|
|
64
|
+
for (const procedure of procedures) {
|
|
65
|
+
const scopeKey = scopeKeyOf(procedure)
|
|
66
|
+
const generated = generatedRouteName(procedure)
|
|
67
|
+
let byName = byScope.get(scopeKey)
|
|
68
|
+
if (byName === undefined) {
|
|
69
|
+
byName = new Map()
|
|
70
|
+
byScope.set(scopeKey, byName)
|
|
71
|
+
}
|
|
72
|
+
const existing = byName.get(generated)
|
|
73
|
+
if (existing === undefined) byName.set(generated, [procedure])
|
|
74
|
+
else existing.push(procedure)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const collisions: { scopeKey: string; generated: string; procs: AnyProcedureRegistration<any, any>[] }[] = []
|
|
78
|
+
for (const [scopeKey, byName] of byScope) {
|
|
79
|
+
for (const [generated, procs] of byName) {
|
|
80
|
+
if (procs.length > 1) collisions.push({ scopeKey, generated, procs })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (collisions.length === 0) return
|
|
85
|
+
|
|
86
|
+
const details = collisions
|
|
87
|
+
.map(({ scopeKey, generated, procs }) => {
|
|
88
|
+
const lines = procs
|
|
89
|
+
.map((p) => ` - "${p.name}" (${p.kind})${definedAtString(p.definitionInfo)}`)
|
|
90
|
+
.join('\n')
|
|
91
|
+
return ` Scope "${scopeKey}" → generated name "${generated}":\n${lines}`
|
|
92
|
+
})
|
|
93
|
+
.join('\n')
|
|
94
|
+
|
|
95
|
+
// Anchor the error (and its appended definition-site frame) on the first
|
|
96
|
+
// colliding procedure; the message enumerates every collision.
|
|
97
|
+
const first = collisions[0]!.procs[0]!
|
|
98
|
+
|
|
99
|
+
throw new ProcedureRegistrationError(
|
|
100
|
+
first.name,
|
|
101
|
+
`Procedure name collision — the server cannot start.\n\n` +
|
|
102
|
+
`These procedures collapse to the same generated client identifier within the same scope. ` +
|
|
103
|
+
`The generated client would emit duplicate types and a duplicate callable key for each pair ` +
|
|
104
|
+
`(this is the same failure the codegen rejects), so the server refuses to start rather than ` +
|
|
105
|
+
`boot a config no client can be generated for:\n\n` +
|
|
106
|
+
`${details}\n\n` +
|
|
107
|
+
`Names are PascalCased per scope, so different raw names can still collide ` +
|
|
108
|
+
`(e.g. "get-task" and "getTask" both become "GetTask"). Rename one procedure in each pair, ` +
|
|
109
|
+
`or move it to a different scope, so every procedure in a scope generates a distinct name.`,
|
|
110
|
+
first.definitionInfo,
|
|
111
|
+
)
|
|
112
|
+
}
|