Repository navigation
Expand file tree
/
Copy pathprotocol.test.ts
More file actions
3022 lines (2740 loc) · 141 KB
/
Copy pathprotocol.test.ts
File metadata and controls
3022 lines (2740 loc) · 141 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { describe, it, expect } from 'vitest';
import {
GetDataRequestSchema,
GetDataResponseSchema,
FindDataRequestSchema,
FindDataResponseSchema,
HttpFindQueryParamsSchema,
CreateDataRequestSchema,
CreateDataResponseSchema,
UpdateDataRequestSchema,
DeleteDataResponseSchema,
BatchDataRequestSchema,
CreateManyDataResponseSchema,
UpdateManyDataRequestSchema,
DeleteManyDataRequestSchema,
// View-management schemas removed with the retired ViewProtocol (commit f549a0d4a, v17)
// Permissions
CheckPermissionRequestSchema,
CheckPermissionResponseSchema,
GetObjectPermissionsRequestSchema,
GetObjectPermissionsResponseSchema,
GetEffectivePermissionsResponseSchema,
// Workflow schemas removed with the retired slot (#4451, v17)
// Realtime
RealtimeConnectRequestSchema,
RealtimeConnectResponseSchema,
RealtimeSubscribeRequestSchema,
RealtimeSubscribeResponseSchema,
SetPresenceRequestSchema,
GetPresenceResponseSchema,
// Notifications
RegisterDeviceRequestSchema,
RegisterDeviceResponseSchema,
NotificationPreferencesSchema,
NotificationSchema,
ListNotificationsRequestSchema,
ListNotificationsResponseSchema,
MarkNotificationsReadRequestSchema,
// AI
AiChatRequestSchema,
AiAgentsResponseSchema,
AiAgentChatRequestSchema,
ListAiPendingActionsRequestSchema,
ListAiPendingActionsResponseSchema,
ApproveAiPendingActionResponseSchema,
RejectAiPendingActionResponseSchema,
AiChatResponseSchema,
AiCompleteRequestSchema,
AiModelsResponseSchema,
CreateAiConversationRequestSchema,
ListAiConversationsRequestSchema,
ListAiConversationsResponseSchema,
UpdateAiConversationRequestSchema,
// i18n
GetLocalesResponseSchema,
GetTranslationsRequestSchema,
GetTranslationsResponseSchema,
GetFieldLabelsRequestSchema,
GetFieldLabelsResponseSchema,
} from './protocol.zod';
import type { ListNotificationsRequest } from './protocol.zod';
import { expectTypeOf } from 'vitest';
import type {
MetadataProtocol,
GetMetaItemLayeredRequest,
GetMetaItemLayeredResponse,
PublishMetaItemRequest,
PublishMetaItemResponse,
} from './protocol.zod';
describe('ObjectStack Protocol', () => {
it('validates GetData', () => {
const request = {
object: 'project',
id: 'p1'
};
expect(GetDataRequestSchema.safeParse(request).success).toBe(true);
const response = {
object: 'project',
id: 'p1',
record: { id: 'p1', name: 'Project A' }
};
expect(GetDataResponseSchema.safeParse(response).success).toBe(true);
});
it('validates FindData', () => {
const request = {
object: 'project',
query: {
object: 'project',
where: { status: 'active' }
}
};
expect(FindDataRequestSchema.safeParse(request).success).toBe(true);
const response = {
object: 'project',
records: [
{ id: 'p1', name: 'Project A', status: 'active' }
],
total: 1
};
expect(FindDataResponseSchema.safeParse(response).success).toBe(true);
});
it('validates CRUD Operations', () => {
const createReq = {
object: 'task',
data: { title: 'New Task' }
};
expect(CreateDataRequestSchema.safeParse(createReq).success).toBe(true);
const createRes = {
object: 'task',
id: 't1',
record: { id: 't1', title: 'New Task' }
};
expect(CreateDataResponseSchema.safeParse(createRes).success).toBe(true);
const updateReq = {
object: 'task',
id: 't1',
data: { status: 'completed' }
};
expect(UpdateDataRequestSchema.safeParse(updateReq).success).toBe(true);
const deleteRes = {
object: 'task',
id: 't1',
success: true
};
expect(DeleteDataResponseSchema.safeParse(deleteRes).success).toBe(true);
});
it('validates Batch Operations', () => {
const batchReq = {
object: 'task',
request: {
operation: 'create',
records: [{ data: { title: 'T1' } }]
}
};
expect(BatchDataRequestSchema.safeParse(batchReq).success).toBe(true);
});
it('validates Bulk Operations', () => {
const createManyRes = {
object: 'task',
records: [{ id: 't1' }, { id: 't2' }],
count: 2
};
expect(CreateManyDataResponseSchema.safeParse(createManyRes).success).toBe(true);
const updateManyReq = {
object: 'task',
records: [{ id: 't1', data: { status: 'done' } }],
options: { atomic: true }
};
expect(UpdateManyDataRequestSchema.safeParse(updateManyReq).success).toBe(true);
const deleteManyReq = {
object: 'task',
ids: ['t1', 't2'],
options: { atomic: false }
};
expect(DeleteManyDataRequestSchema.safeParse(deleteManyReq).success).toBe(true);
});
it('no longer publishes a viewId-addressed view CRUD surface', async () => {
// Retired at protocol 17: five methods, ten schemas, zero implementations
// and zero routes — and already mis-read once as the contract of
// `GET /ui/view/:object/:type` (#5948). The unit test that stood here
// asserted the ten shapes parse; the shapes are what was removed, so it is
// replaced wholesale rather than re-spelled (the retirement playbook's
// third fixture disposition — an assertion that keeps passing because
// nothing is produced is not coverage).
//
// Reverse verification, direction predicted first: these are
// `false`-expecting existence checks over a runtime namespace, so restoring
// any removed limb turns exactly this test red. Plain red, not one of the
// inverted directions — nothing downstream counts these names.
const protocol = await import('./protocol.zod');
for (const name of [
'ListViewsRequestSchema', 'ListViewsResponseSchema',
'GetViewRequestSchema', 'GetViewResponseSchema',
'CreateViewRequestSchema', 'CreateViewResponseSchema',
'UpdateViewRequestSchema', 'UpdateViewResponseSchema',
'DeleteViewRequestSchema', 'DeleteViewResponseSchema',
]) {
expect(name in protocol, `${name} must not be exported`).toBe(false);
}
});
it('keeps the two view surfaces that ARE routed', async () => {
// The point of the removal, asserted positively so a later reader cannot
// mistake it for "views left the protocol". `getUiView` serves the resolved
// render-time view; the stored definition travels on the generic metadata
// methods with `type: 'view'`.
const protocol = await import('./protocol.zod');
expect('GetUiViewRequestSchema' in protocol).toBe(true);
expect('GetUiViewResponseSchema' in protocol).toBe(true);
expect('GetMetaItemRequestSchema' in protocol).toBe(true);
expect('SaveMetaItemRequestSchema' in protocol).toBe(true);
});
it('validates Permissions operations', () => {
expect(CheckPermissionRequestSchema.safeParse({
object: 'account',
action: 'edit',
recordId: 'a1',
}).success).toBe(true);
expect(CheckPermissionResponseSchema.safeParse({ allowed: false, reason: 'Insufficient privileges' }).success).toBe(true);
expect(GetObjectPermissionsRequestSchema.safeParse({ object: 'account' }).success).toBe(true);
expect(GetObjectPermissionsResponseSchema.safeParse({
object: 'account',
permissions: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false },
fieldPermissions: { email: { readable: true, editable: false } },
}).success).toBe(true);
expect(GetEffectivePermissionsResponseSchema.safeParse({
objects: { account: { allowRead: true } },
systemPermissions: ['manage_users', 'view_reports'],
}).success).toBe(true);
// #3391: the effective response carries per-object apiOperations.
const withOps = GetEffectivePermissionsResponseSchema.safeParse({
objects: { account: { allowRead: true, apiOperations: ['get', 'list', 'export'] } },
systemPermissions: [],
});
expect(withOps.success).toBe(true);
if (withOps.success) {
expect((withOps.data.objects.account as any).apiOperations).toEqual(['get', 'list', 'export']);
}
});
// 'validates Workflow operations' removed (#4451, v17): the workflow
// schemas were deleted with the retired slot — nothing ever implemented
// the protocol they described.
it('validates Realtime operations', () => {
expect(RealtimeConnectRequestSchema.safeParse({
transport: 'websocket', channels: ['project.updates'], token: 'tok_abc',
}).success).toBe(true);
expect(RealtimeConnectResponseSchema.safeParse({
connectionId: 'conn_1', transport: 'websocket', url: 'wss://rt.example.com',
}).success).toBe(true);
expect(RealtimeSubscribeRequestSchema.safeParse({
channel: 'project.updates', events: ['record.created', 'record.updated'],
}).success).toBe(true);
expect(RealtimeSubscribeResponseSchema.safeParse({
subscriptionId: 'sub_1', channel: 'project.updates',
}).success).toBe(true);
expect(SetPresenceRequestSchema.safeParse({
channel: 'project.updates',
state: { userId: 'u1', status: 'online', lastSeen: '2024-01-15T10:00:00Z' },
}).success).toBe(true);
expect(GetPresenceResponseSchema.safeParse({
channel: 'project.updates',
members: [{ userId: 'u1', status: 'online', lastSeen: '2024-01-15T10:00:00Z' }],
}).success).toBe(true);
});
it('validates Notification operations', () => {
expect(RegisterDeviceRequestSchema.safeParse({
token: 'fcm_token_xyz', platform: 'android', deviceId: 'dev_1', name: 'Pixel 8',
}).success).toBe(true);
expect(RegisterDeviceResponseSchema.safeParse({ deviceId: 'dev_1', success: true }).success).toBe(true);
expect(NotificationPreferencesSchema.safeParse({
email: true, push: true, inApp: true, digest: 'daily',
channels: { alerts: { enabled: true, push: false } },
}).success).toBe(true);
expect(NotificationSchema.safeParse({
id: 'n1', type: 'task_assigned', title: 'New Task', body: 'You were assigned a task',
read: false, actionUrl: '/tasks/t1', createdAt: '2024-01-15T10:00:00Z',
}).success).toBe(true);
expect(ListNotificationsRequestSchema.safeParse({ read: false, limit: 10 }).success).toBe(true);
expect(ListNotificationsResponseSchema.safeParse({
notifications: [{ id: 'n1', type: 'info', title: 'Hi', body: 'Hello', read: false, createdAt: '2024-01-15T10:00:00Z' }],
unreadCount: 1,
}).success).toBe(true);
expect(MarkNotificationsReadRequestSchema.safeParse({ ids: ['n1', 'n2'] }).success).toBe(true);
});
/**
* [commit 90bbf2510] `GET /api/v1/notifications` declares no pagination — on either half.
*
* Maintainer ruling 2026-08-07 (Option A), ruled jointly with the `unreadCount` fix (commit 17d095413): one
* capability's two halves are never half-deleted. `cursor` was declared on the
* request AND the response and honoured on neither, and `limit` declared a
* `.default(20)` no request path has ever applied (the server windows at 50).
*
* Asserted on the SHAPES rather than only through `safeParse`, and that choice
* is the whole point: both retired keys were `optional`, so every parse was
* green before the removal and every parse is green after it. A value-level
* test cannot see this class of defect at all — which is exactly why the
* family's double-assertion ratchet (#3877 Stage D) was blind to it.
*/
it('tombstones `cursor` on BOTH halves, with the prescription', () => {
// BOTH halves or neither — the ruling's "one capability, two halves" clause,
// asserted rather than trusted. A half-deletion is the specific outcome the
// maintainer ruled out, so it gets a test rather than a comment.
for (const [half, schema] of [
['request', ListNotificationsRequestSchema],
['response', ListNotificationsResponseSchema],
] as const) {
const probe = half === 'request'
? { read: false, limit: 10, cursor: 'n_42' }
: { notifications: [], unreadCount: 0, cursor: 'n_42' };
const result = schema.safeParse(probe);
// Refused, not stripped. Neither schema is `.strict()`, so a BARE DELETION
// would have parsed this cleanly and dropped the key — the silent-strip
// class (#3733, ADR-0104), i.e. the very defect this issue reports,
// re-created one layer down. That is why the assertion is on the refusal.
expect(result.success, `${half} half must refuse a retired \`cursor\``).toBe(false);
const issue = result.error!.issues.find((i) => i.path.join('.') === 'cursor');
expect(issue, `${half} half must fault on the \`cursor\` path`).toBeDefined();
// The message IS the migration doc (retiredKey's contract), so its
// substance is asserted, not merely its existence: what was removed, that
// the route is not paginated, and the replacement to reach for.
expect(issue!.message).toMatch(/`cursor` was removed from GET \/api\/v1\/notifications/);
expect(issue!.message).toMatch(/not paginated/i);
expect(issue!.message).toMatch(/limit/);
expect(issue!.message).toMatch(/declared on the request AND the response/);
}
// `tsc` is the first channel retiredKey buys — the input type is `never`.
// Pinned as a compile-time fact so deleting the tombstone cannot pass as a
// refactor. @ts-expect-error fails the build if the key becomes writable.
// @ts-expect-error `cursor` is retired: its input type is `never`.
const rejectedByTsc: ListNotificationsRequest = { cursor: 'n_42' };
void rejectedByTsc;
// The surviving keys, named. `limit` is plainly optional now — parsing an
// empty query stamps NO number onto it, where it used to stamp 20 that no
// request path had ever applied. Stated through the parse result so it holds
// if Zod's internal representation of a default ever moves.
expect(Object.keys((ListNotificationsRequestSchema as any).shape)).toEqual(['read', 'type', 'limit', 'cursor']);
const parsedEmpty = ListNotificationsRequestSchema.parse({});
expect(Object.prototype.hasOwnProperty.call(parsedEmpty, 'limit')).toBe(false);
expect(ListNotificationsRequestSchema.parse({ limit: 7 }).limit).toBe(7);
// The response half keeps exactly what commit 17d095413 landed (`unreadCount`), and gains nothing.
expect(ListNotificationsResponseSchema.safeParse({ notifications: [], unreadCount: 0 }).success).toBe(true);
});
/**
* These replace the `AiNlq*` / `AiSuggest*` / `AiInsights*` cases (#3718).
* Those parsed cleanly for years against endpoints no repo has ever mounted
* — a schema is a shape, never evidence that anything serves it. What is
* asserted here is the shape of the routes `service-ai` really mounts, which
* `cloud`'s ledger checks against `buildAIRoutes()` itself.
*/
it('validates AI operations', () => {
// chat — Vercel `useChat` flat form, and the JSON (stream:false) reply
expect(AiChatRequestSchema.safeParse({
messages: [{ role: 'user', content: 'how many open orders?' }],
system: 'You are a helpful assistant', model: 'gpt-4o-mini', stream: false,
}).success).toBe(true);
// v6 `parts` messages carry no `content` at all — the routes accept them
expect(AiChatRequestSchema.safeParse({
messages: [{ role: 'assistant', parts: [{ type: 'text', text: 'hi' }] }],
}).success).toBe(true);
expect(AiChatRequestSchema.safeParse({ messages: [] }).success, 'the routes 400 an empty message list').toBe(false);
expect(AiChatResponseSchema.safeParse({
content: '42 open orders', model: 'gpt-4o-mini',
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, conversationId: 'conv_1',
}).success).toBe(true);
// complete
expect(AiCompleteRequestSchema.safeParse({ prompt: 'Summarise:', options: { maxTokens: 64 } }).success).toBe(true);
expect(AiCompleteRequestSchema.safeParse({}).success).toBe(false);
// models — both live shapes: the cloud ADR-0028 allowlist and the bare-id fallback
expect(AiModelsResponseSchema.safeParse({
models: [{ id: 'gpt-4o-mini', label: 'GPT-4o mini', default: true }], defaultModel: 'gpt-4o-mini',
}).success).toBe(true);
expect(AiModelsResponseSchema.safeParse({ models: ['gpt-4o-mini'] }).success).toBe(true);
// conversations
expect(CreateAiConversationRequestSchema.safeParse({ title: 'Q3 pipeline', metadata: { source: 'sdk' } }).success).toBe(true);
expect(ListAiConversationsResponseSchema.safeParse({
conversations: [{
id: 'conv_1', messages: [{ role: 'user', content: 'hi' }],
createdAt: '2026-07-27T10:00:00Z', updatedAt: '2026-07-27T10:00:00Z',
}],
hasMore: false,
}).success).toBe(true);
expect(UpdateAiConversationRequestSchema.safeParse({ title: 'Renamed' }).success).toBe(true);
expect(
UpdateAiConversationRequestSchema.safeParse({}).success,
'PATCH with neither title nor metadata is a 400 on the wire',
).toBe(false);
// agents — the named-agent surface `objectui` hand-built URLs for (#3718)
expect(AiAgentsResponseSchema.safeParse({
agents: [{
name: 'build', label: 'Builder', role: 'authoring',
capabilities: { authoring: true, canvas: true, debug: true, resume: true },
}],
}).success).toBe(true);
expect(
AiAgentsResponseSchema.safeParse({ agents: [] }).success,
'an access-filtered catalog is legitimately empty for a seat-less caller',
).toBe(true);
expect(
AiAgentsResponseSchema.safeParse({ agents: [{ name: 'build', label: 'Builder', role: 'authoring' }] }).success,
'capabilities drive UI affordances — a row without them is not a row this route returns',
).toBe(false);
expect(AiAgentChatRequestSchema.safeParse({
messages: [{ role: 'user', content: 'add a status field' }],
context: { appId: 'crm', objectName: 'account' },
}).success).toBe(true);
expect(
AiAgentChatRequestSchema.safeParse({ messages: [] }).success,
'the agent chat route 400s an empty message list, same as /ai/chat',
).toBe(false);
// pending actions — the HITL queue
expect(ListAiPendingActionsResponseSchema.safeParse({
items: [{
id: 'pa_1', object_name: 'account', action_name: 'update', tool_name: 'record_update',
tool_input: '{"id":"a_1"}', status: 'pending', proposed_at: '2026-07-27T10:00:00Z',
}],
total: 1,
}).success).toBe(true);
expect(
ListAiPendingActionsRequestSchema.safeParse({ status: 'archived' }).success,
'status is the persisted lifecycle enum, not free text',
).toBe(false);
expect(ListAiPendingActionsRequestSchema.safeParse({ status: 'pending', limit: 20 }).success).toBe(true);
// Approval and execution are separate outcomes on one 200 response: a tool
// that fails after approval is `status: 'failed'`, NOT an HTTP error. A
// caller reading only `res.ok` reports a failed write as a success.
expect(ApproveAiPendingActionResponseSchema.safeParse({ status: 'executed', result: { id: 'a_1' } }).success).toBe(true);
expect(ApproveAiPendingActionResponseSchema.safeParse({ status: 'failed', error: 'row locked' }).success).toBe(true);
expect(
ApproveAiPendingActionResponseSchema.safeParse({ status: 'rejected' }).success,
'approve never yields "rejected" — that is the other route',
).toBe(false);
expect(RejectAiPendingActionResponseSchema.safeParse({ status: 'rejected', id: 'pa_1' }).success).toBe(true);
});
it('validates i18n operations', () => {
expect(GetLocalesResponseSchema.safeParse({
locales: [
{ code: 'en-US', label: 'English (US)', isDefault: true },
{ code: 'es-ES', label: 'Spanish (Spain)' },
],
}).success).toBe(true);
expect(GetTranslationsRequestSchema.safeParse({ locale: 'en-US' }).success).toBe(true);
// The request is locale-only. `namespace`/`keys` were declared here but read
// by no serving surface, so they were trimmed (#3676). Asserting on the
// PARSED OUTPUT is the point: `safeParse` still succeeds on a payload
// carrying them (z.object strips unknown keys), so a success-only assertion
// would keep passing green whether the fields were trimmed or not.
const trimmed = GetTranslationsRequestSchema.parse({ locale: 'en-US', namespace: 'objects', keys: ['a'] });
expect(trimmed).toEqual({ locale: 'en-US' });
expect(GetTranslationsResponseSchema.safeParse({
locale: 'en-US',
translations: { objects: { task: { label: 'Task', pluralLabel: 'Tasks' } }, messages: { save: 'Save' } },
}).success).toBe(true);
expect(GetFieldLabelsRequestSchema.safeParse({ object: 'task', locale: 'en-US' }).success).toBe(true);
expect(GetFieldLabelsResponseSchema.safeParse({
object: 'task', locale: 'en-US',
labels: { status: { label: 'Status', help: 'Current task status', options: { open: 'Open', closed: 'Closed' } } },
}).success).toBe(true);
});
});
// ==========================================
// GetDiscoveryResponseSchema — capabilities
// ==========================================
import { GetDiscoveryResponseSchema } from './protocol.zod';
import { WELL_KNOWN_CAPABILITY_KEYS } from './discovery.zod';
describe('GetDiscoveryResponseSchema (capabilities)', () => {
/** The full vocabulary as hierarchical descriptors, all off. */
const allCapabilitiesOff = () =>
Object.fromEntries(WELL_KNOWN_CAPABILITY_KEYS.map(k => [k, { enabled: false }]));
it('should accept response with hierarchical capabilities', () => {
const result = GetDiscoveryResponseSchema.safeParse({
version: 'v1',
name: 'ObjectStack API',
capabilities: {
...allCapabilitiesOff(),
comments: { enabled: true, features: { threaded: true } },
automation: { enabled: false },
search: { enabled: true, description: 'Full-text search' },
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.capabilities?.comments?.enabled).toBe(true);
expect(result.data.capabilities?.comments?.features?.threaded).toBe(true);
expect(result.data.capabilities?.automation?.enabled).toBe(false);
}
});
// [#5672] This fixture used to lead with `feed: { enabled: true }` and assert
// it round-tripped. It did — `capabilities` was an open `z.record`, so a key
// no producer emits and no consumer reads looked exactly like a real one.
// That is the phantom the closed vocabulary removes, and it is worth a test
// of its own rather than a silent deletion.
it('strips a capability key outside the closed vocabulary', () => {
const result = GetDiscoveryResponseSchema.safeParse({
version: 'v1',
name: 'ObjectStack API',
capabilities: { ...allCapabilitiesOff(), feed: { enabled: true } },
});
// A zod object STRIPS unknown keys rather than failing, so the parse itself
// stays green — the fact to pin is that `feed` does not survive into the
// parsed value, i.e. a consumer reading the spec-parsed body can never see
// it. (The producers' own gates carry the complementary key-set check that
// makes emitting it an error rather than a silent drop.)
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.capabilities).not.toHaveProperty('feed');
}
});
it('rejects a capability map that is missing part of the vocabulary (ruled: an absent capability is `enabled: false`, not a missing key)', () => {
const { comments: _dropped, ...partial } = allCapabilitiesOff();
const result = GetDiscoveryResponseSchema.safeParse({
version: 'v1',
name: 'ObjectStack API',
capabilities: partial,
});
// `.partial()` makes the `capabilities` BLOCK optional at this layer; it
// does not make the vocabulary inside it optional. Present ⇒ complete.
expect(result.success).toBe(false);
});
it('should accept response without capabilities (optional)', () => {
const result = GetDiscoveryResponseSchema.safeParse({
version: 'v1',
apiName: 'ObjectStack API',
});
// Still optional HERE and only here: `GetDiscoveryResponseSchema` is
// `DiscoverySchema.partial()`, the lenient wire wrapper. The canonical
// `DiscoverySchema` — the one every producer's conformance gate parses
// against since #4828 — requires it (#5672).
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.capabilities).toBeUndefined();
}
});
it('should accept response with apiName for backward compatibility', () => {
const result = GetDiscoveryResponseSchema.safeParse({
version: 'v1',
apiName: 'ObjectStack API',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.apiName).toBe('ObjectStack API');
}
});
it('should accept full DiscoverySchema-compatible response', () => {
const result = GetDiscoveryResponseSchema.safeParse({
name: 'ObjectStack',
version: '1.0.0',
environment: 'development',
routes: { data: '/api/v1/data', metadata: '/api/v1/meta' },
locale: { default: 'en', supported: ['en'], timezone: 'UTC' },
services: {
data: { enabled: true, status: 'available', route: '/api/v1/data', provider: 'kernel' },
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('ObjectStack');
expect(result.data.environment).toBe('development');
}
});
});
// ==========================================
// GetDataRequestSchema — select/expand params
// ==========================================
describe('GetDataRequestSchema (select/expand)', () => {
it('should accept request with select and expand', () => {
const result = GetDataRequestSchema.safeParse({
object: 'contact',
id: 'c1',
select: ['name', 'email'],
expand: ['account', 'owner'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.select).toEqual(['name', 'email']);
expect(result.data.expand).toEqual(['account', 'owner']);
}
});
it('should accept request without select/expand (optional)', () => {
const result = GetDataRequestSchema.safeParse({
object: 'contact',
id: 'c1',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.select).toBeUndefined();
expect(result.data.expand).toBeUndefined();
}
});
// [#26909] The by-id door reads `select` through the list door's
// projection rule and refuses a list naming no field; every member is then
// checked against the object's fields, where a blank name is never one.
it.each([[[]], [['', ' ']], [['name', '']]])('refuses select=%j — names no field', (select) => {
expect(GetDataRequestSchema.safeParse({ object: 'contact', id: 'c1', select }).success).toBe(false);
});
it.each([[['name']], [['name', 'email']]])('keeps select=%j unchanged', (select) => {
const result = GetDataRequestSchema.safeParse({ object: 'contact', id: 'c1', select });
expect(result.success).toBe(true);
if (result.success) expect(result.data.select).toEqual(select);
});
it('should strip unknown query parameters via strict parsing', () => {
const result = GetDataRequestSchema.strict().safeParse({
object: 'contact',
id: 'c1',
select: ['name'],
// Unknown params that should be rejected by strict mode
unknownParam: 'should-be-stripped',
});
expect(result.success).toBe(false);
});
});
// ==========================================
// HttpFindQueryParamsSchema — HTTP parameter naming
// ==========================================
describe('HttpFindQueryParamsSchema', () => {
it('should accept canonical filter (singular) parameter', () => {
const result = HttpFindQueryParamsSchema.safeParse({
filter: '{"status":"active"}',
select: 'name,email',
top: 10,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.filter).toBe('{"status":"active"}');
expect(result.data.top).toBe(10);
}
});
it('should accept deprecated filters (plural) parameter for backward compat', () => {
const result = HttpFindQueryParamsSchema.safeParse({
filters: '{"status":"active"}',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.filters).toBe('{"status":"active"}');
}
});
it('should coerce string numbers for top/skip', () => {
const result = HttpFindQueryParamsSchema.safeParse({
top: '25',
skip: '50',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.top).toBe(25);
expect(result.data.skip).toBe(50);
}
});
// [#25269] The page window the list door enforces — not `z.coerce.number()`,
// which admitted `-1`, `1.5` and a blank `?top=` (as `0`).
it.each([['top', '-1'], ['top', '1.5'], ['top', ''], ['top', ' '], ['top', 'abc'], ['skip', '-5'], ['skip', 0.5], ['skip', -1]])(
'refuses %s=%j — not a page window',
(key, value) => {
expect(HttpFindQueryParamsSchema.safeParse({ [key]: value }).success).toBe(false);
},
);
it('keeps 0 and the digits a querystring carries as page windows', () => {
const result = HttpFindQueryParamsSchema.safeParse({ top: '0', skip: ' 5 ' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.top).toBe(0);
expect(result.data.skip).toBe(5);
}
});
it('should accept sort, orderBy, expand, search, count', () => {
const result = HttpFindQueryParamsSchema.safeParse({
sort: 'name asc,created_at desc',
expand: 'owner,account',
search: 'John',
count: true,
});
expect(result.success).toBe(true);
});
// [#26817] The list door's accept set for `count` and `select` — not
// `z.coerce.boolean()` (which read `'false'` as `true` and admitted
// `'maybe'`) and not any string (which admitted a blank projection).
it.each([['true', true], ['false', false], [true, true], [false, false]])(
'reads count=%j as %j',
(value, expected) => {
const result = HttpFindQueryParamsSchema.safeParse({ count: value });
expect(result.success).toBe(true);
if (result.success) expect(result.data.count).toBe(expected);
},
);
it.each(['maybe', '1', '0', '', 'TRUE', 1, null])('refuses count=%j — neither true nor false', (value) => {
expect(HttpFindQueryParamsSchema.safeParse({ count: value }).success).toBe(false);
});
it.each(['', ' ', ',', ' , ,', '\t'])('refuses select=%j — names no field', (value) => {
expect(HttpFindQueryParamsSchema.safeParse({ select: value }).success).toBe(false);
});
it.each(['name', 'name,email', ' name , email ', 'name,,email'])('keeps select=%j — names a field', (value) => {
const result = HttpFindQueryParamsSchema.safeParse({ select: value });
expect(result.success).toBe(true);
if (result.success) expect(result.data.select).toBe(value);
});
// [#26909] The body side of the same door (`FindDataRequestSchema.query`,
// the `POST /data/:object/query` body) now refuses what this one refuses.
it.each(['', ' ', ',', ' , ', []])('the body-side declaration refuses select=%j too', (select) => {
for (const key of ['select', '$select']) {
const parsed = FindDataRequestSchema.safeParse({ object: 'contact', query: { object: 'contact', [key]: select } });
expect(parsed.success, key).toBe(false);
}
});
it('rejects ?distinct — the querystring spelling of the removed query.distinct', () => {
const result = HttpFindQueryParamsSchema.safeParse({ distinct: true });
expect(result.success).toBe(false);
expect(JSON.stringify(result.error?.issues)).toMatch(/query\.distinct.*removed/s);
});
it('should accept empty object (all params optional)', () => {
expect(HttpFindQueryParamsSchema.safeParse({}).success).toBe(true);
});
});
import { SaveMetaItemResponseSchema } from './protocol.zod';
/**
* #5745 — `SaveMetaItemResponseSchema` must describe the FULL body the save
* route returns, not a subset of it.
*
* Before this suite the declaration was `{ success, message? }`, so parsing a
* real response silently DROPPED `version` / `seq` / `state` /
* `projectionApplied` — `safeParse` stayed green while the data disappeared,
* which is the worst shape of failure to leave for a consumer. The first case
* below is the one that was red: it asserts nothing is stripped.
*
* Optionality is measured, not assumed (evidence in the PR): the sole producer
* always emits `version` / `seq` / `state` on its only reachable success
* return, and emits `projectionApplied` only when an ADR-0094 mutation
* projector is registered for the type.
*/
describe('SaveMetaItemResponseSchema (declares the full save response)', () => {
/** A verbatim capture of a real `saveMetaItem` return (repo write path). */
const realResponse = {
success: true,
version: 'sha256:7aad99c8d969efb5067fff275fb3e5be7ec90f9cd610d41709fcddbf8c34b1f0',
seq: 1,
state: 'active',
message: 'Saved customization overlay (org=org_x, state=active) — type=view, name=cases [seq=1]',
};
it('round-trips a real response without stripping any field', () => {
const parsed = SaveMetaItemResponseSchema.parse(realResponse);
expect(Object.keys(parsed).sort()).toEqual(Object.keys(realResponse).sort());
expect(parsed).toEqual(realResponse);
});
it('carries the ADR-0008 OCC token: version survives parse as the If-Match value', () => {
const parsed = SaveMetaItemResponseSchema.parse(realResponse);
expect(parsed.version).toBe(realResponse.version);
});
it('keeps seq as an integer and rejects a fractional one', () => {
expect(SaveMetaItemResponseSchema.parse(realResponse).seq).toBe(1);
expect(SaveMetaItemResponseSchema.safeParse({ ...realResponse, seq: 1.5 }).success).toBe(false);
});
it('accepts both lifecycle states and rejects any other', () => {
expect(SaveMetaItemResponseSchema.safeParse({ ...realResponse, state: 'draft' }).success).toBe(true);
expect(SaveMetaItemResponseSchema.safeParse({ ...realResponse, state: 'active' }).success).toBe(true);
expect(SaveMetaItemResponseSchema.safeParse({ ...realResponse, state: 'published' }).success).toBe(false);
});
it('requires version / seq / state — the producer always emits them', () => {
for (const missing of ['version', 'seq', 'state'] as const) {
const body: Record<string, unknown> = { ...realResponse };
delete body[missing];
expect(
SaveMetaItemResponseSchema.safeParse(body).success,
`omitting '${missing}' must fail parse`,
).toBe(false);
}
});
it('leaves projectionApplied optional — absent means no projector ran', () => {
expect(SaveMetaItemResponseSchema.safeParse(realResponse).success).toBe(true);
const withProjection = SaveMetaItemResponseSchema.parse({
...realResponse,
projectionApplied: { success: false, error: 'boom-from-projector' },
});
expect(withProjection.projectionApplied).toEqual({ success: false, error: 'boom-from-projector' });
});
it('projectionApplied.success is required once the key is present', () => {
expect(
SaveMetaItemResponseSchema.safeParse({ ...realResponse, projectionApplied: { error: 'x' } }).success,
).toBe(false);
});
});
import { PublishMetaItemResponseSchema } from './protocol.zod';
/**
* #7294 — the same suite one door over, for `POST /meta/:type/:name/publish`.
*
* The publish door had NO declaration at all: `PublishMetaItem` appeared
* nowhere under `packages/spec/src/`, so its `version` — the same ADR-0008 OCC
* token the save door already declares — rode the wire with no contract behind
* it, and `PublishMetaItemResponse` could not be named at the type level.
*
* Optionality is measured, not assumed (evidence in the PR): the sole producer
* is `ObjectStackProtocolImplementation.publishMetaItem`, whose single response
* literal always sets `success` / `version` / `seq`, and attaches each of the
* three `*Applied` receipts only when the matching side effect ran.
*/
describe('PublishMetaItemResponseSchema (declares the full publish response)', () => {
/** A verbatim capture of a real `publishMetaItem` return (promotion path). */
const realResponse = {
success: true,
version: 'sha256:7aad99c8d969efb5067fff275fb3e5be7ec90f9cd610d41709fcddbf8c34b1f0',
seq: 2,
message: 'Published draft — type=view, name=cases [seq=2]',
};
it('round-trips a real response without stripping any field', () => {
const parsed = PublishMetaItemResponseSchema.parse(realResponse);
expect(Object.keys(parsed).sort()).toEqual(Object.keys(realResponse).sort());
expect(parsed).toEqual(realResponse);
});
it('carries the ADR-0008 OCC token: version survives parse as the If-Match value', () => {
expect(PublishMetaItemResponseSchema.parse(realResponse).version).toBe(realResponse.version);
});
it('keeps seq as an integer and rejects a fractional one', () => {
expect(PublishMetaItemResponseSchema.parse(realResponse).seq).toBe(2);
expect(PublishMetaItemResponseSchema.safeParse({ ...realResponse, seq: 2.5 }).success).toBe(false);
});
it('requires success / version / seq — the producer always emits them', () => {
for (const missing of ['success', 'version', 'seq'] as const) {
const body: Record<string, unknown> = { ...realResponse };
delete body[missing];
expect(
PublishMetaItemResponseSchema.safeParse(body).success,
`omitting '${missing}' must fail parse`,
).toBe(false);
}
});
it('carries seedApplied — present only when a `seed` was published', () => {
expect(PublishMetaItemResponseSchema.safeParse(realResponse).success).toBe(true);
const withSeed = PublishMetaItemResponseSchema.parse({
...realResponse,
seedApplied: { success: true, inserted: 3, updated: 1 },
});
expect(withSeed.seedApplied).toEqual({ success: true, inserted: 3, updated: 1 });
// The loader's per-record failure list is part of the shape, not extra
// baggage the schema drops on the floor.
const withErrors = PublishMetaItemResponseSchema.parse({
...realResponse,
seedApplied: { success: false, inserted: 0, updated: 0, errors: [{ row: 1, reason: 'bad ref' }] },
});
expect(withErrors.seedApplied?.errors).toHaveLength(1);
});
it('seedApplied counters are required integers once the key is present', () => {
expect(
PublishMetaItemResponseSchema.safeParse({ ...realResponse, seedApplied: { success: true } }).success,
).toBe(false);
expect(
PublishMetaItemResponseSchema.safeParse({
...realResponse, seedApplied: { success: true, inserted: 1.5, updated: 0 },
}).success,
).toBe(false);
});
it('carries materializeApplied — present only when an ADR-0086 P2 materializer is registered', () => {
const parsed = PublishMetaItemResponseSchema.parse({
...realResponse,
materializeApplied: { success: false, inserted: 0, updated: 0, error: 'boom-from-materializer' },
});
expect(parsed.materializeApplied)
.toEqual({ success: false, inserted: 0, updated: 0, error: 'boom-from-materializer' });
});
it('carries projectionApplied — the same ADR-0094 receipt the save door declares', () => {
const parsed = PublishMetaItemResponseSchema.parse({
...realResponse,
projectionApplied: { success: false, error: 'boom-from-projector' },
});
expect(parsed.projectionApplied).toEqual({ success: false, error: 'boom-from-projector' });
expect(
PublishMetaItemResponseSchema.safeParse({ ...realResponse, projectionApplied: { error: 'x' } }).success,
).toBe(false);
});
it('leaves all three receipts optional — absent means that side effect did not run', () => {
for (const key of ['seedApplied', 'materializeApplied', 'projectionApplied'] as const) {
expect(realResponse).not.toHaveProperty(key);
}
expect(PublishMetaItemResponseSchema.safeParse(realResponse).success).toBe(true);
});
});
import { DeleteMetaItemResponseSchema } from './protocol.zod';
import type { DeleteMetaItemResponse } from './protocol.zod';
/** Type-level identity helpers for the reachability pins below. */
type EqD< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
type AssertD< T extends true > = T;
/**
* #13155 — the compile-time half, at module scope and EXPORTED.
*
* This is the cost the card names, stated as a type fact: `projectionApplied`
* is the channel a caller reads INSTEAD of trusting the 200, and `seq` is the
* ordering token the history/audit trail is read by. Undeclared, neither was
* reachable from a `DeleteMetaItemResponse` without an `as any` — the
* consumer-side tolerance Prime Directive #12 rejects.
*
* These live at module scope rather than inside an `it()` for the reason the
* sibling read-side file records: an unread alias in a function body is TS6196
* under `noUnusedLocals`, and a pin no program compiles is a phantom check.
* `packages/spec` compiles its tests via `tsconfig.test.json`, so reverting the
* declaration turns these red at `pnpm typecheck`.
*/
export type DeleteSeqIsReachable = AssertD< EqD< DeleteMetaItemResponse['seq'], number | undefined > >;
export type DeleteProjectionIsReachable = AssertD<
EqD< DeleteMetaItemResponse['projectionApplied'], { success: boolean; error?: string | undefined } | undefined >
>;
/**
* #13155 — the third verb on the metadata door declares what its branch sends.
*
* `save` (#5745, by the #5563 maintainer ruling) and `publish` (#7294) each
* declare `seq` and `projectionApplied`. `delete/reset` runs the same ADR-0094
* projector and the same history append and puts the same two keys on the
* wire, and declared neither — so this is a decision made for one verb and
* never carried to its sibling, not a fresh question. The first case below is
* the one that was red: it asserts nothing is stripped.
*
* ⚠️ Optionality here DIFFERS from both siblings, and it is measured rather
* than mirrored: `seq` is REQUIRED on save and publish because each has a
* single success return that always sets it, while `deleteMetaItem` has FOUR
* success returns and only one of them appends a history event. The other
* three — the two "no row to delete" no-ops and the control-plane legacy
* raw-engine delete (#5264, which writes no history row and emits no watch
* event) — answer without a `seq`. Declaring it required would make three of
* the producer's own returns fail their own contract, which is the #5563
* defect in mirror image.
*/
describe('DeleteMetaItemResponseSchema (carries the declared = returned discipline to the third verb)', () => {
/**
* A verbatim capture of a real `deleteMetaItem` return — the repository
* path's delete-ful branch, the only one of the four that carries `seq`.
*/
const realResponse = {
success: true,
reset: true,
seq: 3,
message: 'Customization overlay deleted — view/cases reset to artifact default. [seq=3]',
};
it('round-trips a real response without stripping any field', () => {
const parsed = DeleteMetaItemResponseSchema.parse(realResponse);
expect(Object.keys(parsed).sort()).toEqual(Object.keys(realResponse).sort());
expect(parsed).toEqual(realResponse);
});
it('keeps seq as an integer and rejects a fractional one', () => {