-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathindex.ts
More file actions
7649 lines (7253 loc) · 348 KB
/
Copy pathindex.ts
File metadata and controls
7649 lines (7253 loc) · 348 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
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { QueryAST, SortNode, AggregationNode, isFilterAST, type DroppedFieldsEvent } from '@objectstack/spec/data';
import {
BatchUpdateRequest,
BatchUpdateResponse,
UpdateManyRequest,
DeleteManyRequest,
BatchOptions,
MetadataCacheRequest,
MetadataCacheResponse,
StandardErrorCode,
ErrorCategory,
GetDiscoveryResponse,
GetMetaTypesResponse,
GetMetaItemsResponse,
GetMetaItemResponse,
SaveMetaItemResponse,
// [#13023] The reset door's response contract. Both `meta.deleteItem`
// declarations BIND this type rather than transcribing its members: a
// hand-written member list is the exact defect this card removes (a local
// declaration that drifts from the wire), and the card's own body
// demonstrated the failure by attributing the IMPLEMENTATION's declared
// return to this schema.
DeleteMetaItemResponse,
PublishMetaItemResponse,
PublishPackageDraftsResponse,
LoginRequest,
SessionResponse,
GetPresignedUrlRequest,
PresignedUrlResponse,
CompleteUploadRequest,
FileUploadResponse,
InitiateChunkedUploadRequest,
InitiateChunkedUploadResponse,
UploadChunkResponse,
CompleteChunkedUploadRequest,
CompleteChunkedUploadResponse,
UploadProgress,
ListNotificationsResponse,
MarkNotificationsReadResponse,
MarkAllNotificationsReadResponse,
// The AI wire types (#3718). `Ai{Nlq,Suggest,Insights}{Request,Response}`
// used to be here; they typed three endpoints nothing has ever mounted and
// went with the methods that called them. These type the routes that exist.
AiMessage,
AiChatRequest,
AiChatResponse,
AiStreamChunk,
AiCompleteRequest,
AiModelsResponse,
AiConversation,
CreateAiConversationRequest,
ListAiConversationsRequest,
ListAiConversationsResponse,
UpdateAiConversationRequest,
AiAgentSummary,
AiAgentsResponse,
AiAgentChatRequest,
AiPendingAction,
ListAiPendingActionsRequest,
ListAiPendingActionsResponse,
ApproveAiPendingActionResponse,
RejectAiPendingActionResponse,
GetLocalesResponse,
GetTranslationsResponse,
GetFieldLabelsResponse,
RegisterRequest,
WellKnownCapabilities,
// [#5672] A VALUE, not a type: the capability vocabulary's key list, so the
// getter below enumerates the spec's keys instead of the server's.
WELL_KNOWN_CAPABILITY_KEYS,
ApiRoutes,
ImportRequest,
ImportResponse,
CreateImportJobRequest,
CreateImportJobResponse,
ImportJobProgress,
ImportJobResults,
ImportJobSummary,
ListImportJobsRequest,
ListImportJobsResponse,
UndoImportJobResponse,
CrossObjectBatchOperation,
CrossObjectBatchRequest,
CrossObjectBatchResponse,
// [#11924] The GLOBAL cross-object search body — NOT the per-object
// `SearchResult` in `@objectstack/spec/contracts` (the #8140 near-miss trap).
SearchAllResponse,
// [#12104 → #13079] The two analytics route response types whose `data`
// member already transcribes the producer's declared return. Since #13079
// the methods resolve to that `data` member (post-`unwrapResponse`), so the
// annotations INDEX these envelope types — `AnalyticsMetadataResponse['data']`,
// `AnalyticsSqlResponse['data']` — rather than re-declaring the payload: a
// change to the route's declared `data` reaches the SDK with it, and no
// payload type has to be declared in `packages/spec` for the SDK's sake.
AnalyticsMetadataResponse,
AnalyticsSqlResponse,
// [#12038] The meta history/diagnostics and package lifecycle response
// contracts, bound under the recorded ruling (1C/2C/3A/4A/5A). Each is the
// PAYLOAD the route answers — the value after `unwrapResponse` strips the
// dispatcher's `{ success, data }` envelope, and the whole bare body where
// the REST server answers without one — so one type is true on both
// surfaces (survey §8.1).
GetPublishedMetaItemResponse,
ListDraftsResponse,
GetMetaDiagnosticsResponse,
FindReferencesToMetaResponse,
AuditMetaItemResponse,
RollbackMetaItemResponse,
DiffMetaItemResponse,
// [#13523] The change-log body of `GET /meta/:type/:name/history`, the one
// door of the family above whose declaration (#12005, PR #13521) landed
// AFTER the ruling's bindings were written — so both of its exits carried a
// pre-declaration spelling until now. Bound here on the same terms as its
// `AuditMetaItemResponse` twin: the PAYLOAD, envelope-free.
HistoryMetaItemResponse,
PackagePublishResult,
DiscardPackageDraftsResponse,
ListPackageCommitsResponse,
RevertPackageCommitResponse,
RollbackToPackageCommitResponse,
PackageExportManifest,
ReassignOrphanedMetadataResponse,
DuplicatePackageResponse,
} from '@objectstack/spec/api';
import type {
ApprovalRequestRow,
ApprovalActionRow,
ApprovalStatus,
ApprovalDecisionResult,
// [#8140] The service-contract return types this SDK relays verbatim. Each
// one is the DECLARED return of the service method the route calls, and the
// route serves it under at most one `{ success, data }` envelope that
// `unwrapResponse` strips — so the annotation on the method and the value
// the caller receives are one statement, not two.
ApprovalRecallResult,
ApprovalResubmitResult,
ApprovalSendBackResult,
AnalyticsResult,
AudienceBindingSuggestion,
AudienceBindingSuggestionSync,
AutomationResult,
DelegableScope,
ImportObjectResult,
ObjectDraft,
RecordShare,
RemoteTable,
ReportRunResult,
ReportSchedule,
SaveReportInput,
SavedReport,
SchemaValidationReport,
ScreenSpec,
SendEmailResult,
ShareLink,
SharingRuleEvaluationResult,
SharingRuleRow,
} from '@objectstack/spec/contracts';
import type {
ActionDescriptor,
ExecutionLog,
ExecutionStatus,
FlowParsed,
} from '@objectstack/spec/automation';
import type { ExternalCatalog } from '@objectstack/spec/data';
import type { InstalledPackage } from '@objectstack/spec/kernel';
// [#12038] The resolved book tree `meta.getBookTree` answers — declared beside
// its resolver in spec `system/book.zod.ts` (its schema is re-exported into
// `/api` for the route-ledger resolver; the type lives on the system entry).
import type { ResolvedBook } from '@objectstack/spec/system';
import type { ConnectorDescriptor } from '@objectstack/spec/integration';
import type { ExplainDecision } from '@objectstack/spec/security';
import type { InvitationStatus } from '@objectstack/spec/identity';
import { Logger, createLogger } from '@objectstack/core/logger';
import { RealtimeAPI } from './realtime-api';
/**
* Route types that the client can resolve.
* Covers all keys from `ApiRoutes` (the discovery schema). The former
* client-only virtual routes (`views`, `permissions`) were removed in #3612 —
* no server surface ever mounted them.
*/
export type ApiRouteType = keyof ApiRoutes;
export interface ClientConfig {
baseUrl: string;
token?: string;
/**
* Custom fetch implementation (e.g. node-fetch or for Next.js caching)
*/
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
/**
* Logger instance for debugging
*/
logger?: Logger;
/**
* Enable debug logging
*/
debug?: boolean;
/**
* Active environment id (UUID of `sys_environment`). When present, the
* client injects an `X-Environment-Id` header on every request so the
* server's tenant router can resolve the physical data-plane database.
*
* @see docs/adr/0002-environment-database-isolation.md
*/
environmentId?: string;
/**
* Active UI locale (BCP-47, e.g. `'zh-CN'`). When set, the client sends
* it as an `Accept-Language` header on every request so the server
* resolves metadata translations (object/field labels, view headers,
* action text) for the *in-app* language rather than the browser default.
*
* Apps should keep this in sync with their language switcher via
* {@link ObjectStackClient.setLocale} so switching language re-fetches
* localized metadata without a page refresh (issue #1319).
*/
locale?: string;
}
/**
* Discovery Result
* Re-export from @objectstack/spec/api for convenience
*/
export type DiscoveryResult = GetDiscoveryResponse;
/**
* @deprecated Use `data.query()` with standard QueryAST parameters instead.
* This interface uses legacy parameter names (filter/sort/top/skip) that
* require translation to QueryAST. Prefer QueryAST fields directly:
* - filter → where
* - select → fields
* - sort → orderBy
* - skip → offset
* - top → limit
*/
export interface QueryOptions {
select?: string[]; // Simplified Selection
/** @canonical Preferred filter parameter (singular). */
filter?: Record<string, any> | unknown[]; // Map or AST
/** @deprecated Use `filter` (singular). Kept for backward compatibility. */
filters?: Record<string, any> | unknown[]; // Map or AST
sort?: string | string[] | SortNode[]; // 'name' or ['-created_at'] or AST
top?: number;
skip?: number;
// Advanced features
aggregations?: AggregationNode[];
groupBy?: string[];
}
/**
* Canonical query options using Spec protocol field names.
* This is the vocabulary `data.find()` still accepts — `find` itself
* carries `@deprecated`; new code should call `data.query()` instead.
*
* Canonical field mapping (QueryAST-aligned):
* - `where` — filter conditions (replaces legacy `filter`/`filters`)
* - `fields` — field selection (replaces legacy `select`)
* - `orderBy` — sort definition (replaces legacy `sort`)
* - `limit` — max records (replaces legacy `top`)
* - `offset` — skip records (replaces legacy `skip`)
* - `expand` — relation loading (replaces legacy `populate`)
*/
export interface QueryOptionsV2 {
/** Filter conditions (WHERE clause). Accepts MongoDB-style $op object or FilterCondition AST. */
where?: Record<string, any> | unknown[];
/** Fields to retrieve (SELECT clause). */
fields?: string[];
/** Sort definition (ORDER BY clause). */
orderBy?: string | string[] | SortNode[];
/** Maximum number of records to return (LIMIT). */
limit?: number;
/** Number of records to skip (OFFSET). */
offset?: number;
/** Relations to expand (JOIN / eager-load). */
expand?: Record<string, any> | string[];
/** Aggregation functions. */
aggregations?: AggregationNode[];
/** Group by fields. */
groupBy?: string[];
}
/**
* [#6322] A key that exists on {@link QueryOptionsV2} and on NO spelling of the
* legacy {@link QueryOptions} — computed by the type system, not restated by
* hand. Presence of any one of them is what tells `data.find()` the caller
* wrote canonical vocabulary.
*
* `aggregations` / `groupBy` are excluded automatically because both options
* types declare them: shared vocabulary cannot discriminate between the two.
*/
type QueryOptionsV2OnlyKey = Exclude<keyof QueryOptionsV2, keyof QueryOptions>;
/**
* Every canonical-only key, exhaustively.
*
* WHY A `Record<QueryOptionsV2OnlyKey, true>` AND NOT AN ARRAY OF STRINGS.
* `data.find()` used to sniff the branch with a hand-written inline condition
* (`'where' in options || 'fields' in options || 'orderBy' in options ||
* 'offset' in options`), duplicated verbatim in both `find` copies. A
* hand-written list is a second, independent statement of what
* `QueryOptionsV2` declares, and it had already fallen behind that declaration
* TWICE:
*
* - `limit` was never in it, so `find('task', { limit: 20 })` — the most
* natural canonical spelling of "first 20" — fell to the legacy branch,
* which reads only `top`/`skip`/`sort`/`select`/`filter`/`filters`/
* `aggregations`/`groupBy`. Nothing there reads `limit`, so the value was
* dropped between the call and the wire: HTTP 200, server default page
* size, no warning. Its pagination twin `offset` WAS in the list, so one
* interface shipped two pagination keys with opposite behaviour.
* - `expand` was never in it either (see {@link canonicalExpandParam}).
*
* Appending the missing key would have been the third round of the same
* mistake. This shape cannot fall behind: TypeScript rejects the object if a
* canonical-only key is MISSING and rejects it if a key that is not
* canonical-only is present, so the next key added to `QueryOptionsV2` is a
* compile error here until it is listed — and both `find` copies pick it up
* from this one definition on the same commit.
*/
const QUERY_OPTIONS_V2_ONLY_KEYS: Record<QueryOptionsV2OnlyKey, true> = {
where: true,
fields: true,
orderBy: true,
limit: true,
offset: true,
expand: true,
};
/**
* Does this options bag speak canonical {@link QueryOptionsV2} vocabulary?
*
* ONE definition read by both `data.find()` implementations
* (`ObjectStackClient` and `ScopedEnvironmentClient`), which are two faces of one
* wire contract and were byte-identical copies of the old inline condition.
*/
function isCanonicalQueryOptions(options: QueryOptions | QueryOptionsV2): options is QueryOptionsV2 {
return Object.keys(QUERY_OPTIONS_V2_ONLY_KEYS).some((key) => key in options);
}
/**
* `QueryOptionsV2.expand` → the `?expand=` transport spelling, or `undefined`
* when there is nothing to expand.
*
* THE ACCEPTED SPELLING, verified against the server rather than invented:
* `HttpFindQueryParamsSchema` (packages/spec/src/api/protocol.zod.ts) declares
* `expand` on the GET list route as a "Comma-separated list of
* lookup/master_detail field names to expand", and the protocol normalizer
* (packages/metadata-protocol/src/protocol.ts, `findData`) splits that string
* on commas and folds each name into `{ [name]: { object: name } }` before the
* engine batch-loads it. So a comma-joined name list is not one encoding among
* several — it is the only shape this route reads.
*
* Until #6322 `expand` was declared on `QueryOptionsV2`, documented as the
* replacement for a legacy `populate` that `QueryOptions` never had, and mapped
* on NEITHER branch: not one character of it reached the wire.
*
* WHY A NESTED PER-RELATION QUERY IS REFUSED RATHER THAN TRIMMED. The `Record`
* form's KEYS are relation names — exactly what the server derives from the
* comma list, so they map losslessly. Its VALUES are nested QueryASTs, and a
* query string has no spelling for them: trimming them away would deliver a
* wider read than the caller asked for and say nothing, which is the same
* silent-drop defect this function exists to close (and the one the engine
* itself refused inside `expand` in #4371). `data.query()` carries a QueryAST
* body and is where nested expand detail belongs.
*/
function canonicalExpandParam(expand: QueryOptionsV2['expand']): string | undefined {
if (expand == null) return undefined;
let names: string[];
if (Array.isArray(expand)) {
names = expand.map((name) => String(name).trim()).filter(Boolean);
} else if (typeof expand === 'object') {
for (const [relation, nested] of Object.entries(expand)) {
const nestedKeys = nested != null && typeof nested === 'object' && !Array.isArray(nested)
? Object.keys(nested as Record<string, unknown>)
: [];
if (nestedKeys.length > 0) {
throw new Error(
`data.find(): expand['${relation}'] carries a nested query (${nestedKeys.slice().sort().join(', ')}), `
+ 'but the list route accepts only `expand=<comma-separated relation names>` — a nested per-relation '
+ 'query has no transport spelling on a GET, so it would be dropped rather than applied. '
+ 'Pass relation names only, or use data.query() with a QueryAST `expand` for nested detail.',
);
}
}
names = Object.keys(expand).map((name) => name.trim()).filter(Boolean);
} else {
return undefined;
}
return names.length > 0 ? names.join(',') : undefined;
}
export interface PaginatedResult<T = any> {
/** Spec-compliant: array of matching records */
records: T[];
/** Total number of matching records (if requested) */
total?: number;
/** The object name */
object?: string;
/** Whether more records are available */
hasMore?: boolean;
}
/** Spec: GetDataResponseSchema */
export interface GetDataResult<T = any> {
object: string;
id: string;
record: T;
}
/** Spec: CreateDataResponseSchema */
export interface CreateDataResult<T = any> {
object: string;
id: string;
record: T;
/**
* [#3431/#3455] Caller-supplied fields the server LEGALLY stripped before the
* record was written — e.g. a non-system create cannot seed a static `readonly`
* column (#3043), so those keys are dropped and the field re-derives its default.
* Present only when ≥1 field was dropped; the create still succeeded. REST also
* mirrors this in the `X-ObjectStack-Dropped-Fields` response header.
*/
droppedFields?: DroppedFieldsEvent[];
}
/**
* Spec: CloneDataResponseSchema (#11924)
*
* `CreateDataResult`'s structural sibling plus `sourceId` — `id` names the NEW
* record, `sourceId` the record it was copied from. Since #15703 it carries
* `droppedFields` too: the clone producer reports the engine's readonly-strip
* verdict exactly as `createData` does.
*/
export interface CloneDataResult<T = any> {
object: string;
id: string;
sourceId: string;
record: T;
/**
* [#15703] Fields the server LEGALLY stripped before the clone was written —
* a non-system clone cannot seed a static `readonly` column, whether the value
* was COPIED from the source row or supplied through `overrides`, so those
* keys are dropped and the field re-derives its default. Present only when
* ≥1 field was dropped; the clone still succeeded. Body only: unlike `create`,
* the clone route sets no `X-ObjectStack-Dropped-Fields` header.
*/
droppedFields?: DroppedFieldsEvent[];
}
/** Spec: UpdateDataResponseSchema */
export interface UpdateDataResult<T = any> {
object: string;
id: string;
record: T;
/**
* [#3431/#3455] Caller-supplied fields the server LEGALLY stripped from the
* write before persisting — static `readonly` (#2948) or a TRUE `readonlyWhen`
* predicate (#3042). Present only when ≥1 field was dropped; the update still
* succeeded. REST also mirrors this in the `X-ObjectStack-Dropped-Fields` header.
*/
droppedFields?: DroppedFieldsEvent[];
}
/**
* Spec: DeleteDataResponseSchema
*
* [#5638] The success flag is `success`, matching the schema this comment
* names (`packages/spec/src/api/protocol.zod.ts`). It was declared `deleted`
* — a key no schema has ever declared and no server path has ever returned on
* `/data/:object/:id`, so `r.deleted` compiled and read `undefined` at
* runtime. Both `delete` surfaces below are pure `unwrapResponse` / `_unwrap`
* passthroughs: this interface is a claim about the server's body, never a
* rewrite of it, so the claim has to be the schema's.
*/
export interface DeleteDataResult {
object: string;
id: string;
success: boolean;
}
export interface StandardError {
code: StandardErrorCode;
message: string;
category: ErrorCategory;
httpStatus: number;
retryable: boolean;
details?: Record<string, any>;
}
/**
* Parse an SSE response body into the JSON frames it carries.
*
* Used by `ai.chatStream` (#3718). Both AI streaming routes write one JSON
* object per `data:` line and terminate with `data: [DONE]`, so this reads
* line-by-line rather than splitting on the `\n\n` frame separator: the
* encoder also emits a few single-`\n` `g:`-prefixed lines (the legacy Data
* Stream Protocol form for reasoning deltas) that a frame-split would glue
* onto the next event. Non-`data:` lines are skipped, as is a `data:` payload
* that is not JSON — a malformed frame mid-stream must not destroy the frames
* around it.
*
* A module-level function, not a client method: the SDK's URL-conformance
* sweep enumerates every callable on the client and demands each one either
* issue a request or carry an explicit non-HTTP reason. A parser is neither.
*/
async function* parseEventStream(res: Response): AsyncIterable<AiStreamChunk> {
const body = res.body as (ReadableStream<Uint8Array> & AsyncIterable<Uint8Array>) | null | undefined;
if (!body) {
throw new Error('Streaming response carried no body — this runtime\'s fetch does not expose `Response.body`');
}
const decoder = new TextDecoder();
let buffer = '';
const emit = function* (chunk: string): Generator<AiStreamChunk> {
buffer += chunk;
let nl: number;
while ((nl = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line.startsWith('data:')) continue; // blank separators, `g:` reasoning frames
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
try {
yield JSON.parse(payload) as AiStreamChunk;
} catch {
// A frame the server did not finish writing, or a non-JSON payload.
}
}
};
// `getReader()` in the browser and modern Node; async iteration for the
// Node-stream bodies older fetch polyfills hand back.
if (typeof body.getReader === 'function') {
const reader = body.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
yield* emit(decoder.decode(value, { stream: true }));
}
} finally {
reader.releaseLock?.();
}
} else {
for await (const value of body) {
yield* emit(decoder.decode(value as Uint8Array, { stream: true }));
}
}
yield* emit(decoder.decode());
}
/** Best human-readable text for an action failure, whatever shape carried it. */
function actionErrorMessage(e: any): string {
if (typeof e === 'string' && e) return e;
if (typeof e?.message === 'string' && e.message) return e.message;
return 'Action failed';
}
/**
* Fold a 2xx `POST /api/v1/actions/...` body into the `{ success, data?,
* error? }` shape `client.actions.invoke` has always returned.
*
* `payload` is what `unwrapResponse` produced. On a #3962 server a 2xx means
* the action ran and returned, and `payload` IS the handler's return value —
* every failure (rejection 400, dispatch 404/403/503, crash 500) arrives as a
* non-2xx, `fetch` throws on it, and `invoke` catches. This surface does not
* throw either way.
*
* Servers older than #3962 answered a 2xx with the legacy INNER envelope —
* `{success: true, data}` on success, `{success: false, error, code?,
* fields?}` when the handler rejected. A current SDK still has to talk to
* them, so that shape is detected NARROWLY (a `boolean` `success` and no keys
* beyond the envelope's own) and unwrapped; anything else passes through as
* the handler's data. The residual ambiguity — a handler whose own return
* value is exactly envelope-shaped — is unavoidable while both servers exist
* and resolves once the fleet is on #3962.
*/
function normalizeActionResult<T>(payload: any): { success: boolean; data?: T; error?: string } {
const ENVELOPE_KEYS = new Set(['success', 'data', 'error', 'code', 'fields']);
const legacy =
payload && typeof payload === 'object' && !Array.isArray(payload)
&& typeof payload.success === 'boolean'
&& Object.keys(payload).every((k) => ENVELOPE_KEYS.has(k));
if (legacy) {
return payload.success === false
? { success: false, error: actionErrorMessage(payload.error) }
: { success: true, data: payload.data as T };
}
return { success: true, data: payload as T };
}
/**
* Write options for `meta.saveItem` on BOTH clients — the unscoped
* `ObjectStackClient.meta` and {@link ScopedEnvironmentClient.meta}.
*
* Named for the WRITE, not for the query string: three members ride the query
* string and `ifMatch` rides a request HEADER (#11713). One bag per write
* whatever carrier each member takes — the same shape the other first-party
* client's `MetadataClientSaveOptions`
* (`@object-ui/data-objectstack`) already carries, and for the same reason.
*
* ONE exported type deliberately shared by the two declarations, rather than
* an inline literal copied into each. The two `saveItem`s are the same method
* on two clients reaching one pair of routes; every divergence measured
* between them so far has been closed as a defect (#7019 and the cards citing
* it), and a bag spelled twice is a divergence waiting to be introduced by
* whoever next extends only the copy they happened to open.
*
* ## Why these three QUERY parameters, and why they are the ones that exist
*
* `PUT /api/v1/meta/:type/:name` reads exactly these three query parameters,
* and until this type existed the SDK sent NONE of them — `saveItem` built a
* bare path and a body. The sharpest consequence is `force`: `saveMetaItem`'s
* Phase 3a-destructive gate refuses with `409 DESTRUCTIVE_CHANGE` and ends the
* message `— re-submit with ?force=true to proceed.`, so a first-party SDK
* caller was told to set a parameter their client had no way to set. Doing
* literally what the refusal said returned the identical refusal, forever; the
* only way out was to abandon the SDK for raw `fetch`. That is not a
* hypothetical — the platform QA checklist instructs its own authors to issue
* these steps "as raw HTTP with the query string appended" for exactly this
* reason, and `@object-ui/data-objectstack` carries a hand-rolled
* `MetadataClient.save` that composes these same three parameters itself.
*
* ⛔ The remedy clause is the contract here, not a nicety: it is a
* risk-acknowledgement refusal, and `destructiveChangeRemedy` renders it per
* write FACE precisely so that no caller is ever prescribed a mechanism their
* door does not have. Both REST `PUT` doors state face `'meta-envelope'`,
* whose clause names `?force=true` — so the clause is only true of an SDK
* caller while this bag exists. Removing it re-breaks the prose, not just the
* feature.
*
* The face is stated by the SERVER and is not derivable from the caller's
* identity: an SDK save and a raw `fetch` save arrive at the same door as the
* same request. There is therefore no "SDK-flavoured" wording available to
* repair this from the message side — the parameter had to become reachable.
*/
export interface SaveMetaItemOptions {
/**
* `If-Match: <version>` — the ADR-0008 optimistic-concurrency token, and
* the one member here that is NOT a query parameter.
*
* Echo back the `version` a previous `saveItem` (or `publishItem`)
* resolved, and a concurrent edit is refused with `409
* metadata_conflict` instead of silently overwriting the other author's
* write. Omit for the last-write-wins behaviour every call had before
* this member existed — the pin is opt-in on the door too.
*
* Opaque: echo it verbatim, never parse it. `SaveMetaItemResponse.version`
* says so in the contract itself — currently `sha256:<64 hex chars>`, with
* the format explicitly not promised.
*
* Only a non-empty token reaches the wire. `undefined` and `''` both OMIT
* the header rather than sending an empty `If-Match`, which is not merely
* tidier: the door reads the header's PRESENCE as "pin this write", so an
* empty spelling would pin the write against the empty string and refuse
* every save with a 409 the caller never asked for.
*
* [#12195] There is ONE door now. The compound-name twin
* `PUT /meta/:type/:section/:name` — which this note used to pair with —
* is retired, and every name reaches `PUT /meta/:type/:name`
* percent-encoded, so `if-match` behaviour no longer varies by how the
* name is spelled.
*
* Same member name, same header, same truthy guard as the sibling
* first-party `@object-ui/data-objectstack` `MetadataClient.save`, whose
* read surface names this token `checksum` — one content hash, one
* behaviour, two clients. `data.update` / `data.delete` on this same
* client carry it under the same name too.
*/
ifMatch?: string;
/**
* `?force=true` — acknowledge and proceed past the Phase 3a
* destructive-change refusal (`409 DESTRUCTIVE_CHANGE`), whose message
* names this parameter as the remedy.
*
* Only `true` reaches the wire. `false` and `undefined` both OMIT the
* parameter rather than sending `?force=false`, which is not merely
* tidier: the door refuses a REPEATED `?force` (#6877) because a repeated
* value arrives as an array and a non-empty array is truthy — so a
* spelled-out opt-OUT could turn the guard ON. Never emitting the
* opt-out spelling keeps this client clear of that edge entirely.
*/
force?: boolean;
/**
* `?package=<id>` — bind the saved row to that software package
* (`sys_metadata.package_id`). Omit for an environment-local overlay.
* Named `packageId` rather than `package` to match the sibling
* `getItem`/`getItems` options on this same object (`package` is also a
* reserved word).
*/
packageId?: string;
/**
* `?mode=draft` — stage the write as a pending draft instead of
* publishing it to the active overlay.
*
* `'publish'` is the explicit spelling of the default and deliberately
* sends NOTHING: the door acts on `mode=draft` alone and treats every
* other value as publish, so emitting `?mode=publish` would put a value
* on the wire that the server ignores. Same shape the first-party
* `@object-ui/data-objectstack` `MetadataClient.save` already uses.
*
* [#12195] REACHES EVERY SAVE — the carve-out this note used to carry is
* GONE, and it is worth recording why rather than deleting it silently.
*
* `mode` used to reach only the single-segment `PUT /meta/:type/:name`.
* A `name` containing a slash landed on the compound-name twin
* `PUT /meta/:type/:section/:name`, which never read this parameter — so
* `{ mode: 'draft' }` there was IGNORED and the write was PUBLISHED LIVE,
* answered 200, with no signal at the call site (objectstack#11712).
*
* Two changes closed it at the source rather than from this side. Stage 1
* (#12194) made a slash-bearing name unwritable at all, and this stage
* retired the twin and unified this file on `encodeURIComponent`, so every
* save now arrives at the one door that reads `mode`. A name that would
* once have forked to the silent-publish door is now refused `400
* INVALID_REQUEST` by the grammar — loud, at the door, before any write.
*/
mode?: 'draft' | 'publish';
}
/**
* Compose the `meta.saveItem` query string — the ONE builder both `saveItem`
* declarations call, for the same reason {@link SaveMetaItemOptions} is one
* type: the twins must not be able to drift in what they put on the wire.
*
* Returns `''` (not `'?'`) when nothing is set, so an options-less call is
* BYTE-IDENTICAL to what this method sent before the bag existed. That is the
* backward-compatibility guarantee, and it is what the pre-existing URL pins
* measure.
*
* `URLSearchParams.set` (never `append`) is load-bearing: the door REFUSES a
* repeated `force` / `package` / `mode`, so a builder that could emit a key
* twice would turn a caller's option into a 400.
*/
function metaSaveQuery(options?: SaveMetaItemOptions): string {
if (!options) return '';
const params = new URLSearchParams();
// Only the opt-IN is spelled on the wire — see `force`'s doc comment.
if (options.force) params.set('force', 'true');
if (options.packageId) params.set('package', options.packageId);
// Only `'draft'` is actionable server-side; `'publish'` is the default
// said out loud and sends nothing.
if (options.mode === 'draft') params.set('mode', 'draft');
const qs = params.toString();
return qs ? `?${qs}` : '';
}
/**
* Compose the `meta.saveItem` request headers — the ONE builder both
* `saveItem` declarations call, for the same reason {@link metaSaveQuery} is
* one function: the twins must not be able to drift in what they put on the
* wire.
*
* Returns `undefined` (never `{}`) when nothing is set, so the call site can
* omit the `headers` key entirely and an options-less save stays
* BYTE-IDENTICAL to what it sent before this existed.
*
* ⛔ `ifMatch` must never be added to {@link metaSaveQuery}. It is a header;
* neither PUT door reads an `?ifMatch=` parameter, so a query spelling would
* look set at the call site and protect nothing — the exact failure #11713
* exists to close, one carrier over.
*/
function metaSaveHeaders(options?: SaveMetaItemOptions): Record<string, string> | undefined {
if (!options?.ifMatch) return undefined;
// Verbatim and UNQUOTED. The door tolerates ETag-style quotes by stripping
// them, but the token a save resolves carries none, and wrapping it here
// would put bytes on the wire the sibling first-party client does not send.
return { 'If-Match': String(options.ifMatch) };
}
/**
* Request options for `meta.deleteItem` — the carriers the REST reset door
* reads, made reachable from the SDK (#12181).
*
* `DELETE /meta/:type/:name` ("reset metadata item to artifact default")
* reads THREE carriers. This bag declares TWO of them, and the third's
* absence is a ruling rather than an oversight:
*
* - {@link DeleteMetaItemOptions.ifMatch} — the ADR-0008 OCC pin. The door
* already reads `If-Match` and threads it as `parentVersion`, and
* `DeleteMetaItemRequest.parentVersion` names that header in the spec text
* itself. Without an argument for it, every SDK reset was last-write-wins
* on the one verb whose whole job is destroying an overlay row.
* - {@link DeleteMetaItemOptions.state} — `?state=draft`, the NARROWER
* reset: discard the pending draft and leave the published overlay
* serving. Its absence did not make the SDK safer, it made the SDK's only
* reachable reset the full one.
*
* ⛔ `?dropStorage=true` is deliberately NOT a member, and adding it "for
* completeness" reverses a decision. It is the one carrier of the three that
* ADDS destructive reach — it drops the object's physical table after the
* metadata row goes — no caller was measured needing it from this client,
* and the door's repeated-parameter refusal exists because of that
* destructiveness. Maintainer-seat ruling on #12181: a destructive surface
* with no measured pull is not published. A caller that needs it is a
* separate, separately reviewable widening.
*
* Same bag on BOTH `deleteItem` declarations — the unscoped client and the
* environment-scoped twin — for the reason #7019 states: the scoped mount is
* not a second implementation, it is one `registerForBase` call replayed
* against `/environments/:environmentId`, so a bag on one client only would
* be a fresh divergence, not half a fix.
*/
export interface DeleteMetaItemOptions {
/**
* `If-Match: <version>` — the ADR-0008 optimistic-concurrency pin, and
* the one member here that is NOT a query parameter.
*
* Echo back the `version` a previous `saveItem` (or `publishItem`)
* resolved, and a concurrent edit is refused with `409
* metadata_conflict` instead of silently resetting the row the other
* author just wrote. Omit for the last-write-wins behaviour every reset
* had before this member existed — the pin is opt-in on the door too
* (Studio's "Reset" button is deliberately unpinned).
*
* Opaque: echo it verbatim, never parse it.
*
* Only a non-empty token reaches the wire. `undefined` and `''` both OMIT
* the header rather than sending an empty `If-Match`: the door reads the
* header's PRESENCE as "pin this reset", so an empty spelling would pin
* against the empty string and refuse a reset the caller never asked to
* pin.
*
* Same member name, same header, same truthy guard as the sibling
* first-party `@object-ui/data-objectstack` `MetadataClient.reset`, and
* as {@link SaveMetaItemOptions.ifMatch} on this same client.
*/
ifMatch?: string;
/**
* `?state=draft` — discard ONLY the pending draft overlay, leaving the
* still-active overlay serving.
*
* `'active'` is the explicit spelling of the default and deliberately
* sends NOTHING: the door acts on `state=draft` alone and treats every
* other value as active, so emitting `?state=active` would put a value on
* the wire the server ignores. Same shape as
* {@link SaveMetaItemOptions.mode}, and the same vocabulary the spec
* declares (`DeleteMetaItemRequest.state`: `'active' | 'draft'`).
*
* This is the LESS destructive reset, not a new destructive one: without
* it the only reachable reset is the full one, which drops the published
* overlay too.
*/
state?: 'active' | 'draft';
}
/**
* Compose the `meta.deleteItem` query string — the ONE builder both
* `deleteItem` declarations call, for the same reason
* {@link DeleteMetaItemOptions} is one type: the twins must not be able to
* drift in what they put on the wire.
*
* Returns `''` (not `'?'`) when nothing is set, so an options-less call is
* BYTE-IDENTICAL to what this method sent before the bag existed.
*
* `URLSearchParams.set` (never `append`) is load-bearing: the door REFUSES a
* repeated `state` (#6877), so a builder that could emit the key twice would
* turn a caller's option into a 400.
*/
function metaDeleteQuery(options?: DeleteMetaItemOptions): string {
if (!options) return '';
const params = new URLSearchParams();
// Only `'draft'` is actionable server-side; `'active'` is the default
// said out loud and sends nothing.
if (options.state === 'draft') params.set('state', 'draft');
const qs = params.toString();
return qs ? `?${qs}` : '';
}
/**
* Compose the `meta.deleteItem` request headers — the ONE builder both
* `deleteItem` declarations call, for the same reason {@link metaDeleteQuery}
* is one function.
*
* Returns `undefined` (never `{}`) when nothing is set, so the call site can
* omit the `headers` key entirely and an options-less reset stays
* BYTE-IDENTICAL to what it sent before this existed.
*
* ⛔ `ifMatch` must never be added to {@link metaDeleteQuery}. It is a
* header; the reset door reads no `?ifMatch=` parameter, so a query spelling
* would look set at the call site and protect nothing.
*
* Deliberately a sibling of {@link metaSaveHeaders} rather than a call into
* it: the two methods carry two separately-ruled option bags (#11713 for
* `saveItem`, #12181 for this one), so neither type may quietly acquire the
* other's members. The two builders are pinned IN STEP by a test instead —
* one token in, identical header bytes out.
*/
function metaDeleteHeaders(options?: DeleteMetaItemOptions): Record<string, string> | undefined {
if (!options?.ifMatch) return undefined;
// Verbatim and UNQUOTED — the door tolerates ETag-style quotes by
// stripping them, but the token a save resolves carries none, and
// wrapping it here would put bytes on the wire the sibling first-party
// client does not send.
return { 'If-Match': String(options.ifMatch) };
}
/**
* An OAuth client application exactly as `@better-auth/oauth-provider` puts it
* on the wire: RFC 7591 §2 client metadata, snake_case, served BARE — these
* routes carry no ObjectStack envelope (`auth-route-ledger.ts` records all six
* `oauth2/*` client routes as `source: 'better-auth'`).
*
* ⚠️ **The two timestamps are RFC 7591 numbers — Unix epoch SECONDS — not
* `Date` and not ISO-8601 strings.** The provider converts its stored `Date`
* to seconds before serialising, so a `Date` never reaches the wire and there
* is nothing here to revive. `new Date(client_id_issued_at * 1000)` is the
* caller's own step.
*
* Only `client_id` and `redirect_uris` are always present: the serialiser
* emits every other column as `undefined` when the row does not carry it, and
* `JSON.stringify` then drops the key. `redirect_uris` is unconditional
* because the serialiser defaults it to `[]`.
*
* ⚠️ A row that carries provider `metadata` spreads those keys at the TOP
* level alongside the members below. They are deliberately not declared: an
* index signature here would erase every precise member it sits beside.
*/
export interface OAuthApplication {
/** RFC 7591 `client_id`. Always present. */
client_id: string;
/**
* Returned by registration ONLY. `get` strips it, and it is never on the
* public projection — store it at creation time or lose it.
*/
client_secret?: string;
/**
* Unix epoch **seconds** at which the secret expires; `0` is RFC 7591's
* "never expires". Present whenever the client has a secret.
*/
client_secret_expires_at?: number;
/** Unix epoch **seconds** at which the client was registered. */
client_id_issued_at?: number;
/** Space-delimited scope list (RFC 7591 §2), not an array. */
scope?: string;
/**
* Owning user. Declared `string` and not `string | null`: the serialiser
* folds a null column to `undefined`, so `null` is not reachable here even
* though the provider's own type admits it.
*/
user_id?: string;
client_name?: string;
client_uri?: string;
logo_uri?: string;
contacts?: string[];
tos_uri?: string;
policy_uri?: string;
/** RFC 7517 JWK Set, already parsed out of its stored JSON text. */
jwks?: { keys: Record<string, unknown>[] };
jwks_uri?: string;
software_id?: string;
software_version?: string;
software_statement?: string;
/** Always present; the public projection answers an empty array. */
redirect_uris: string[];
post_logout_redirect_uris?: string[];
backchannel_logout_uri?: string;
backchannel_logout_session_required?: boolean;
/** Open union, as the provider declares it — the listed values autocomplete. */
token_endpoint_auth_method?: 'none' | 'client_secret_basic' | 'client_secret_post' | 'private_key_jwt' | (string & {});
/** Open union, as the provider declares it — the listed values autocomplete. */
grant_types?: ('authorization_code' | 'client_credentials' | 'refresh_token' | (string & {}))[];
response_types?: 'code'[];
/** Not `| null`: the serialiser folds a null column to `undefined`. */
application_type?: 'web' | 'native';
disabled?: boolean;
skip_consent?: boolean;
enable_end_session?: boolean;
require_pkce?: boolean;
dpop_bound_access_tokens?: boolean;
subject_type?: 'public' | 'pairwise';
reference_id?: string;
}
/**
* What dynamic registration answers (HTTP **201**): an {@link OAuthApplication}
* plus the server-owned resources linked to the new client, when there are any.
*
* This is the one shape that carries `client_secret`.
*/
export interface OAuthApplicationRegistration extends OAuthApplication {
/** Server-owned resources linked to the registered client, when present. */
resources?: string[];
}
/**
* The PUBLIC projection of an OAuth application — what the consent screen may
* read about a client before the user has agreed to anything.
*
* Deliberately derived from {@link OAuthApplication} rather than restated, so
* the two cannot drift, and deliberately NOT `OAuthApplication` itself: the
* provider hand-picks these seven columns, so every other member is provably
* absent rather than merely optional.
*
* ⚠️ `redirect_uris` is always `[]` here. The projection does not pass the