brustjs 0.1.24-alpha → 0.1.25-alpha

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/runtime/treaty.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ActionsBuilder } from './define-actions.ts'
1
+ import type { ActionsBuilder, EndpointEntry } from './define-actions.ts'
2
2
 
3
3
  const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head'])
4
4
 
@@ -15,12 +15,6 @@ export interface ClientOptions {
15
15
  fetch?: typeof fetch
16
16
  }
17
17
 
18
- type FirstSegment<S extends string> = S extends `/${infer Rest}`
19
- ? FirstSegment<Rest>
20
- : S extends `${infer T}/${infer _U}`
21
- ? T
22
- : S
23
-
24
18
  export type PermissiveProxy = {
25
19
  (arg?: any): PermissiveProxy
26
20
  [key: string]: PermissiveProxy
@@ -33,10 +27,53 @@ export type PermissiveProxy = {
33
27
  head: (options?: any) => Promise<TreatyResponse<any, any>>
34
28
  }
35
29
 
30
+ // ─── Typed Treaty<App> (B2, descoped) ───────────────────────────────────────
31
+ // Static paths get fully-typed methods (output + discriminated error union);
32
+ // any param path (`{...}`) or unknown segment falls through to PermissiveProxy.
33
+ // Reference shape proven by spike5 (EXIT=0).
34
+
35
+ /** Per-method client signatures for one endpoint-entry map (the `{ GET, POST, … }`
36
+ * object for a single path). Bodyless methods (GET/HEAD) take only options. */
37
+ type Methods<E> = {
38
+ [M in keyof E & string as Lowercase<M>]: M extends 'GET' | 'HEAD'
39
+ ? E[M] extends EndpointEntry
40
+ ? (o?: {
41
+ query?: Record<string, string>
42
+ headers?: Record<string, string>
43
+ }) => Promise<TreatyResponse<E[M]['output'], E[M]['error']>>
44
+ : never
45
+ : E[M] extends EndpointEntry
46
+ ? (
47
+ b?: E[M]['input'],
48
+ o?: { headers?: Record<string, string> },
49
+ ) => Promise<TreatyResponse<E[M]['output'], E[M]['error']>>
50
+ : never
51
+ }
52
+
53
+ /** A path is static iff it contains no `{param}` segment. */
54
+ type IsStatic<K extends string> = K extends `${string}{${string}}${string}` ? false : true
55
+ /** Keep only the static path keys of the accumulator. */
56
+ type StaticAcc<Acc> = {
57
+ [K in keyof Acc as K extends string ? (IsStatic<K> extends true ? K : never) : never]: Acc[K]
58
+ }
59
+ /** Given a path key `K` and the accumulated prefix `P`, the next segment after `P`. */
60
+ type Tail<K extends string, P extends string> = K extends `${P}/${infer Rest}`
61
+ ? Rest extends `${infer H}/${string}`
62
+ ? H
63
+ : Rest
64
+ : never
65
+ /** All immediate child segments under prefix `P`. */
66
+ type ChildSegs<SA, P extends string> = { [K in keyof SA]: Tail<K & string, P> }[keyof SA]
67
+ /** The entry map for the exact path `P`, if one exists. */
68
+ // biome-ignore lint/complexity/noBannedTypes: `{}` is the empty-methods identity when no exact endpoint matches the prefix
69
+ type ExactEntry<SA, P extends string> = P extends keyof SA ? SA[P] : {}
70
+ /** A treaty node: methods of the exact path + child segment nodes + permissive fallback. */
71
+ type TNode<SA, P extends string> = Methods<ExactEntry<SA, P>> & {
72
+ [Seg in ChildSegs<SA, P> & string]: TNode<SA, `${P}/${Seg}`>
73
+ } & PermissiveProxy
74
+
36
75
  export type Treaty<App> =
37
- App extends ActionsBuilder<infer Acc>
38
- ? { [K in FirstSegment<keyof Acc & string>]: PermissiveProxy } & PermissiveProxy
39
- : PermissiveProxy
76
+ App extends ActionsBuilder<infer Acc> ? TNode<StaticAcc<Acc>, ''> : PermissiveProxy
40
77
 
41
78
  function resolvePrefix(opts?: ClientOptions): string {
42
79
  if (opts?.prefix) return opts.prefix
@@ -0,0 +1,69 @@
1
+ // Isolated TYPE TEST for the B2 typed treaty client. Not executed — every
2
+ // assertion is a pure type check inside an un-called async function. Run via:
3
+ // bun run typecheck:treaty → tsc -p tsconfig.typecheck.json --noEmit
4
+ //
5
+ // It exercises: (1) static-path methods are fully typed (output + error union),
6
+ // (2) the discriminated error union narrows on `code`, (3) accessing a
7
+ // non-existent output field is a type error, (4) a wrong error code / field is a
8
+ // type error, (5) param paths stay permissive (no type error, the fallback).
9
+ import { z } from 'zod'
10
+ import { defineActions } from './define-actions.ts'
11
+ import { client } from './treaty.ts'
12
+
13
+ const actions = defineActions()
14
+ .get('/team', () => ({ team: [{ id: 1 }], max: 6 }))
15
+ .post('/team', ({ body }: { body: { id: number } }) => ({ team: [{ id: body.id }], max: 6 }), {
16
+ body: z.object({ id: z.number() }),
17
+ errors: {
18
+ TEAM_FULL: z.object({ max: z.number() }),
19
+ DUPLICATE: z.object({ existingId: z.number() }),
20
+ },
21
+ })
22
+ .delete('/team/{id}', () => ({ ok: true }))
23
+
24
+ const api = client<typeof actions>()
25
+
26
+ // Never called — purely for type checking.
27
+ export async function _typeTest() {
28
+ // (1) static POST → output typed.
29
+ const r = await api.team.post({ id: 5 })
30
+ if (r.data) {
31
+ const max: number = r.data.max
32
+ void max
33
+ // (3) non-existent output field → must be a type error.
34
+ // @ts-expect-error `nope` is not a field of the typed output
35
+ void r.data.nope
36
+ }
37
+
38
+ // (2) error union narrows on the discriminant `code` to the SPECIFIC branch,
39
+ // and that branch's `data` is typed (multi-member union: TEAM_FULL | DUPLICATE).
40
+ if (r.error && r.error.value.code === 'TEAM_FULL') {
41
+ const m: number = r.error.value.data.max
42
+ void m
43
+ // (4a) non-existent error-data field → must be a type error.
44
+ // @ts-expect-error `min` is not a field of the TEAM_FULL error data
45
+ void r.error.value.data.min
46
+ // (4c) narrowing is per-branch: DUPLICATE's `existingId` is NOT on TEAM_FULL.
47
+ // @ts-expect-error `existingId` belongs to the DUPLICATE branch, not TEAM_FULL
48
+ void r.error.value.data.existingId
49
+ }
50
+ if (r.error && r.error.value.code === 'DUPLICATE') {
51
+ const e: number = r.error.value.data.existingId
52
+ void e
53
+ }
54
+ // (4b) comparing against an undeclared error code → must be a type error.
55
+ if (r.error) {
56
+ // @ts-expect-error 'NOPE' is not a declared error code
57
+ void (r.error.value.code === 'NOPE')
58
+ }
59
+
60
+ // GET → output typed.
61
+ const g = await api.team.get()
62
+ if (g.data) {
63
+ const max2: number = g.data.max
64
+ void max2
65
+ }
66
+
67
+ // (5) param path stays permissive — MUST NOT error (locks the fallback).
68
+ await api.team({ id: '5' }).delete()
69
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "skipLibCheck": true,
5
+ "target": "ES2022",
6
+ "lib": ["ES2022", "DOM"],
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "noEmit": true,
11
+ "jsx": "react-jsx",
12
+ "types": []
13
+ },
14
+ "files": ["treaty.ts", "define-actions.ts", "standard-schema.ts", "treaty.type-test.ts"]
15
+ }