ts-procedures 10.2.2 → 10.3.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 (52) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/build/adapters/hono/index.d.ts +2 -0
  3. package/build/adapters/hono/index.js +14 -0
  4. package/build/adapters/hono/index.js.map +1 -1
  5. package/build/adapters/hono/index.test.js +30 -0
  6. package/build/adapters/hono/index.test.js.map +1 -1
  7. package/build/codegen/emit/route-shared.d.ts +1 -5
  8. package/build/codegen/emit/route-shared.js +3 -10
  9. package/build/codegen/emit/route-shared.js.map +1 -1
  10. package/build/codegen/group-routes.d.ts +2 -7
  11. package/build/codegen/group-routes.js +4 -13
  12. package/build/codegen/group-routes.js.map +1 -1
  13. package/build/codegen/naming.d.ts +2 -2
  14. package/build/codegen/naming.js +4 -7
  15. package/build/codegen/naming.js.map +1 -1
  16. package/build/core/create-http-stream.js +1 -0
  17. package/build/core/create-http-stream.js.map +1 -1
  18. package/build/core/create-http.js +1 -0
  19. package/build/core/create-http.js.map +1 -1
  20. package/build/core/create-stream.js +1 -0
  21. package/build/core/create-stream.js.map +1 -1
  22. package/build/core/create.js +1 -0
  23. package/build/core/create.js.map +1 -1
  24. package/build/core/types.d.ts +9 -0
  25. package/build/naming.d.ts +22 -0
  26. package/build/naming.js +39 -0
  27. package/build/naming.js.map +1 -0
  28. package/build/server/index.d.ts +1 -0
  29. package/build/server/index.js +1 -0
  30. package/build/server/index.js.map +1 -1
  31. package/build/server/scope-name-collision.d.ts +17 -0
  32. package/build/server/scope-name-collision.js +99 -0
  33. package/build/server/scope-name-collision.js.map +1 -0
  34. package/build/server/scope-name-collision.test.d.ts +1 -0
  35. package/build/server/scope-name-collision.test.js +134 -0
  36. package/build/server/scope-name-collision.test.js.map +1 -0
  37. package/docs/client-and-codegen.md +1 -1
  38. package/package.json +1 -1
  39. package/src/adapters/hono/index.test.ts +36 -0
  40. package/src/adapters/hono/index.ts +16 -0
  41. package/src/codegen/emit/route-shared.ts +3 -9
  42. package/src/codegen/group-routes.ts +5 -12
  43. package/src/codegen/naming.ts +4 -7
  44. package/src/core/create-http-stream.ts +1 -0
  45. package/src/core/create-http.ts +1 -0
  46. package/src/core/create-stream.ts +1 -0
  47. package/src/core/create.ts +1 -0
  48. package/src/core/types.ts +9 -0
  49. package/src/naming.ts +38 -0
  50. package/src/server/index.ts +1 -0
  51. package/src/server/scope-name-collision.test.ts +151 -0
  52. 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
+ }