@synthigy/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.d.ts ADDED
@@ -0,0 +1,1108 @@
1
+ // ============================================================================
2
+ // Selection
3
+ // ============================================================================
4
+
5
+ /** A rel() config object — use directly or in an array for multiple occurrences */
6
+ export interface RelationConfig {
7
+ selections?: Selection
8
+ args?: Args
9
+ alias?: string
10
+ }
11
+
12
+ /**
13
+ * Selection for a /data operation.
14
+ *
15
+ * Shorthand forms (all equivalent at nesting level):
16
+ * ['name', 'email'] — scalar field list
17
+ * { name: null, email: null } — scalar object
18
+ * { roles: { name: null } } — relation (auto-wrapped)
19
+ * { roles: rel({ name: null }, { _limit: 5 }) } — rel() with args
20
+ * { roles: [rel({ name: null }, w1, 'a'), rel({ name: null }, w2, 'b')] } — multiple
21
+ */
22
+ export type Selection =
23
+ | string[]
24
+ | { [field: string]: null | true | RelationConfig | RelationConfig[] | Selection }
25
+
26
+ // ============================================================================
27
+ // Filter / Args
28
+ // ============================================================================
29
+
30
+ export type Condition =
31
+ | { _eq: unknown }
32
+ | { _neq: unknown }
33
+ | { _gt: unknown }
34
+ | { _ge: unknown }
35
+ | { _lt: unknown }
36
+ | { _le: unknown }
37
+ | { _in: unknown[] }
38
+ | { _nin: unknown[] }
39
+ | { _like: string }
40
+ | { _ilike: string }
41
+ | { _is_null: boolean }
42
+
43
+ export interface WhereClause {
44
+ _and?: WhereClause[]
45
+ _or?: WhereClause[]
46
+ _not?: WhereClause
47
+ [field: string]: Condition | WhereClause | WhereClause[] | undefined
48
+ }
49
+
50
+ export interface Args {
51
+ _where?: WhereClause
52
+ _limit?: number
53
+ _offset?: number
54
+ _order_by?: Array<[string, 'asc' | 'desc']>
55
+ _distinct?: string[]
56
+ [field: string]: unknown
57
+ }
58
+
59
+ // ============================================================================
60
+ // Operations
61
+ // ============================================================================
62
+
63
+ export type KeyFormat = 'kebab' | 'snake' | 'camel'
64
+
65
+ export interface ExecOpts {
66
+ actingAs?: string
67
+ keyFormat?: KeyFormat
68
+ }
69
+
70
+ export interface WriteOpts extends ExecOpts {
71
+ /** Echo the written records instead of the default silent `{ count }`. */
72
+ returning?: boolean
73
+ }
74
+
75
+ export interface TreeExecOpts extends ExecOpts {
76
+ /** Return the raw flat array instead of composing into a tree/forest. */
77
+ raw?: boolean
78
+ /** Key under which children are nested (default: "_children"). */
79
+ childrenKey?: string
80
+ }
81
+
82
+ export type OperationResult<T = unknown> =
83
+ | { ok: true; data: T }
84
+ | { ok: false; error: { message: string; code: string } }
85
+
86
+ export type Operation = Record<string, unknown>
87
+
88
+ export type FetchFn = (body: object) => Promise<{ results: OperationResult[] }>
89
+
90
+ // ============================================================================
91
+ // Subscriptions — records-only
92
+ // ============================================================================
93
+
94
+ export interface SubscribeEvent {
95
+ /**
96
+ * The delta kind from the envelope payload: 'record/insert' | 'record/update'
97
+ * | 'record/delete' | 'relation/link' | 'relation/unlink' |
98
+ * 'entity/touched' | 'relation/touched' (plus the SDK's 'sse/open' sentinel).
99
+ * NOT the SSE `event:` field — that is always the literal 'data'. This is the
100
+ * raw envelope; the typed shape is ObserveEvent (see observe()).
101
+ */
102
+ type: string
103
+ [key: string]: unknown
104
+ }
105
+
106
+ /**
107
+ * A subscription descriptor declares the records a consumer is watching.
108
+ * Most callers pass a bare array of xids; the object form lets you also
109
+ * narrow by operation.
110
+ *
111
+ * subscribe(['u-abc', 'u-def'])
112
+ * subscribe({ records: ['u-abc'], operations: ['update', 'link'] })
113
+ */
114
+ export type Descriptor =
115
+ | string[]
116
+ | { records: string[]; operations?: string[] }
117
+
118
+ export interface SubscribeOpts {
119
+ /** Stable handle for later unsubscribe(); defaults to a hash of the descriptor. */
120
+ key?: string
121
+ }
122
+
123
+ export interface ListenOpts {
124
+ signal?: AbortSignal
125
+ }
126
+
127
+ export interface SubscriptionInfo {
128
+ /** Subscription type. 'data' for record-scoped events, 'deployed-model' / 'runtime-model' for model deploys. */
129
+ type?: 'data' | 'deployed-model' | 'runtime-model'
130
+ records?: string[]
131
+ operations?: string[]
132
+ }
133
+
134
+ export interface SubscriptionItem {
135
+ type?: 'data' | 'deployed-model' | 'runtime-model'
136
+ /** Required for `type: "data"`. Non-empty array of xid strings. */
137
+ records?: string[]
138
+ operations?: string[]
139
+ /** Optional stable handle for later unsubscribe(). */
140
+ key?: string
141
+ }
142
+
143
+ // ============================================================================
144
+ // History — temporal query surface
145
+ // ============================================================================
146
+
147
+ export interface HistoryEvent {
148
+ ts: string
149
+ recordXid?: string
150
+ entityXid?: string
151
+ attributeXid?: string
152
+ value?: unknown
153
+ op?: 'insert' | 'update' | 'delete' | 'link' | 'unlink' | string
154
+ actorXid?: string
155
+ requestId?: string
156
+ scopeXid?: string
157
+ txid?: number | string
158
+ // Relation events
159
+ relationXid?: string
160
+ fromXid?: string
161
+ toXid?: string
162
+ }
163
+
164
+ export interface HistoryEventsOpts {
165
+ recordXid?: string
166
+ between?: [string, string]
167
+ tenant?: string
168
+ limit?: number
169
+ /** 'entity' (default) or 'relation' */
170
+ track?: 'entity' | 'relation'
171
+ }
172
+
173
+ export interface HistoryDiffResult<T = Record<string, unknown>> {
174
+ before: T | null
175
+ after: T | null
176
+ changed: string[]
177
+ }
178
+
179
+ export interface HistoryTimelineOpts {
180
+ between?: [string, string]
181
+ groupBy?: 'request' | 'actor' | 'scope'
182
+ tenant?: string
183
+ limit?: number
184
+ }
185
+
186
+ export interface HistorySinceOpts {
187
+ cursor?: string
188
+ tenant?: string
189
+ limit?: number
190
+ track?: 'entity' | 'relation'
191
+ }
192
+
193
+ // ============================================================================
194
+ // Lint
195
+ // ============================================================================
196
+
197
+ export interface LintDiagnostic {
198
+ severity: 'error' | 'warning'
199
+ message: string
200
+ /** Byte offset (inclusive) into the source. */
201
+ from: number
202
+ /** Byte offset (exclusive) into the source. */
203
+ to: number
204
+ /** 1-based line/column at `from`. */
205
+ start: { line: number; col: number }
206
+ /** 1-based line/column at `to`. */
207
+ end: { line: number; col: number }
208
+ }
209
+
210
+ // ============================================================================
211
+ // Schema
212
+ // ============================================================================
213
+
214
+ export interface SchemaRelation {
215
+ to: string
216
+ cardinality: 'one' | 'many'
217
+ }
218
+
219
+ export interface SchemaAttribute {
220
+ /** Scalar or reference type: 'string' | 'boolean' | 'int' | 'float' | 'timestamp' | 'json' | 'uuid' | 'enum' | 'hashed' | 'encrypted' | 'transit' | <referenced-entity> */
221
+ type: string
222
+ /** Present (and always `false`) only for mandatory fields; absent ⇒ nullable.
223
+ * The terse-by-exception form: most fields are optional and omit this key. */
224
+ nullable?: boolean
225
+ /** Enum member values, verbatim (not re-cased). Present only when `type === 'enum'`. */
226
+ enum?: string[]
227
+ /** Input-only — written but never read back (no plaintext). Present only for
228
+ * `hashed` fields. (`encrypted` reads back as ciphertext, so it is NOT write-only.) */
229
+ 'write-only'?: boolean
230
+ }
231
+
232
+ export interface SchemaEntity {
233
+ name: string
234
+ /** Field name (kebab-case) → attribute schema. Keys are canonical kebab-case
235
+ * regardless of any `key_format` used on `/data` responses. */
236
+ attributes: Record<string, SchemaAttribute>
237
+ relations: Record<string, SchemaRelation>
238
+ constraints: { unique: string[][] }
239
+ }
240
+
241
+ export interface Schema {
242
+ 'id-key': 'xid' | 'euuid'
243
+ /** Entity name (kebab-case) → entity schema */
244
+ entities: Record<string, SchemaEntity>
245
+ }
246
+
247
+ // ============================================================================
248
+ // Error
249
+ // ============================================================================
250
+
251
+ /**
252
+ * Wire-level error code emitted by the Synthigy server. Union of the
253
+ * stable typed codes; falls through to `string` so future codes don't
254
+ * become a breaking change here.
255
+ *
256
+ * Discriminate on these instead of regex-matching `error.message`:
257
+ *
258
+ * try { await client.search(...) } catch (e) {
259
+ * if (e instanceof SynthigyError && e.code === 'UNKNOWN_ATTRIBUTE') {
260
+ * // e.details may carry { entity, attribute, hint }
261
+ * }
262
+ * }
263
+ *
264
+ * Auth / impersonation:
265
+ * UNAUTHORIZED, CLIENT_NOT_FOUND, CLIENT_INACTIVE,
266
+ * PUBLIC_CLIENT_FORBIDDEN, NOT_TRUSTED, USER_NOT_FOUND, FORBIDDEN
267
+ *
268
+ * Request shape:
269
+ * INVALID_BODY, NO_OPERATIONS, BAD_OP, MISSING_ENTITY,
270
+ * BAD_ARGS_SHAPE
271
+ *
272
+ * Schema resolution (typed throws ride along the engine's existing
273
+ * walks; ex-`INTERNAL_ERROR`):
274
+ * UNKNOWN_OP, UNKNOWN_ENTITY, UNKNOWN_ATTRIBUTE, UNKNOWN_OPERATOR,
275
+ * MISSING_ON, MISSING_ROOT, XSQL_PARSE_ERROR,
276
+ * PARAM_MISSING, PARAM_TYPE_MISMATCH
277
+ *
278
+ * DB:
279
+ * FK_VIOLATION, UNIQUE_VIOLATION, NOT_NULL_VIOLATION,
280
+ * CHECK_VIOLATION, TYPE_CAST_FAILURE, TIMEOUT
281
+ *
282
+ * Transport / catch-all:
283
+ * HTTP_ERROR, OPERATION_ERROR, INTERNAL_ERROR
284
+ *
285
+ * Client-side (no server round trip):
286
+ * NO_TOKEN — no token source configured; see ClientConfig.
287
+ */
288
+ export type SynthigyErrorCode =
289
+ | 'UNAUTHORIZED' | 'CLIENT_NOT_FOUND' | 'CLIENT_INACTIVE'
290
+ | 'PUBLIC_CLIENT_FORBIDDEN' | 'NOT_TRUSTED' | 'USER_NOT_FOUND'
291
+ | 'FORBIDDEN' | 'NO_TOKEN'
292
+ | 'INVALID_BODY' | 'NO_OPERATIONS' | 'BAD_OP' | 'MISSING_ENTITY'
293
+ | 'BAD_ARGS_SHAPE'
294
+ | 'UNKNOWN_OP' | 'UNKNOWN_ENTITY' | 'UNKNOWN_ATTRIBUTE' | 'UNKNOWN_OPERATOR'
295
+ | 'MISSING_ON' | 'MISSING_ROOT' | 'XSQL_PARSE_ERROR'
296
+ | 'PARAM_MISSING' | 'PARAM_TYPE_MISMATCH'
297
+ | 'FK_VIOLATION' | 'UNIQUE_VIOLATION' | 'NOT_NULL_VIOLATION'
298
+ | 'CHECK_VIOLATION' | 'TYPE_CAST_FAILURE' | 'TIMEOUT'
299
+ | 'HTTP_ERROR' | 'OPERATION_ERROR' | 'INTERNAL_ERROR'
300
+ | (string & {})
301
+
302
+ export class SynthigyError extends Error {
303
+ name: 'SynthigyError'
304
+ code: SynthigyErrorCode
305
+ details?: unknown
306
+ constructor(message: string, code: string, details?: unknown)
307
+ }
308
+
309
+ // ============================================================================
310
+ // Client config
311
+ // ============================================================================
312
+
313
+ export interface ClientConfig {
314
+ endpoint: string
315
+ /** OAuth client ID — required with clientSecret for client credentials */
316
+ clientId?: string
317
+ clientSecret?: string
318
+ /**
319
+ * Static bearer token — alternative to client credentials. On Node,
320
+ * with none of clientId+clientSecret/token given, the client also falls
321
+ * back to SYNTHIGY_TOKEN env, then a SYNTHIGY_SUPERVISED=1 stdio ask;
322
+ * with no source at all it throws SynthigyError{code: 'NO_TOKEN'}.
323
+ */
324
+ token?: string
325
+ scope?: string
326
+ /**
327
+ * Default client_credentials audience, applied to every mint this client
328
+ * makes (falls back to SYNTHIGY_AUDIENCE on Node). The platform's audience
329
+ * model is opt-in: a mint naming no audience resolves to the identity-only
330
+ * OIDC audience, which /data rejects. The server publishes its /data
331
+ * audience at /.well-known/synthigy as `auth.oidc.audience`.
332
+ */
333
+ audience?: string
334
+ /** Default acting_as applied to every request unless overridden per-call */
335
+ actingAs?: string
336
+ keyFormat?: KeyFormat
337
+ /**
338
+ * Hold the upstream SSE connection open across windows where no user
339
+ * watches are active. Default: false (SSE closes when the last watch
340
+ * closes — correct for browser SPAs).
341
+ *
342
+ * Set true for long-lived BFFs / Node services: the server-side
343
+ * plug session is keyed by [sub, client_id] and is torn down when
344
+ * the SSE drops, so a tab refresh that briefly closes every watch can
345
+ * lose published events. With keepAlive on, the SSE stays open from
346
+ * `createClient()` until `client.close()`.
347
+ */
348
+ keepAlive?: boolean
349
+ /**
350
+ * Request timeout in milliseconds. Applied to all one-shot requests (exec,
351
+ * subscribe, etc.) via `AbortSignal.timeout`. Not applied when the call
352
+ * already carries an AbortSignal (e.g. listen() with opts.signal).
353
+ * Requires Node ≥ 17.3 / modern browsers.
354
+ */
355
+ timeout?: number
356
+ /**
357
+ * Optional fetch implementation. Defaults to `globalThis.fetch`.
358
+ * Pass `undici.fetch` with a shared `Agent` for pooled connections in Node.
359
+ */
360
+ fetch?: typeof globalThis.fetch
361
+ }
362
+
363
+ // ============================================================================
364
+ // Client
365
+ // ============================================================================
366
+
367
+ export class SynthigyClient {
368
+ /**
369
+ * Tear the client down — close every live watch, release the keepAlive
370
+ * hold, close the SSE. Idempotent. Use this on shutdown so the server
371
+ * drops the plug session promptly instead of waiting for the SSE
372
+ * to time out.
373
+ */
374
+ close(): void
375
+
376
+ /**
377
+ * Returns a FetchFn for `@synthigy/buffer`'s `connect({ fetchFn })`.
378
+ * Shares this client's token cache, endpoint, and fetch implementation.
379
+ */
380
+ fetchFn(): FetchFn
381
+
382
+ /** Get a bearer token, optionally for a specific audience (Synthigy as IdP). */
383
+ token(opts?: { audience?: string }): Promise<string>
384
+
385
+ /** Execute raw operations against /data. */
386
+ exec(operations: Operation[], opts?: ExecOpts): Promise<OperationResult[]>
387
+
388
+ // ── Read ──────────────────────────────────────────────────────────────────
389
+
390
+ search<T = Record<string, unknown>>(
391
+ entity: string,
392
+ args: Args | undefined,
393
+ selection: Selection,
394
+ opts?: ExecOpts,
395
+ ): Promise<T[]>
396
+
397
+ /**
398
+ * Run an XSQL selection-DSL query in `get` mode. Returns a single
399
+ * record or `null`. The XSQL must express identity via unique-key
400
+ * predicates at the root (`xid = ?xid:string` etc.).
401
+ */
402
+ query<T = Record<string, unknown>>(
403
+ xsql: string,
404
+ params: Record<string, unknown> | undefined,
405
+ opts: ExecOpts & { op: 'get'; entity?: string },
406
+ ): Promise<T | null>
407
+
408
+ /**
409
+ * Run an XSQL selection-DSL query with optional named parameters.
410
+ * The wire envelope carries the XSQL string in `selections` plus a
411
+ * `params` map for `?name:type[]` placeholders. Default `op` is
412
+ * `'search'`; pass `opts.op` to use `'get'` / `'slice'` / `'purge'`.
413
+ */
414
+ query<T = Record<string, unknown>>(
415
+ xsql: string,
416
+ params?: Record<string, unknown>,
417
+ opts?: ExecOpts & {
418
+ op?: 'search' | 'slice' | 'purge'
419
+ entity?: string
420
+ },
421
+ ): Promise<T[]>
422
+
423
+ get<T = Record<string, unknown>>(
424
+ entity: string,
425
+ args: Record<string, unknown>,
426
+ selection: Selection,
427
+ opts?: ExecOpts,
428
+ ): Promise<T | null>
429
+
430
+ sqlTemplate<T = Record<string, unknown>>(
431
+ template: string,
432
+ params?: Record<string, unknown> | unknown[],
433
+ opts?: ExecOpts & { cached?: boolean },
434
+ ): Promise<T[]>
435
+
436
+ /** Returns a forest of composed trees. Pass `{ raw: true }` to get the flat array instead. */
437
+ searchTree<T = Record<string, unknown>>(
438
+ entity: string,
439
+ on: string,
440
+ args: Args | undefined,
441
+ selection: Selection,
442
+ opts?: TreeExecOpts,
443
+ ): Promise<(T & { [key: string]: unknown })[]>
444
+
445
+ /** Returns a single composed tree rooted at `root`, or null if not found. Pass `{ raw: true }` to get the flat array instead. */
446
+ getTree<T = Record<string, unknown>>(
447
+ entity: string,
448
+ root: string,
449
+ on: string,
450
+ selection: Selection,
451
+ opts?: TreeExecOpts,
452
+ ): Promise<(T & { [key: string]: unknown }) | null>
453
+
454
+ // ── Write ─────────────────────────────────────────────────────────────────
455
+
456
+ /**
457
+ * Writes are silent by default — the server answers `{ count }`. Pass
458
+ * `returning: true` for the written records, or mint ids up front with
459
+ * `newXid()`, which is the cheap way to know what you wrote.
460
+ */
461
+ sync<T = Record<string, unknown>>(
462
+ entity: string,
463
+ data: Record<string, unknown> | Record<string, unknown>[],
464
+ opts?: WriteOpts,
465
+ ): Promise<T>
466
+
467
+ /** Same `returning` contract as sync. */
468
+ stack<T = Record<string, unknown>>(
469
+ entity: string,
470
+ data: Record<string, unknown> | Record<string, unknown>[],
471
+ opts?: WriteOpts,
472
+ ): Promise<T>
473
+
474
+ /** Returns a map of relation-name → boolean (true = sliced successfully). */
475
+ slice(
476
+ entity: string,
477
+ args: Record<string, unknown>,
478
+ selection?: Selection,
479
+ opts?: ExecOpts,
480
+ ): Promise<Record<string, boolean>>
481
+
482
+ delete(
483
+ entity: string,
484
+ data: Record<string, unknown>,
485
+ opts?: ExecOpts,
486
+ ): Promise<boolean>
487
+
488
+ purge<T = Record<string, unknown>>(
489
+ entity: string,
490
+ args: Args | undefined,
491
+ selection?: Selection,
492
+ opts?: ExecOpts,
493
+ ): Promise<T>
494
+
495
+ // ── Subscriptions ─────────────────────────────────────────────────────────
496
+ //
497
+ // The SDK tracks the active subscription set locally; each call below
498
+ // mutates that local set and POSTs the full set to the server's
499
+ // /data/subscription/set endpoint (full-set replacement semantics).
500
+
501
+ /**
502
+ * Add a record-scoped data subscription. Re-subscribing with the same
503
+ * descriptor (or same `opts.key`) replaces the prior entry.
504
+ *
505
+ * subscribe(['u-abc', 'u-def'])
506
+ * subscribe({ records: ['u-abc'], operations: ['update'] }, { key: 'page-detail' })
507
+ */
508
+ subscribe(descriptor: Descriptor, opts?: SubscribeOpts): Promise<{ ok: boolean }>
509
+
510
+ /**
511
+ * Remove a record-scoped data subscription. `handle` is either the
512
+ * `key` you used at subscribe-time or an equivalent descriptor.
513
+ */
514
+ unsubscribe(handle: string | Descriptor): Promise<{ ok: boolean }>
515
+
516
+ /** Subscribe to model deploy events. raw=true uses 'deployed-model'; otherwise 'runtime-model'. */
517
+ subscribeModel(opts?: { raw?: boolean }): Promise<{ ok: boolean }>
518
+
519
+ /** Unsubscribe from model deploy events. */
520
+ unsubscribeModel(opts?: { raw?: boolean }): Promise<{ ok: boolean }>
521
+
522
+ /** Replace the entire subscription set in one call. */
523
+ setSubscriptions(items: SubscriptionItem[]): Promise<{ ok: boolean }>
524
+
525
+ /** Clear all subscriptions for this session. */
526
+ clearSubscriptions(): Promise<{ ok: boolean }>
527
+
528
+ /** Fetch current subscription state from the server. */
529
+ subscriptions(): Promise<{ subscriptions: SubscriptionInfo[] }>
530
+
531
+ // ── History — temporal query surface ─────────────────────────────────────
532
+
533
+ history: {
534
+ /** State of a record at timestamp T. */
535
+ getAt<T = Record<string, unknown>>(
536
+ recordXid: string,
537
+ at: string,
538
+ opts?: { tenant?: string; includeDeleted?: boolean },
539
+ ): Promise<T | null>
540
+
541
+ /** Events for a record (or any record) over a time range. */
542
+ events(opts?: HistoryEventsOpts): Promise<HistoryEvent[]>
543
+
544
+ /** Diff a record between two timestamps. */
545
+ diff<T = Record<string, unknown>>(
546
+ recordXid: string,
547
+ fromTs: string,
548
+ toTs: string,
549
+ opts?: { tenant?: string },
550
+ ): Promise<HistoryDiffResult<T>>
551
+
552
+ /** Events grouped by request, actor, or scope. */
553
+ timeline(opts: HistoryTimelineOpts): Promise<Record<string, HistoryEvent[]>>
554
+
555
+ /** Events strictly after cursor timestamp T, oldest-first. */
556
+ since(opts?: HistorySinceOpts): Promise<HistoryEvent[]>
557
+ }
558
+
559
+ /** Fetch the IAM-filtered model schema via `GET /schema`. Pass entity names to filter. */
560
+ schema(entities?: string[]): Promise<Schema>
561
+
562
+ /**
563
+ * Lint an XSQL source string against the IAM-projected schema.
564
+ * `entity` enables schema-aware checks; omit for syntax-only lint.
565
+ */
566
+ lint(source: string, opts?: {
567
+ entity?: string
568
+ op?: 'search' | 'get' | 'slice' | 'purge'
569
+ }): Promise<LintDiagnostic[]>
570
+
571
+ /**
572
+ * Mint a one-time account-claim link via `POST /oauth/onboard`.
573
+ * Confidential client whose principal administers the account — RBAC
574
+ * update on User plus the row inside its owner-group write scope, which
575
+ * the shipped User Provisioner role grants — else `PROVISION_FORBIDDEN`;
576
+ * this client's own client_credentials identity IS that principal. `xid`
577
+ * addresses an EXISTING account (create it first via `sync('iam/user', ...)`);
578
+ * a blank xid fails with `XID_REQUIRED`, one that doesn't resolve with
579
+ * `USER_NOT_FOUND`. `reset` soft-recycles the account first (strips its
580
+ * federated identities, nulls its password, revokes its live
581
+ * sessions/tokens — leaves `active` untouched); `methods` restricts the
582
+ * claim page (e.g. `['password']`); omitted allows every active provider
583
+ * plus password. `returnUrl`, if set, must match one of THIS client's
584
+ * registered redirections (or be a loopback URI); a successful DIRECT
585
+ * claim (browser) then redirects there instead of the generic status page.
586
+ */
587
+ onboard(xid: string, opts?: {
588
+ reset?: boolean
589
+ methods?: string[]
590
+ ttlSeconds?: number
591
+ returnUrl?: string
592
+ }): Promise<{ onboard_url: string; expires_at: number; user: { xid: string } }>
593
+
594
+ /**
595
+ * Redeem an onboarding ticket without a browser via
596
+ * `POST /oauth/onboard/complete` — the indirect face of the SAME ticket
597
+ * `onboard()` mints. Must be called by the SAME client that minted the
598
+ * ticket. Never sets a credential — credentials are subject-only; the
599
+ * account activates with none. Give it one via the claim page (password
600
+ * or a federated identity) or a later ticket.
601
+ */
602
+ onboardComplete(ticket: string): Promise<{ user: { xid: string }; active: boolean }>
603
+
604
+ /**
605
+ * Fetch the full ERD model via `op: "deployed-model"`.
606
+ * Unlike `schema()`, this returns the raw model including internal
607
+ * metadata and un-filtered entities. Requires `dataset:load` scope.
608
+ */
609
+ deployedModel(opts?: ExecOpts): Promise<Record<string, unknown>>
610
+
611
+ /**
612
+ * Fetch the runtime (enhanced) ERD model via `op: "runtime-model"` —
613
+ * the model as the running system actually emits it, with audit
614
+ * timestamps, computed defaults, and reference-typed attrs surfaced
615
+ * as first-class relations. Prefer this over `deployedModel()` when
616
+ * you want to see what the live system emits. The server currently
617
+ * returns this transit-encoded, so the result may be a string.
618
+ * Requires `dataset:load` scope.
619
+ */
620
+ runtimeModel(opts?: ExecOpts): Promise<Record<string, unknown> | string>
621
+
622
+ /**
623
+ * Open a persistent SSE connection to `/data/events`.
624
+ * Reconnects automatically with exponential backoff.
625
+ * Sends `Last-Event-ID` on reconnect to avoid missing events.
626
+ * Cancel via `AbortSignal`.
627
+ */
628
+ listen(opts?: ListenOpts): AsyncGenerator<SubscribeEvent>
629
+
630
+ /**
631
+ * One-call live primitive: subscribe + SSE + auto-reconnect (+ optional
632
+ * history backfill). Yields a typed event stream filtered to the
633
+ * records named by the descriptor. Cleanup on iterator close:
634
+ * unsubscribes locally.
635
+ *
636
+ * Server tears down subscription state on each SSE close, so observe()
637
+ * re-subscribes before every new SSE session. The plug's default
638
+ * best-effort delivery is normally enough; pass `backfill: true` to
639
+ * also replay `/history` events missed during a disconnect.
640
+ *
641
+ * for await (const ev of client.observe(['u-abc'])) { ... }
642
+ * for await (const ev of client.observe({
643
+ * records: [userXid, teamXid],
644
+ * operations: ['update', 'link'],
645
+ * })) { ... }
646
+ */
647
+ observe(descriptor: Descriptor, opts?: ObserveOpts): AsyncGenerator<ObserveEvent>
648
+
649
+ /**
650
+ * Open a live, multiplexed subscription. Many `watch()` calls on the
651
+ * same client fuse onto ONE SSE connection and ONE consolidated
652
+ * subscription set on the server. Events are coalesced per-record
653
+ * and attribute-name keyed.
654
+ *
655
+ * Each `for await (const ev of w.events)` gets a fresh iterator with
656
+ * its own buffer — siblings in a component tree can consume the same
657
+ * Watch independently.
658
+ */
659
+ watch(interest: WatchInterest, opts?: WatchOpts): Watch
660
+
661
+ /** Open a separate stream of schema-deploy events. */
662
+ watchSchema(): SchemaWatch
663
+
664
+ /**
665
+ * Live result-set for a query. Built on `watch()`. Snapshots via
666
+ * /data search, watches the result xids + the entity's relations,
667
+ * coalesced re-runs the query on each event window, diffs against
668
+ * last result, emits query/added | query/changed | query/removed.
669
+ */
670
+ watchQuery: WatchQueryFn
671
+
672
+ /**
673
+ * Live raw-SQL result. Composes `sqlTemplate()` snapshot with
674
+ * `watch()` subscription on the entities named in `opts.entities`.
675
+ *
676
+ * SQL is opaque to the SDK, so the caller declares which entities
677
+ * the query reads from — those entities' relation xids become the
678
+ * watch interest. Add specific record xids via `opts.records` if
679
+ * you also need per-row update events to trigger refresh.
680
+ */
681
+ watchSqlTemplate<T = Record<string, unknown>>(
682
+ template: string,
683
+ params?: Record<string, unknown> | unknown[],
684
+ opts?: SqlTemplateWatchOpts,
685
+ ): SqlTemplateWatch<T>
686
+ }
687
+
688
+ export interface SqlTemplateWatchOpts extends WatchOpts {
689
+ /** Entity names the SQL reads from. Required — used to compute the
690
+ * relation-xid watch interest. */
691
+ entities: string[]
692
+ /** Extra record xids to include in the watch interest. Use when the
693
+ * SQL aggregates over fields that can change without inserts/deletes
694
+ * (e.g. avg(value) where existing rows' values are mutable). */
695
+ records?: string[]
696
+ /** Forwarded to sqlTemplate() — actingAs, abort signal, etc. */
697
+ searchOpts?: ExecOpts
698
+ }
699
+
700
+ export interface SqlTemplateWatch<T = Record<string, unknown>> {
701
+ /** Current result: array of rows. */
702
+ readonly value: T[] | null
703
+ /** Convenience: first row, or null. */
704
+ readonly first: T | null
705
+ /** Resolves once the initial snapshot + watch are wired. */
706
+ ready(): Promise<void>
707
+ /** Force a fresh re-run of the SQL. */
708
+ refresh(): Promise<void>
709
+ /** Async iterable of events. */
710
+ readonly events: AsyncIterable<SqlTemplateWatchEvent<T> | WatchSentinel>
711
+ /** Tear down the watch. */
712
+ close(): void
713
+ }
714
+
715
+ export type SqlTemplateWatchEvent<T = Record<string, unknown>> =
716
+ | { type: 'result/changed'; before: T[] | null; after: T[]; ts?: string; txid?: string; actor?: string; request?: string }
717
+
718
+ // ============================================================================
719
+ // Live query / aggregate types
720
+ // ============================================================================
721
+
722
+ export interface WatchQueryOpts extends WatchOpts {
723
+ /** Forwarded to search()/query() as the request's options bag. */
724
+ searchOpts?: ExecOpts
725
+ }
726
+
727
+ export interface XsqlWatchOpts extends WatchOpts {
728
+ /** Kebab-case root entity name — required so the SDK knows which
729
+ * relations to watch for new-member events. */
730
+ entity: string
731
+ searchOpts?: ExecOpts
732
+ /** Op override for the underlying client.query() call (default 'search'). */
733
+ op?: 'search' | 'get' | 'slice' | 'purge'
734
+ }
735
+
736
+ export interface WatchQueryFn {
737
+ <T = Record<string, unknown>>(
738
+ entity: string,
739
+ args: Args,
740
+ selection: Selection,
741
+ opts?: WatchQueryOpts,
742
+ ): QueryWatch<T>
743
+ xsql<T = Record<string, unknown>>(
744
+ xsql: string,
745
+ params?: Record<string, unknown>,
746
+ opts?: XsqlWatchOpts,
747
+ ): QueryWatch<T>
748
+ }
749
+
750
+ export type QueryEvent<T> =
751
+ | { type: 'query/added'; record: T;
752
+ ts?: string; txid?: number; actor?: string; request?: string }
753
+ | { type: 'query/removed'; recordXid: string;
754
+ ts?: string; txid?: number; actor?: string; request?: string }
755
+ | { type: 'query/changed'; record: T;
756
+ before: Partial<T>; after: Partial<T>; changed: (keyof T & string)[];
757
+ ts?: string; txid?: number; actor?: string; request?: string }
758
+ | WatchSentinel
759
+
760
+ export interface QueryWatch<T = Record<string, unknown>> {
761
+ readonly initial: readonly T[] | null
762
+ readonly records: ReadonlyMap<string, T>
763
+ list(): T[]
764
+ ready(): Promise<void>
765
+ refresh(): Promise<void>
766
+ readonly events: AsyncIterable<QueryEvent<T>>
767
+ close(): void
768
+ }
769
+
770
+ export interface AggregateSelection {
771
+ count?: null | true
772
+ avg?: Record<string, null | true>
773
+ sum?: Record<string, null | true>
774
+ min?: Record<string, null | true>
775
+ max?: Record<string, null | true>
776
+ }
777
+
778
+ export interface AggregateResult {
779
+ count?: number
780
+ avg?: Record<string, number>
781
+ sum?: Record<string, number>
782
+ min?: Record<string, unknown>
783
+ max?: Record<string, unknown>
784
+ }
785
+
786
+ // ============================================================================
787
+ // Watch types
788
+ // ============================================================================
789
+
790
+ export interface WatchInterest {
791
+ /** Record xids to watch. Receives entity events for these records, and
792
+ * relation events where either side of the link is one of these records. */
793
+ records?: string[]
794
+ /** Relation-type xids to watch. Receives link/unlink events for those types. */
795
+ relations?: string[]
796
+ /** Ops to include; omit for all. */
797
+ ops?: ('insert' | 'update' | 'delete' | 'link' | 'unlink')[]
798
+ }
799
+
800
+ export interface WatchOpts {
801
+ /**
802
+ * 'coalesce' (default) — collapse repeated entity events on the same record
803
+ * into the latest state. 'lossless' — preserve every event; emit a 'paused'
804
+ * sentinel on overflow. 'sliding' — drop oldest on overflow (legacy).
805
+ */
806
+ backpressure?: 'coalesce' | 'lossless' | 'sliding'
807
+ /** Per-iterator buffer size (default 100). */
808
+ bufferSize?: number
809
+ signal?: AbortSignal
810
+ }
811
+
812
+ /** Common provenance carried on every event. */
813
+ export interface WatchProvenance {
814
+ ts: string
815
+ txid?: number
816
+ actor?: string
817
+ request?: string
818
+ tenant?: string
819
+ scope?: string
820
+ }
821
+
822
+ export interface RecordEvent extends WatchProvenance {
823
+ type: 'record/insert' | 'record/update' | 'record/delete'
824
+ record: string
825
+ /**
826
+ * Keyed by the plug's raw attribute names — **snake_case**, NOT
827
+ * transformed to the client's keyFormat (unlike `/data` responses, which the
828
+ * server cases per `key_format`). So `after['release_year']`, even when the
829
+ * client is configured `keyFormat: 'kebab'` and `/data` returns `release-year`.
830
+ */
831
+ before?: Record<string, unknown>
832
+ after?: Record<string, unknown>
833
+ /** Attribute-name list; computed by the SDK from before/after. */
834
+ changed?: string[]
835
+ }
836
+
837
+ export interface RelationEvent extends WatchProvenance {
838
+ type: 'relation/link' | 'relation/unlink'
839
+ /** [subscribed-xid, other-xid] — position 0 is always your subscribed endpoint. */
840
+ data: [string, string]
841
+ }
842
+
843
+ export type WatchSentinel =
844
+ | { type: 'connection/resumed'; gap: [string | null, string | null] }
845
+ | { type: 'schema/changed' }
846
+ | { type: 'subscription/rejected'; reason: string; records: string[] }
847
+ | { type: 'paused' }
848
+
849
+ export type WatchEvent = RecordEvent | RelationEvent | WatchSentinel
850
+
851
+ export interface Watch {
852
+ readonly interest: WatchInterest
853
+ readonly closed: boolean
854
+ readonly events: AsyncIterable<WatchEvent>
855
+
856
+ add(xids: string[]): void
857
+ remove(xids: string[]): void
858
+ setInterest(interest: WatchInterest): void
859
+
860
+ /** Suppress the next event carrying this request-id. Auto-expires after ttlMs. */
861
+ muteRequest(requestId: string, opts?: { ttlMs?: number }): void
862
+
863
+ close(): void
864
+ }
865
+
866
+ export interface SchemaWatch {
867
+ readonly events: AsyncIterable<{ type: 'schema/changed' }>
868
+ close(): void
869
+ }
870
+
871
+ export class WatchError extends Error {
872
+ code: string
873
+ details?: unknown
874
+ constructor(message: string, code: string, details?: unknown)
875
+ }
876
+
877
+ // ============================================================================
878
+ // Observe types — record-shaped envelope
879
+ // ============================================================================
880
+
881
+ export interface ObserveOpts {
882
+ signal?: AbortSignal
883
+ /** Stable subscription handle; defaults to a hash of the descriptor. */
884
+ key?: string
885
+ /** Replay /history events missed during disconnect (default: false). */
886
+ backfill?: boolean
887
+ /** Max rows per backfill pass (default: 1000). */
888
+ backfillLimit?: number
889
+ }
890
+
891
+ /**
892
+ * Common provenance fields carried on every delta envelope.
893
+ */
894
+ export interface DeltaProvenance {
895
+ /** ISO-8601 timestamp captured by the trigger when the underlying change happened. */
896
+ ts: string
897
+ /** Tenant scope xid (install-level boundary). */
898
+ tenant?: string
899
+ /** Scope xid (per-record access scope, if set). */
900
+ scope?: string
901
+ /** Actor xid (the user who triggered the change). */
902
+ actor?: string
903
+ /** Request-id from the actor's request, if propagated through the trigger. */
904
+ request?: string
905
+ /** DB transaction id — same across all envelopes from one multi-row tx. */
906
+ txid?: number
907
+ /** Set to `true` on events replayed from /history during reconnect. */
908
+ fromBackfill?: boolean
909
+ }
910
+
911
+ /**
912
+ * Record-track delta event. `before` is absent on `record/insert`;
913
+ * `after` is absent on `record/delete`. The `before`/`after` maps are keyed by
914
+ * the plug's raw attribute names — **snake_case**, NOT transformed to the
915
+ * client's keyFormat (unlike `/data`, which cases keys per `key_format`). So
916
+ * `after['release_year']` even under `keyFormat: 'kebab'`. Encrypted columns
917
+ * carry their encrypted form; resolve via /data if you need decrypted values.
918
+ */
919
+ export interface RecordDeltaEvent extends DeltaProvenance {
920
+ type: 'record/insert' | 'record/update' | 'record/delete'
921
+ 'record-xid': string
922
+ before?: Record<string, unknown>
923
+ after?: Record<string, unknown>
924
+ }
925
+
926
+ /**
927
+ * Relation-track delta event for a link/unlink between two entities.
928
+ * `data` is a `[subscribed-xid, other-xid]` tuple — server has rotated
929
+ * so position 0 is always the endpoint in your watched record set.
930
+ * When the identity's union covers BOTH endpoints, the server emits
931
+ * two events per link with rotated tuples (same `ts`/`txid`/provenance);
932
+ * dedupe by `txid` + sorted-endpoints if you want one per underlying link.
933
+ */
934
+ export interface RelationDeltaEvent extends DeltaProvenance {
935
+ type: 'relation/link' | 'relation/unlink'
936
+ data: [string, string]
937
+ }
938
+
939
+ /**
940
+ * Coalesced cache-invalidation poke for an entity track subscription.
941
+ * `entity` is the client's verbatim original string (server echoes back
942
+ * exactly what was subscribed to, before any case/whitespace/separator
943
+ * normalization).
944
+ */
945
+ export interface EntityTouchedEvent {
946
+ type: 'entity/touched'
947
+ entity: string
948
+ ts: string
949
+ }
950
+
951
+ /**
952
+ * Coalesced cache-invalidation poke for a relation track subscription.
953
+ * `relation` is the client's verbatim original string.
954
+ */
955
+ export interface RelationTouchedEvent {
956
+ type: 'relation/touched'
957
+ relation: string
958
+ ts: string
959
+ }
960
+
961
+ export type ObserveEvent =
962
+ | RecordDeltaEvent
963
+ | RelationDeltaEvent
964
+ | EntityTouchedEvent
965
+ | RelationTouchedEvent
966
+
967
+ // ============================================================================
968
+ // Operator helpers
969
+ // ============================================================================
970
+
971
+ export function eq(v: unknown): { _eq: unknown }
972
+ export function neq(v: unknown): { _neq: unknown }
973
+ export function gt(v: unknown): { _gt: unknown }
974
+ export function gte(v: unknown): { _ge: unknown }
975
+ export function lt(v: unknown): { _lt: unknown }
976
+ export function lte(v: unknown): { _le: unknown }
977
+ export function in_(...v: unknown[]): { _in: unknown[] }
978
+ export function nin(...v: unknown[]): { _nin: unknown[] }
979
+ export function like(v: string): { _like: string }
980
+ export function ilike(v: string): { _ilike: string }
981
+ export function isNull(): { _is_null: true }
982
+ export function isNotNull(): { _is_null: false }
983
+
984
+ export function and(...clauses: WhereClause[]): { _and: WhereClause[] }
985
+ export function or(...clauses: WhereClause[]): { _or: WhereClause[] }
986
+ export function not(clause: WhereClause): { _not: WhereClause }
987
+
988
+ // ============================================================================
989
+ // Selection helpers
990
+ // ============================================================================
991
+
992
+ /**
993
+ * Build a relation config for use in a selection.
994
+ *
995
+ * Single: { roles: rel({ name: null }, { _limit: 5 }) }
996
+ * Multiple: { roles: [rel({ name: null }, w1, 'active'), rel({ name: null }, w2, 'archived')] }
997
+ */
998
+ export function rel(selections: Selection, args?: Args, alias?: string): RelationConfig
999
+
1000
+ // ============================================================================
1001
+ // Operation builders
1002
+ // ============================================================================
1003
+
1004
+ export const op: {
1005
+ search(entity: string, args: Args | undefined, selection: Selection): Operation
1006
+ get(entity: string, args: Record<string, unknown>, selection: Selection): Operation
1007
+ sync(entity: string, data: Record<string, unknown>, returning?: boolean): Operation
1008
+ stack(entity: string, data: Record<string, unknown>, returning?: boolean): Operation
1009
+ slice(entity: string, args: Record<string, unknown>, selection?: Selection): Operation
1010
+ delete(entity: string, data: Record<string, unknown>): Operation
1011
+ purge(entity: string, args: Args | undefined, selection?: Selection): Operation
1012
+ searchTree(entity: string, on: string, args: Args | undefined, selection: Selection): Operation
1013
+ getTree(entity: string, root: string, on: string, selection: Selection): Operation
1014
+ sqlTemplate(template: string, params?: Record<string, unknown> | unknown[], opts?: { cached?: boolean }): Operation
1015
+ deployedModel(): Operation
1016
+ runtimeModel(): Operation
1017
+ }
1018
+
1019
+ // ============================================================================
1020
+ // Tree composition
1021
+ // ============================================================================
1022
+
1023
+ export interface ComposeTreeOpts {
1024
+ /** Name of the tree (self-FK) relation, e.g. "father", "parent" */
1025
+ on: string
1026
+ /** Root record id; defaults to the first record */
1027
+ rootId?: string
1028
+ /** Key under which children are nested (default: "_children") */
1029
+ childrenKey?: string
1030
+ }
1031
+
1032
+ export interface ComposeForestOpts {
1033
+ on: string
1034
+ /** Key under which children are nested (default: "_children") */
1035
+ childrenKey?: string
1036
+ }
1037
+
1038
+ export function composeTree<T extends Record<string, unknown>>(
1039
+ records: T[],
1040
+ opts: ComposeTreeOpts,
1041
+ ): (T & { [key: string]: unknown }) | null
1042
+
1043
+ export function composeForest<T extends Record<string, unknown>>(
1044
+ records: T[],
1045
+ opts: ComposeForestOpts,
1046
+ ): (T & { [key: string]: unknown })[]
1047
+
1048
+ // ============================================================================
1049
+ // Factory
1050
+ // ============================================================================
1051
+
1052
+ export function createClient(config: ClientConfig): SynthigyClient
1053
+
1054
+ // ============================================================================
1055
+ // Single-client layer — connect once, call the bare verbs, no client threading.
1056
+ // One process, one client; multiplex identity per-call with opts.actingAs.
1057
+ // Verb types are derived from SynthigyClient so they never drift.
1058
+ // ============================================================================
1059
+
1060
+ /** Install a module-wide default client (destroys any previous one). */
1061
+ export function connect(config: ClientConfig): SynthigyClient
1062
+ /** Destroy and uninstall the default client. No-op when not connected. */
1063
+ export function disconnect(): void
1064
+ /** The default client installed by `connect`, or null. */
1065
+ export function getClient(): SynthigyClient | null
1066
+
1067
+ /**
1068
+ * A fresh 22-char Base58 xid — client-minted identity for sync/stack, the same
1069
+ * derivation the server uses. Mint one before a write to know a record's id up
1070
+ * front, or to make a retried write idempotent.
1071
+ */
1072
+ export function newXid(): string
1073
+
1074
+ export const search: SynthigyClient['search']
1075
+ export const get: SynthigyClient['get']
1076
+ export const sync: SynthigyClient['sync']
1077
+ export const stack: SynthigyClient['stack']
1078
+ export const slice: SynthigyClient['slice']
1079
+ export const purge: SynthigyClient['purge']
1080
+ export const sqlTemplate: SynthigyClient['sqlTemplate']
1081
+ export const query: SynthigyClient['query']
1082
+ export const searchTree: SynthigyClient['searchTree']
1083
+ export const getTree: SynthigyClient['getTree']
1084
+ export const exec: SynthigyClient['exec']
1085
+ declare const _delete: SynthigyClient['delete']
1086
+ export { _delete as delete }
1087
+
1088
+ export const schema: SynthigyClient['schema']
1089
+ export const lint: SynthigyClient['lint']
1090
+ export const deployedModel: SynthigyClient['deployedModel']
1091
+ export const runtimeModel: SynthigyClient['runtimeModel']
1092
+ export const token: SynthigyClient['token']
1093
+ export function history(): SynthigyClient['history']
1094
+
1095
+ export const listen: SynthigyClient['listen']
1096
+ export const observe: SynthigyClient['observe']
1097
+ export const watch: SynthigyClient['watch']
1098
+ export const watchSchema: SynthigyClient['watchSchema']
1099
+ export const watchQuery: SynthigyClient['watchQuery']
1100
+ export const watchSqlTemplate: SynthigyClient['watchSqlTemplate']
1101
+
1102
+ export const subscribe: SynthigyClient['subscribe']
1103
+ export const unsubscribe: SynthigyClient['unsubscribe']
1104
+ export const subscribeModel: SynthigyClient['subscribeModel']
1105
+ export const unsubscribeModel: SynthigyClient['unsubscribeModel']
1106
+ export const setSubscriptions: SynthigyClient['setSubscriptions']
1107
+ export const clearSubscriptions: SynthigyClient['clearSubscriptions']
1108
+ export const subscriptions: SynthigyClient['subscriptions']