@groovenet/contracts 0.1.0 → 1.0.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/README.md CHANGED
@@ -1,8 +1,13 @@
1
1
  # @groovenet/contracts
2
2
 
3
- Shared runtime schemas and TypeScript types for Groovenet API resources. The
4
- package starts with live sets and is the source of truth for their wire enums,
5
- entities, and update payloads.
3
+ Shared runtime schemas and TypeScript types for Groovenet API resources. It is
4
+ the source of truth for the wire enums, entities, and request/response payloads
5
+ of live sets (`liveSets.ts`), playlists (`playlists.ts`), tracks (`tracks.ts`),
6
+ albums (`albums.ts`), friends with the default-library setting (`friends.ts`)
7
+ and physical-copy record care (`recordCare.ts`).
8
+
9
+ [`INVENTORY.md`](INVENTORY.md) lists every published API operation with its
10
+ family and migration status, and the order the remaining families follow.
6
11
 
7
12
  ## Adding or migrating a contract
8
13
 
@@ -17,7 +22,119 @@ entities, and update payloads.
17
22
  components from the route contract. Add an OpenAPI test for the resource's
18
23
  shape and enum values.
19
24
 
20
- Migrate one resource family at a time: live sets, playlists, tracks/albums,
21
- then the remaining API resources. Preserve permissive response fields where
25
+ Migrate one resource family at a time: live sets, playlists, tracks, albums,
26
+ friends, record care and spins are done; `INVENTORY.md` orders the rest. Preserve permissive response fields where
22
27
  the database returns additional wire fields; make request schemas strict only
23
28
  when the endpoint already rejects unknown fields.
29
+
30
+ ## Pattern for endpoints with route-specific coercion
31
+
32
+ Playlist endpoints showed three cases the live-set migration did not need:
33
+
34
+ - **Text-coerced ids stay in the app.** Path and query ids arrive as strings, so
35
+ the app keeps `intFromInputSchema` for `params`/`query` schemas. The shared
36
+ schema declares the wire type (`id: z.number().int()`); the app derives its
37
+ route schema with `.extend({ id: intFromInputSchema })`. Do not move
38
+ `z.preprocess` into this package: it has no JSON Schema form.
39
+ - **Schemas with defaults or refinements publish their input shape.** Pass
40
+ `{ io: "input" }` to `z.toJSONSchema` for those (`PlaylistCreate`,
41
+ `PlaylistSpinsInput`) so OpenAPI documents what a client sends, not what the
42
+ parser produces. Refinements such as "not both `performed_at` and
43
+ `performance_id`" run at runtime but are not expressible in JSON Schema;
44
+ describe them in the route description.
45
+ - **Keep the app's schema names as aliases.** `api-contract/schemas.ts` re-exports
46
+ shared schemas under the names routes and `internalApi/*` already import, so a
47
+ migration changes where a schema is defined without touching every caller.
48
+
49
+ Name wire types after the resource, not the endpoint (`Playlist`,
50
+ `PlaylistDetail`, `PlaylistSpinsResult`), and reference them from
51
+ `openapi.ts` via the exported `*JsonSchemas` object. Cover new shapes with a
52
+ test in this package and an OpenAPI `$ref` assertion in
53
+ `my-collection-search/src/api-contract/__tests__/openapi.test.ts`.
54
+
55
+ ## Pattern for large entities (tracks and albums)
56
+
57
+ Track and album rows carry dozens of database columns, which raised conventions
58
+ the smaller resources did not need:
59
+
60
+ - **Export the entity type from a non-passthrough shape.** `.passthrough()` adds
61
+ an index signature (`[x: string]: unknown`) that collapses `Omit`/`Pick` and
62
+ makes every key `unknown`. Declare `const trackShape = z.object({...})`, export
63
+ `trackSchema = trackShape.passthrough()` for parsing and OpenAPI, and infer
64
+ `TrackEntity` from `trackShape`.
65
+ - **The wire schema is honest about nulls; consumers narrow.** `Track` and
66
+ `Album` allow `null` on columns the database can return null for. The app's
67
+ `types/track.ts` narrows the handful of fields its UI never sees null
68
+ (`discogs_url`, `album_thumbnail`, ...) with `Omit<> & {...}` instead of
69
+ loosening the contract.
70
+ - **Entities in list/search responses stay opaque at runtime.** The shared
71
+ `TrackSearchResponse` documents `hits` as `Track`, but the search route's
72
+ runtime schema overrides `hits` to `z.array(z.unknown())` because raw SQL rows
73
+ have never been rejected. Do not parse whole entities in a route unless a
74
+ real-database test shows they conform (timestamps and numerics arrive in
75
+ driver-specific types).
76
+ - **Query schemas stay in the app.** `trackSearchQuerySchema` and
77
+ `albumSearchQuerySchema` here describe the coerced wire values; the app's
78
+ `*GetQuerySchema` keeps `intFromInputSchema`/`booleanFlagSchema` coercion.
79
+ The client's `trackSearchGetQuerySchema` is still a coercing copy (deferred).
80
+ - **Add response validation only to flat payloads.** Routes that return a small
81
+ fixed object (`essentia`, `playlists`, `DELETE`, extract-cover) parse their
82
+ response with the shared schema; lenient request handling (e.g.
83
+ `playlist_counts` silently dropping malformed refs) is preserved.
84
+
85
+ ## Pattern for the remaining families (#552)
86
+
87
+ Friends and the default-library setting were the first of #552's slices, and
88
+ fixed the conventions the rest follow:
89
+
90
+ - **Update `INVENTORY.md` in the same PR.** `contractInventory.test.ts` in the
91
+ app fails when an OpenAPI operation has no row, a row names an operation that
92
+ no longer exists, an `exception` has no reason, or a row marked `contracts`
93
+ references no shared component. Add the family's `*JsonSchemas` to that test's
94
+ `SHARED_COMPONENTS` when you register them in `openapi.ts`.
95
+ - **Request bodies publish their input shape.** A plain `z.object` strips
96
+ unknown keys at runtime, but its default (output) JSON Schema says
97
+ `additionalProperties: false`, which tells clients the server rejects them.
98
+ Publish request bodies with `{ io: "input" }` unless the route really rejects
99
+ unknown fields (`.strict()`).
100
+ - **Keep the route's own coercion when the app helper would change it.**
101
+ `intFromInputSchema` uses `parseInt`, so it accepts `"3abc"`; the
102
+ default-library `PUT` has always coerced with `Number()` and rejected that.
103
+ The route keeps `Number()` and validates the result with the shared wire
104
+ schema, so only already-broken input changes (`2.5` is now a 400, where it
105
+ used to reach Postgres and come back a 500).
106
+ - **Reference components with `componentRef(name)`** in `routes.ts`.
107
+ `openapi.ts` inlines `$ref`s before generating response examples, so a
108
+ migrated response keeps a realistic example instead of the literal
109
+ `"example"`, which every `$ref`'d response produced until this fix.
110
+ - **Streams and binaries are exceptions, not gaps.** An operation whose body is
111
+ not JSON (the friend-removal progress stream, audio and dump downloads, SSE)
112
+ stays `exception` in the inventory with its reason, even when its query is
113
+ validated with a shared schema.
114
+ - **Refined request bodies export their object and their refinement.** Zod
115
+ will not `.extend()` an object that carries a `.refine`/`.superRefine`, and
116
+ the app must swap ids for its coercing versions. Export the plain
117
+ `*ObjectSchema`, the refinement (`requireLabelOrNotes`,
118
+ `refineRecordActionCreate`) and the refined wire schema; the app rebuilds
119
+ `object.extend({...coercion}).superRefine(refinement)`. Where the refinement
120
+ enforces "exactly one of", export a TypeScript union for callers
121
+ (`RecordActionCreate`) built from the inferred base type.
122
+ - **Documentation travels with the schema.** Put OpenAPI descriptions,
123
+ examples and `format: "date-time"` on the field with `.meta({...})`, which
124
+ `z.toJSONSchema` copies through, instead of keeping them in a hand-written
125
+ schema object in `routes.ts`. Use `format` rather than `z.iso.datetime()` on
126
+ responses, so the parser stays as lenient as it was.
127
+ - **Enum tuples come from the schema.** Export `SLEEVE_TYPES =
128
+ sleeveTypeSchema.options` and have app modules (`lib/recordCare.ts`)
129
+ re-export it, so UI, SQL helpers and analytics share one list. Database row
130
+ types that differ only in timestamp types derive from the wire type with
131
+ `Omit<> & {...}` rather than repeating every column.
132
+ - **The client's own Zod copy is replaced, not kept.** `groovenet-client`
133
+ exports Zod schemas from `schemas.ts` as well as types. When a family has
134
+ both, re-export the shared shapes there and rebuild only the coercing
135
+ bodies and queries with the same `.extend()` + exported refinement the app
136
+ uses (spins). A hand-copied enum can lag the database: the client's spin
137
+ schema had no `playlist` selection mode or provenance until it was replaced.
138
+ - **Wire queries omit defaults.** Shared query schemas describe what a client
139
+ may send (`limit?: number`); the app adds `.default(50)` after coercion and
140
+ the OpenAPI `parameters` list documents the default.