|
21 | 21 |
|
22 | 22 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
23 | 23 | import type { RealtimeEventPayload } from '@objectstack/spec/contracts'; |
| 24 | +import type { MetadataEvent, MetadataEventSubject } from '@objectstack/spec/api'; |
24 | 25 | import { RealtimeAPI } from './realtime-api'; |
25 | 26 |
|
26 | 27 | const VALID_EVENT = { |
@@ -138,3 +139,98 @@ describe('#4602 — RealtimeAPI.subscribeMetadata contract boundary', () => { |
138 | 139 | expect(callback).toHaveBeenCalledTimes(1); |
139 | 140 | }); |
140 | 141 | }); |
| 142 | + |
| 143 | +// =========================================================================== |
| 144 | +// #4627 — the `type` parameter is the closed MetadataEventSubject, not string |
| 145 | +// =========================================================================== |
| 146 | +// |
| 147 | +// Every pin below is resolved by tsc, not by vitest. `packages/client`'s |
| 148 | +// `typecheck` script compiles this file through `tsconfig.test.json`, and |
| 149 | +// `test-typecheck-debt.json` carries NO entry for `src/realtime-api.test.ts` — |
| 150 | +// which, under that ledger's "a file not listed here may have no errors at all" |
| 151 | +// rule, makes zero the measurable baseline these pins move away from. Reverting |
| 152 | +// the parameter to `string` leaves the `@ts-expect-error` directives unused, |
| 153 | +// and an unused directive is itself an error (TS2578), so the revert is red. |
| 154 | +// |
| 155 | +// Reverse verification, direction declared before running it: `type: string` |
| 156 | +// restored → the two `@ts-expect-error` lines below go red as TS2578 "Unused |
| 157 | +// '@ts-expect-error' directive", and `ParameterIsExactlySubject` resolves to |
| 158 | +// `never` so its initializer goes red as TS2322. Both were measured and both |
| 159 | +// landed. A fourth red was NOT predicted and is recorded here rather than |
| 160 | +// tidied away: `realtime-api.ts`'s own `const eventTypes: MetadataEventType[]` |
| 161 | +// goes red too (three TS2322, `` `metadata.${string}.created` `` not assignable |
| 162 | +// to the enum), because a widened `type` makes the composed names unprovable. |
| 163 | +// The implementation carries a pin of its own, one level below the signature. |
| 164 | +// |
| 165 | +// The `@ts-expect-error` directives are deliberately NOT the only pin. A bare |
| 166 | +// directive passes on ANY error at that line — the phantom-check hazard — so |
| 167 | +// each is corroborated by a type-level assertion that fixes exactly WHICH |
| 168 | +// error it can be: the parameter type is pinned to `MetadataEventSubject` |
| 169 | +// exactly, and the positive cases below prove the callback/options arms of the |
| 170 | +// same signature still compile. What is left for the directive to catch can |
| 171 | +// then only be argument one (TS2345 — the code recorded in each comment, |
| 172 | +// measured by deleting the directive and reading tsc's output). |
| 173 | + |
| 174 | +describe('#4627 — subscribeMetadata narrows `type` to the event vocabulary', () => { |
| 175 | + const api = new RealtimeAPI('http://localhost:3000'); |
| 176 | + const callback = (_event: MetadataEvent): void => undefined; |
| 177 | + |
| 178 | + it('declares the parameter as exactly MetadataEventSubject', () => { |
| 179 | + // Read off the METHOD, not off the alias: a revert that widened the |
| 180 | + // signature back to `string` while leaving `MetadataEventSubject` exported |
| 181 | + // would sail past any alias-scoped assertion. |
| 182 | + // |
| 183 | + // Both directions are asserted, and each catches a different regression: |
| 184 | + // - `Param extends MetadataEventSubject` fails on a re-widening |
| 185 | + // (`string extends MetadataEventSubject` is false); |
| 186 | + // - `MetadataEventSubject extends Param` fails on an over-narrowing to a |
| 187 | + // subset (`… extends 'object'` is false). |
| 188 | + // Together they are exactness; either alone is not. |
| 189 | + type Param = Parameters<RealtimeAPI['subscribeMetadata']>[0]; |
| 190 | + type ParameterIsExactlySubject = |
| 191 | + Param extends MetadataEventSubject |
| 192 | + ? MetadataEventSubject extends Param |
| 193 | + ? 'exact' |
| 194 | + : never |
| 195 | + : never; |
| 196 | + const exact: ParameterIsExactlySubject = 'exact'; |
| 197 | + expect(exact).toBe('exact'); |
| 198 | + }); |
| 199 | + |
| 200 | + it('accepts every member of the vocabulary, not just the one this file uses', () => { |
| 201 | + // The other 12 subjects compile too — this is a union, not `'object'`. |
| 202 | + const offs = [ |
| 203 | + api.subscribeMetadata('field', callback), |
| 204 | + api.subscribeMetadata('view', callback), |
| 205 | + api.subscribeMetadata('permission', callback, { packageId: 'com.acme' }), |
| 206 | + ]; |
| 207 | + expect(offs).toHaveLength(3); |
| 208 | + for (const off of offs) off(); |
| 209 | + api.disconnect(); |
| 210 | + }); |
| 211 | + |
| 212 | + it('rejects a metadata type that has no realtime event contract', () => { |
| 213 | + // `translation` IS registrable (`DEFAULT_METADATA_TYPE_REGISTRY`) but has |
| 214 | + // no `metadata.translation.*` name in `MetadataEventType`, so #4602's |
| 215 | + // producer publishes nothing for it. Before #4627 this line compiled and |
| 216 | + // the callback waited forever. |
| 217 | + // @ts-expect-error TS2345 - '"translation"' is not assignable to parameter of type 'MetadataEventSubject' |
| 218 | + const off = api.subscribeMetadata('translation', callback); |
| 219 | + expect(off).toBeTypeOf('function'); |
| 220 | + off(); |
| 221 | + api.disconnect(); |
| 222 | + }); |
| 223 | + |
| 224 | + it('rejects a plain `string` variable — the one real migration break', () => { |
| 225 | + // This is what a 17.x caller has to change: a `string`-typed variable no |
| 226 | + // longer flows in. The fix is to type the variable (or the state, or the |
| 227 | + // route param) as `MetadataEventSubject`; there is no runtime change to |
| 228 | + // accompany it, because the runtime already ignored these subscriptions. |
| 229 | + const fromConfig: string = 'object'; |
| 230 | + // @ts-expect-error TS2345 - 'string' is not assignable to parameter of type 'MetadataEventSubject' |
| 231 | + const off = api.subscribeMetadata(fromConfig, callback); |
| 232 | + expect(off).toBeTypeOf('function'); |
| 233 | + off(); |
| 234 | + api.disconnect(); |
| 235 | + }); |
| 236 | +}); |
0 commit comments