@abloatai/ablo 0.6.0 → 0.8.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/CHANGELOG.md +77 -0
- package/README.md +95 -57
- package/dist/BaseSyncedStore.d.ts +1 -1
- package/dist/BaseSyncedStore.js +8 -4
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +3 -2
- package/dist/auth/index.js +39 -11
- package/dist/client/Ablo.d.ts +112 -3
- package/dist/client/Ablo.js +144 -10
- package/dist/client/ApiClient.d.ts +32 -0
- package/dist/client/ApiClient.js +76 -44
- package/dist/client/auth.d.ts +11 -1
- package/dist/client/auth.js +21 -2
- package/dist/client/createModelProxy.d.ts +120 -53
- package/dist/client/createModelProxy.js +66 -31
- package/dist/client/identity.js +14 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +57 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +286 -0
- package/dist/errorCodes.js +284 -0
- package/dist/errors.d.ts +103 -7
- package/dist/errors.js +192 -41
- package/dist/index.d.ts +11 -6
- package/dist/index.js +10 -6
- package/dist/keys/index.d.ts +61 -0
- package/dist/keys/index.js +151 -0
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/client.js +19 -8
- package/dist/react/AbloProvider.d.ts +37 -0
- package/dist/react/AbloProvider.js +107 -4
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +6 -0
- package/dist/schema/diff.js +21 -3
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/index.d.ts +7 -4
- package/dist/schema/index.js +9 -3
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +2 -112
- package/dist/schema/schema.js +50 -62
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +16 -12
- package/dist/schema/serialize.js +16 -12
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- package/dist/sync/BootstrapHelper.js +46 -27
- package/dist/sync/ConnectionManager.d.ts +3 -1
- package/dist/sync/ConnectionManager.js +37 -1
- package/dist/sync/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +26 -19
- package/dist/sync/NetworkProbe.d.ts +8 -0
- package/dist/sync/NetworkProbe.js +24 -2
- package/dist/sync/SyncWebSocket.d.ts +1 -1
- package/dist/sync/SyncWebSocket.js +43 -53
- package/dist/sync/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +46 -1
- package/dist/sync/participants.js +10 -16
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +53 -33
- package/docs/api-keys.md +47 -3
- package/docs/api.md +103 -57
- package/docs/audit.md +16 -9
- package/docs/cli.md +222 -0
- package/docs/client-behavior.md +35 -21
- package/docs/coordination.md +74 -36
- package/docs/data-sources.md +23 -21
- package/docs/examples/agent-human.md +72 -28
- package/docs/examples/ai-sdk-tool.md +14 -11
- package/docs/examples/existing-python-backend.md +30 -19
- package/docs/examples/nextjs.md +21 -8
- package/docs/examples/scoped-agent.md +93 -0
- package/docs/examples/server-agent.md +27 -5
- package/docs/guarantees.md +29 -17
- package/docs/identity.md +198 -121
- package/docs/index.md +35 -18
- package/docs/integration-guide.md +79 -83
- package/docs/interaction-model.md +40 -25
- package/docs/mcp/claude-code.md +9 -17
- package/docs/mcp/cursor.md +6 -24
- package/docs/mcp/windsurf.md +6 -19
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +31 -39
- package/docs/react.md +18 -14
- package/docs/roadmap.md +15 -3
- package/docs/schema-contract.md +109 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +6 -2
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +27 -16
- package/package.json +13 -1
package/dist/schema/model.d.ts
CHANGED
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { z } from 'zod';
|
|
20
20
|
import type { RelationDef } from './relation.js';
|
|
21
|
+
import type { EntityRole } from './roles.js';
|
|
21
22
|
import { type FieldMeta } from './field.js';
|
|
23
|
+
import { type Tenancy, type ScopedViaRef } from './tenancy.js';
|
|
24
|
+
export type { ScopedViaRef, Tenancy } from './tenancy.js';
|
|
22
25
|
/**
|
|
23
26
|
* Controls when model data is loaded from the server.
|
|
24
27
|
*
|
|
@@ -46,18 +49,15 @@ export interface PersistOptions {
|
|
|
46
49
|
store?: string;
|
|
47
50
|
}
|
|
48
51
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
52
|
+
* Declares a membership edge on a join model. See {@link ModelOptions.grants}
|
|
53
|
+
* for semantics and how the server membership resolver reads it. Both fields
|
|
54
|
+
* name `belongsTo` relations declared on the same model.
|
|
51
55
|
*/
|
|
52
|
-
export interface
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
/** Column on the parent that `localKey` references. Default: `'id'`. */
|
|
58
|
-
parentKey?: string;
|
|
59
|
-
/** Column on the parent holding the tenant id. Default: `'organization_id'`. */
|
|
60
|
-
parentOrgColumn?: string;
|
|
56
|
+
export interface GrantsRef {
|
|
57
|
+
/** Relation name pointing at the identity that gains access (e.g. `'user'`). */
|
|
58
|
+
subject: string;
|
|
59
|
+
/** Relation name pointing at the scope-root entity (e.g. `'dataroom'`). */
|
|
60
|
+
scope: string;
|
|
61
61
|
}
|
|
62
62
|
/** Options for model() */
|
|
63
63
|
export interface ModelOptions {
|
|
@@ -120,18 +120,64 @@ export interface ModelOptions {
|
|
|
120
120
|
*/
|
|
121
121
|
scopedVia?: ScopedViaRef;
|
|
122
122
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
|
|
123
|
+
* Override the physical tenancy column name (default `organization_id`).
|
|
124
|
+
* Authoring sugar — normalized into the canonical {@link tenancy} descriptor.
|
|
125
|
+
*/
|
|
126
|
+
orgColumn?: string;
|
|
127
|
+
/**
|
|
128
|
+
* Canonical tenancy descriptor. You normally don't set this — `orgScoped`,
|
|
129
|
+
* `scopedVia`, and `orgColumn` are normalized into it at build time. Set it
|
|
130
|
+
* directly only to author the union form explicitly.
|
|
131
|
+
*/
|
|
132
|
+
tenancy?: Tenancy;
|
|
133
|
+
/**
|
|
134
|
+
* Marks this model as a **scope root** — a model that forms a sync group of
|
|
135
|
+
* its own. A scope-root record lives in the group `<kind>:<id>`, where `kind`
|
|
136
|
+
* defaults to the lowercased `typename` (so a `Deck` → `deck:<id>`). Pass a
|
|
137
|
+
* string to override the kind explicitly (`scope: 'matter'`).
|
|
138
|
+
*
|
|
139
|
+
* Replaces the old `syncGroupFormat` template string: there is no `{id}`
|
|
140
|
+
* placeholder to author — the engine mints the branded {@link SyncGroup} from
|
|
141
|
+
* `(kind, id)`. Child models inherit a root's group via their `belongsTo`
|
|
142
|
+
* relations (a `document` with `dataroomId` fans into `dataroom:<id>`), so
|
|
143
|
+
* only the root declares anything.
|
|
144
|
+
*/
|
|
145
|
+
scope?: boolean | string;
|
|
146
|
+
/**
|
|
147
|
+
* Declares this model as a **membership edge** that grants an identity access
|
|
148
|
+
* to a scope root — the relation-driven equivalent of "this user can see that
|
|
149
|
+
* dataroom." Both values are *relation names* already declared on this model:
|
|
150
|
+
* `subject` is the `belongsTo` to the identity (e.g. a `user`), `scope` is the
|
|
151
|
+
* `belongsTo` to the scope-root entity (e.g. a `dataroom`).
|
|
152
|
+
*
|
|
153
|
+
* The server's membership resolver reads this at connect time — for identity
|
|
154
|
+
* `U`, it queries `WHERE <subject FK> = U` and adds `<scopeKind>:<scope FK>`
|
|
155
|
+
* to the identity's subscribed groups (Linear's `/sync/user_sync_groups`).
|
|
126
156
|
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
157
|
+
* ```ts
|
|
158
|
+
* // dataroomMember: { userId, dataroomId }
|
|
159
|
+
* grants: { subject: 'user', scope: 'dataroom' } // both are relation names
|
|
160
|
+
* ```
|
|
129
161
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* inside a `document` inside a `matter`).
|
|
162
|
+
* Not needed for org-level access — a `dataroom.organizationId` already routes
|
|
163
|
+
* via the `org:<id>` group. `grants` is only for sub-org membership.
|
|
133
164
|
*/
|
|
134
|
-
|
|
165
|
+
grants?: GrantsRef;
|
|
166
|
+
/**
|
|
167
|
+
* Explicit record→group roles for routing that isn't relational — a group
|
|
168
|
+
* keyed on a plain field rather than the record's own id or a `belongsTo`
|
|
169
|
+
* scope root. The escape hatch for cases like per-recipient inbox fan-out:
|
|
170
|
+
*
|
|
171
|
+
* ```ts
|
|
172
|
+
* // a message routes to its addressee's inbox, keyed on `toId`
|
|
173
|
+
* entityRoles: [entityRole({ kind: 'inbox', source: 'toId' })]
|
|
174
|
+
* ```
|
|
175
|
+
*
|
|
176
|
+
* Prefer {@link scope} (self group) + `belongsTo` relations (parent groups)
|
|
177
|
+
* when the routing follows the relation graph — reach for `entityRoles` only
|
|
178
|
+
* when it genuinely doesn't.
|
|
179
|
+
*/
|
|
180
|
+
entityRoles?: EntityRole | readonly EntityRole[];
|
|
135
181
|
/**
|
|
136
182
|
* Whether clients may issue CREATE/UPDATE/DELETE mutations for this
|
|
137
183
|
* model via the `commit` wire protocol. Default: false.
|
|
@@ -272,11 +318,15 @@ export interface ModelDef<Shape extends z.ZodRawShape = z.ZodRawShape, R extends
|
|
|
272
318
|
/** The actual database table name from Prisma @@map. See {@link ModelOptions.tableName}. */
|
|
273
319
|
readonly tableName?: string;
|
|
274
320
|
/** Whether the table has organization_id. See {@link ModelOptions.orgScoped}. */
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
readonly
|
|
278
|
-
/**
|
|
279
|
-
readonly
|
|
321
|
+
/** Canonical tenancy descriptor — the single source of truth, normalized from
|
|
322
|
+
* the `orgScoped`/`scopedVia`/`orgColumn` authoring sugar at build. */
|
|
323
|
+
readonly tenancy: Tenancy;
|
|
324
|
+
/** Scope-root marker. See {@link ModelOptions.scope}. */
|
|
325
|
+
readonly scope?: boolean | string;
|
|
326
|
+
/** Membership edge granting identity → scope-root access. See {@link ModelOptions.grants}. */
|
|
327
|
+
readonly grants?: GrantsRef;
|
|
328
|
+
/** Explicit non-relational record→group roles (normalized to an array). See {@link ModelOptions.entityRoles}. */
|
|
329
|
+
readonly entityRoles?: readonly EntityRole[];
|
|
280
330
|
/** Whether wire-level CREATE/UPDATE/DELETE is allowed. See {@link ModelOptions.mutable}. */
|
|
281
331
|
readonly mutable?: boolean;
|
|
282
332
|
/** Defer MobX setup until first observer access. See {@link ModelOptions.lazyObservable}. */
|
|
@@ -324,3 +374,15 @@ export interface ModelDef<Shape extends z.ZodRawShape = z.ZodRawShape, R extends
|
|
|
324
374
|
export declare function model<Shape extends z.ZodRawShape, R extends RelationRecord = Record<string, never>, C extends ComputedRecord = Record<string, never>>(shape: Shape, relations?: R, options?: ModelOptions & {
|
|
325
375
|
computed?: C;
|
|
326
376
|
}): ModelDef<Shape, R, C>;
|
|
377
|
+
/**
|
|
378
|
+
* The sync-group kind a scope-root model mints, or `undefined` when the model
|
|
379
|
+
* isn't a scope root. `scope: true` derives the kind from the lowercased
|
|
380
|
+
* typename (`SlideDeck` → `slidedeck`); `scope: 'deck'` sets it explicitly
|
|
381
|
+
* (the form to use when the wire kind must differ from the typename). One place
|
|
382
|
+
* so the commit path, the membership resolver, and the participant join-side
|
|
383
|
+
* all agree on what a record's own group is.
|
|
384
|
+
*/
|
|
385
|
+
export declare function scopeKindOf(def: {
|
|
386
|
+
scope?: boolean | string;
|
|
387
|
+
typename?: string;
|
|
388
|
+
}, fallbackKey: string): string | undefined;
|
package/dist/schema/model.js
CHANGED
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { z } from 'zod';
|
|
20
20
|
import { getFieldMeta, inferFieldMetaFromZod } from './field.js';
|
|
21
|
+
// Tenancy is owned by `tenancy.ts` (single source of truth). `ScopedViaRef` is
|
|
22
|
+
// re-exported so existing `import { ScopedViaRef } from './model'` call sites
|
|
23
|
+
// keep resolving while consumers migrate.
|
|
24
|
+
import { resolveTenancy } from './tenancy.js';
|
|
25
|
+
/** Normalize the `entityRoles` option (single | array | undefined) to an array. */
|
|
26
|
+
function normalizeEntityRoles(input) {
|
|
27
|
+
if (!input)
|
|
28
|
+
return undefined;
|
|
29
|
+
return Array.isArray(input) ? input : [input];
|
|
30
|
+
}
|
|
21
31
|
// ── Model factory ─────────────────────────────────────────────────────────
|
|
22
32
|
/**
|
|
23
33
|
* Define a model with a Zod shape and optional relations.
|
|
@@ -77,9 +87,16 @@ export function model(shape, relations, options) {
|
|
|
77
87
|
typename: options?.typename,
|
|
78
88
|
persist: options?.persist,
|
|
79
89
|
tableName: options?.tableName,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
// Normalize all tenancy authoring sugar into the one canonical descriptor.
|
|
91
|
+
tenancy: resolveTenancy({
|
|
92
|
+
tenancy: options?.tenancy,
|
|
93
|
+
orgScoped: options?.orgScoped,
|
|
94
|
+
scopedVia: options?.scopedVia,
|
|
95
|
+
orgColumn: options?.orgColumn,
|
|
96
|
+
}),
|
|
97
|
+
scope: options?.scope,
|
|
98
|
+
grants: options?.grants,
|
|
99
|
+
entityRoles: normalizeEntityRoles(options?.entityRoles),
|
|
83
100
|
mutable: options?.mutable,
|
|
84
101
|
lazyObservable: options?.lazyObservable,
|
|
85
102
|
computed: options?.computed,
|
|
@@ -87,3 +104,16 @@ export function model(shape, relations, options) {
|
|
|
87
104
|
requiredFields: options?.requiredFields,
|
|
88
105
|
};
|
|
89
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The sync-group kind a scope-root model mints, or `undefined` when the model
|
|
109
|
+
* isn't a scope root. `scope: true` derives the kind from the lowercased
|
|
110
|
+
* typename (`SlideDeck` → `slidedeck`); `scope: 'deck'` sets it explicitly
|
|
111
|
+
* (the form to use when the wire kind must differ from the typename). One place
|
|
112
|
+
* so the commit path, the membership resolver, and the participant join-side
|
|
113
|
+
* all agree on what a record's own group is.
|
|
114
|
+
*/
|
|
115
|
+
export function scopeKindOf(def, fallbackKey) {
|
|
116
|
+
if (!def.scope)
|
|
117
|
+
return undefined;
|
|
118
|
+
return (typeof def.scope === 'string' ? def.scope : (def.typename ?? fallbackKey)).toLowerCase();
|
|
119
|
+
}
|
|
@@ -62,6 +62,23 @@ export interface BelongsToOptions {
|
|
|
62
62
|
readonly index?: boolean;
|
|
63
63
|
readonly enrich?: boolean;
|
|
64
64
|
readonly defer?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Marks the relation's target as this record's **parent** in the Zanzibar/
|
|
67
|
+
* ReBAC sense — the entity it lives in, that scope inherits *from*. Sync-group
|
|
68
|
+
* fan-out routes a record into its parent's group (directly, or transitively
|
|
69
|
+
* up a chain of `parent` edges), so a write reaches everyone subscribed to the
|
|
70
|
+
* owning entity — the same "access inherits from parent" rule OpenFGA/Zanzibar
|
|
71
|
+
* and filesystems use.
|
|
72
|
+
*
|
|
73
|
+
* A reference (provenance/template pointer like `sourceSlideId`, `templateId`)
|
|
74
|
+
* must NOT set this, or the record would leak into an unrelated scope.
|
|
75
|
+
* Optionality is NOT a proxy — many parent FKs are optional (a root folder, an
|
|
76
|
+
* inbox task) — so the parent edge must be declared, not inferred.
|
|
77
|
+
*
|
|
78
|
+
* Reads on the relation: `belongsTo('deck', 'deckId', { parent: true })` —
|
|
79
|
+
* "the deck is the parent."
|
|
80
|
+
*/
|
|
81
|
+
readonly parent?: boolean;
|
|
65
82
|
}
|
|
66
83
|
declare const __relationType: unique symbol;
|
|
67
84
|
declare const __relationTarget: unique symbol;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Sync-group roles — the fan-out vocabulary, as typed data.
|
|
4
|
+
*
|
|
5
|
+
* A *sync group* is the unit the broadcast layer routes deltas on. On the wire
|
|
6
|
+
* it's a `kind:id` string, but that serialization is owned entirely by this
|
|
7
|
+
* module — callers never hand-write `'org:abc'`. Instead they declare a typed
|
|
8
|
+
* {@link Role} (`kind` + which field supplies the id) and the engine mints the
|
|
9
|
+
* branded {@link SyncGroup} string via {@link syncGroup}.
|
|
10
|
+
*
|
|
11
|
+
* Two reading directions, one shape:
|
|
12
|
+
*
|
|
13
|
+
* • {@link IdentityRole} — "which groups may this *participant* subscribe to?"
|
|
14
|
+
* Reads fields off an identity context (`organizationId`, `teamIds`).
|
|
15
|
+
*
|
|
16
|
+
* • {@link EntityRole} — "which groups does this *record* live in?" Reads
|
|
17
|
+
* fields off the record itself (`id`, `deckId`), so the server can fan a
|
|
18
|
+
* committed delta to the right entity streams regardless of what the
|
|
19
|
+
* committer was subscribed to.
|
|
20
|
+
*
|
|
21
|
+
* Roles are pure data (no closures) so a `Schema` round-trips through the
|
|
22
|
+
* control plane and the reconstructed copy behaves identically.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* The branded wire form of a sync group: `${kind}:${id}`. Branded so a raw
|
|
26
|
+
* string can't masquerade as one — the only way to produce a `SyncGroup` is
|
|
27
|
+
* {@link syncGroup}. Because the brand is an intersection it's still assignable
|
|
28
|
+
* *to* `string`, so existing `string[]` plumbing keeps working unchanged.
|
|
29
|
+
*/
|
|
30
|
+
export declare const syncGroupSchema: z.core.$ZodBranded<z.ZodTemplateLiteral<`${string}:${string}`>, "SyncGroup", "out">;
|
|
31
|
+
export type SyncGroup = z.infer<typeof syncGroupSchema>;
|
|
32
|
+
/**
|
|
33
|
+
* Mint a sync-group string. The single place the `kind:id` convention lives —
|
|
34
|
+
* if the wire format ever changes (structured columns, a different separator),
|
|
35
|
+
* it changes here and nowhere else.
|
|
36
|
+
*/
|
|
37
|
+
export declare function syncGroup(kind: string, id: string): SyncGroup;
|
|
38
|
+
/** Validates how a role pulls ids out of a context (identity or record). */
|
|
39
|
+
export declare const roleSourceSchema: z.ZodObject<{
|
|
40
|
+
field: z.ZodString;
|
|
41
|
+
multi: z.ZodBoolean;
|
|
42
|
+
}, z.core.$strip>;
|
|
43
|
+
export type RoleSource = z.infer<typeof roleSourceSchema>;
|
|
44
|
+
/** Back-compat alias — historical name for {@link RoleSource}. */
|
|
45
|
+
export type IdentityRoleSource = RoleSource;
|
|
46
|
+
/** Record-side name for {@link RoleSource}. */
|
|
47
|
+
export type EntityRoleSource = RoleSource;
|
|
48
|
+
/** Free-form context a role reads from. */
|
|
49
|
+
export type RoleContext = Record<string, unknown>;
|
|
50
|
+
/** The identity shape an {@link IdentityRole} reads from. */
|
|
51
|
+
export type IdentityContext = RoleContext;
|
|
52
|
+
/** The record shape an {@link EntityRole} reads from. */
|
|
53
|
+
export type EntityContext = RoleContext;
|
|
54
|
+
/**
|
|
55
|
+
* A sync-group role: a typed `kind` plus the field that supplies the id. The
|
|
56
|
+
* wire string is `${kind}:${id}`, built by the engine — there is deliberately
|
|
57
|
+
* no template/placeholder for the author to get wrong.
|
|
58
|
+
*/
|
|
59
|
+
export declare const roleSchema: z.ZodObject<{
|
|
60
|
+
kind: z.ZodString;
|
|
61
|
+
source: z.ZodObject<{
|
|
62
|
+
field: z.ZodString;
|
|
63
|
+
multi: z.ZodBoolean;
|
|
64
|
+
}, z.core.$strip>;
|
|
65
|
+
}, z.core.$strip>;
|
|
66
|
+
export type Role = z.infer<typeof roleSchema>;
|
|
67
|
+
/**
|
|
68
|
+
* Identity-anchored role. Reads an identity field; `kind` names the group.
|
|
69
|
+
*
|
|
70
|
+
* ```ts
|
|
71
|
+
* identityRole({ kind: 'org', source: 'organizationId' })
|
|
72
|
+
* identityRole({ kind: 'team', source: 'teamIds', multi: true })
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export type IdentityRole = Role;
|
|
76
|
+
/**
|
|
77
|
+
* Record-anchored role. Reads a record field; `kind` names the group. A record
|
|
78
|
+
* can route to a group keyed by its own `id` *or* a foreign key like `deckId`.
|
|
79
|
+
*
|
|
80
|
+
* ```ts
|
|
81
|
+
* entityRole({ kind: 'deck', source: 'id' }) // a deck → deck:<id>
|
|
82
|
+
* entityRole({ kind: 'deck', source: 'deckId' }) // a layer → its parent deck
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export type EntityRole = Role;
|
|
86
|
+
/** Validates an {@link IdentityRole}. */
|
|
87
|
+
export declare const identityRoleSchema: z.ZodType<IdentityRole>;
|
|
88
|
+
/** Validates an {@link EntityRole}. */
|
|
89
|
+
export declare const entityRoleSchema: z.ZodType<EntityRole>;
|
|
90
|
+
/**
|
|
91
|
+
* Validates a model's `scope` declaration: `true` (kind = typename) or an
|
|
92
|
+
* explicit lowercase kind string. The same vocabulary the roles use, so the
|
|
93
|
+
* whole sync-group declaration surface is Zod-validated, not hand-checked.
|
|
94
|
+
*/
|
|
95
|
+
export declare const scopeSchema: z.ZodUnion<readonly [z.ZodBoolean, z.ZodString]>;
|
|
96
|
+
/**
|
|
97
|
+
* Validates a model's `grants` membership edge. Both values are relation names
|
|
98
|
+
* declared on the same model (`subject` → identity, `scope` → scope root); that
|
|
99
|
+
* the relations actually exist + are `belongsTo` is a cross-field check done in
|
|
100
|
+
* `defineSchema` where the relation map is in scope.
|
|
101
|
+
*/
|
|
102
|
+
export declare const grantsRefSchema: z.ZodObject<{
|
|
103
|
+
subject: z.ZodString;
|
|
104
|
+
scope: z.ZodString;
|
|
105
|
+
}, z.core.$strip>;
|
|
106
|
+
/** Build an identity-anchored role. `multi` defaults to `false`. */
|
|
107
|
+
export declare function identityRole(spec: {
|
|
108
|
+
readonly kind: string;
|
|
109
|
+
/** Identity-context field to read. See {@link RoleSource.field}. */
|
|
110
|
+
readonly source: string;
|
|
111
|
+
/** Treat the field as an array of ids. See {@link RoleSource.multi}. */
|
|
112
|
+
readonly multi?: boolean;
|
|
113
|
+
}): IdentityRole;
|
|
114
|
+
/** Build a record-anchored role. `multi` defaults to `false`. */
|
|
115
|
+
export declare function entityRole(spec: {
|
|
116
|
+
readonly kind: string;
|
|
117
|
+
/** Record field to read. See {@link RoleSource.field}. */
|
|
118
|
+
readonly source: string;
|
|
119
|
+
/** Treat the field as an array of ids. See {@link RoleSource.multi}. */
|
|
120
|
+
readonly multi?: boolean;
|
|
121
|
+
}): EntityRole;
|
|
122
|
+
/**
|
|
123
|
+
* Evaluate a {@link RoleSource} against a context. Absent or falsy fields yield
|
|
124
|
+
* `[]`, so a role whose field isn't present (a user with no `teamIds`, a record
|
|
125
|
+
* with no `deckId`) is a silent no-op.
|
|
126
|
+
*/
|
|
127
|
+
export declare function extractRoleIds(context: RoleContext, source: RoleSource): readonly string[];
|
|
128
|
+
/** Identity-side name for {@link extractRoleIds}. */
|
|
129
|
+
export declare const extractIdentityIds: typeof extractRoleIds;
|
|
130
|
+
/** Record-side name for {@link extractRoleIds}. */
|
|
131
|
+
export declare const extractEntityIds: typeof extractRoleIds;
|
|
132
|
+
/**
|
|
133
|
+
* Compose the sync groups an identity may subscribe to, from the schema's
|
|
134
|
+
* registered {@link IdentityRole}s. Returns `[]` when no role produces an id;
|
|
135
|
+
* the caller treats `[]` as "no scope", not "match everything".
|
|
136
|
+
*/
|
|
137
|
+
export declare function composeIdentitySyncGroups(identity: IdentityContext, schema: {
|
|
138
|
+
readonly identityRoles: readonly IdentityRole[];
|
|
139
|
+
}): readonly SyncGroup[];
|
|
140
|
+
/**
|
|
141
|
+
* Compose the sync groups a record belongs to, from the model's registered
|
|
142
|
+
* {@link EntityRole}s. Mirror of {@link composeIdentitySyncGroups}, reading the
|
|
143
|
+
* record instead of an identity. Returns `[]` when the model has no entity
|
|
144
|
+
* roles (the delta then fans on its base `org:`/`user:` groups only).
|
|
145
|
+
*/
|
|
146
|
+
export declare function composeEntitySyncGroups(record: EntityContext, def: {
|
|
147
|
+
readonly entityRoles?: readonly EntityRole[];
|
|
148
|
+
}): readonly SyncGroup[];
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Sync-group roles — the fan-out vocabulary, as typed data.
|
|
4
|
+
*
|
|
5
|
+
* A *sync group* is the unit the broadcast layer routes deltas on. On the wire
|
|
6
|
+
* it's a `kind:id` string, but that serialization is owned entirely by this
|
|
7
|
+
* module — callers never hand-write `'org:abc'`. Instead they declare a typed
|
|
8
|
+
* {@link Role} (`kind` + which field supplies the id) and the engine mints the
|
|
9
|
+
* branded {@link SyncGroup} string via {@link syncGroup}.
|
|
10
|
+
*
|
|
11
|
+
* Two reading directions, one shape:
|
|
12
|
+
*
|
|
13
|
+
* • {@link IdentityRole} — "which groups may this *participant* subscribe to?"
|
|
14
|
+
* Reads fields off an identity context (`organizationId`, `teamIds`).
|
|
15
|
+
*
|
|
16
|
+
* • {@link EntityRole} — "which groups does this *record* live in?" Reads
|
|
17
|
+
* fields off the record itself (`id`, `deckId`), so the server can fan a
|
|
18
|
+
* committed delta to the right entity streams regardless of what the
|
|
19
|
+
* committer was subscribed to.
|
|
20
|
+
*
|
|
21
|
+
* Roles are pure data (no closures) so a `Schema` round-trips through the
|
|
22
|
+
* control plane and the reconstructed copy behaves identically.
|
|
23
|
+
*/
|
|
24
|
+
// ── Sync-group wire form (branded) ──────────────────────────────────────────
|
|
25
|
+
/**
|
|
26
|
+
* The branded wire form of a sync group: `${kind}:${id}`. Branded so a raw
|
|
27
|
+
* string can't masquerade as one — the only way to produce a `SyncGroup` is
|
|
28
|
+
* {@link syncGroup}. Because the brand is an intersection it's still assignable
|
|
29
|
+
* *to* `string`, so existing `string[]` plumbing keeps working unchanged.
|
|
30
|
+
*/
|
|
31
|
+
export const syncGroupSchema = z
|
|
32
|
+
.templateLiteral([z.string().regex(/^[a-z][a-z0-9_]*$/), ':', z.string().min(1)])
|
|
33
|
+
.brand();
|
|
34
|
+
/**
|
|
35
|
+
* Mint a sync-group string. The single place the `kind:id` convention lives —
|
|
36
|
+
* if the wire format ever changes (structured columns, a different separator),
|
|
37
|
+
* it changes here and nowhere else.
|
|
38
|
+
*/
|
|
39
|
+
export function syncGroup(kind, id) {
|
|
40
|
+
return `${kind}:${id}`;
|
|
41
|
+
}
|
|
42
|
+
// ── Role source ─────────────────────────────────────────────────────────────
|
|
43
|
+
/** Validates how a role pulls ids out of a context (identity or record). */
|
|
44
|
+
export const roleSourceSchema = z.object({
|
|
45
|
+
/** The context field to read, e.g. `'organizationId'`, `'id'`, `'deckId'`. */
|
|
46
|
+
field: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, 'source must be a valid identifier'),
|
|
47
|
+
/**
|
|
48
|
+
* When `true`, `field` holds an array; every non-empty string element yields
|
|
49
|
+
* one group. When `false` (default), `field` is a scalar; truthy → one group.
|
|
50
|
+
*/
|
|
51
|
+
multi: z.boolean(),
|
|
52
|
+
});
|
|
53
|
+
// ── Role (kind + source — no template string) ───────────────────────────────
|
|
54
|
+
/**
|
|
55
|
+
* A sync-group role: a typed `kind` plus the field that supplies the id. The
|
|
56
|
+
* wire string is `${kind}:${id}`, built by the engine — there is deliberately
|
|
57
|
+
* no template/placeholder for the author to get wrong.
|
|
58
|
+
*/
|
|
59
|
+
export const roleSchema = z.object({
|
|
60
|
+
kind: z.string().regex(/^[a-z][a-z0-9_]*$/, 'kind must be a lowercase identifier, e.g. "deck"'),
|
|
61
|
+
source: roleSourceSchema,
|
|
62
|
+
});
|
|
63
|
+
/** Validates an {@link IdentityRole}. */
|
|
64
|
+
export const identityRoleSchema = roleSchema;
|
|
65
|
+
/** Validates an {@link EntityRole}. */
|
|
66
|
+
export const entityRoleSchema = roleSchema;
|
|
67
|
+
/**
|
|
68
|
+
* Validates a model's `scope` declaration: `true` (kind = typename) or an
|
|
69
|
+
* explicit lowercase kind string. The same vocabulary the roles use, so the
|
|
70
|
+
* whole sync-group declaration surface is Zod-validated, not hand-checked.
|
|
71
|
+
*/
|
|
72
|
+
export const scopeSchema = z.union([
|
|
73
|
+
z.boolean(),
|
|
74
|
+
z.string().regex(/^[a-z][a-z0-9_]*$/, 'scope kind must be a lowercase identifier, e.g. "dataroom"'),
|
|
75
|
+
]);
|
|
76
|
+
/**
|
|
77
|
+
* Validates a model's `grants` membership edge. Both values are relation names
|
|
78
|
+
* declared on the same model (`subject` → identity, `scope` → scope root); that
|
|
79
|
+
* the relations actually exist + are `belongsTo` is a cross-field check done in
|
|
80
|
+
* `defineSchema` where the relation map is in scope.
|
|
81
|
+
*/
|
|
82
|
+
export const grantsRefSchema = z.object({
|
|
83
|
+
subject: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, 'grants.subject must name a relation'),
|
|
84
|
+
scope: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, 'grants.scope must name a relation'),
|
|
85
|
+
});
|
|
86
|
+
// ── Factories ───────────────────────────────────────────────────────────────
|
|
87
|
+
function makeRole(spec) {
|
|
88
|
+
return { kind: spec.kind, source: { field: spec.source, multi: spec.multi ?? false } };
|
|
89
|
+
}
|
|
90
|
+
/** Build an identity-anchored role. `multi` defaults to `false`. */
|
|
91
|
+
export function identityRole(spec) {
|
|
92
|
+
return makeRole(spec);
|
|
93
|
+
}
|
|
94
|
+
/** Build a record-anchored role. `multi` defaults to `false`. */
|
|
95
|
+
export function entityRole(spec) {
|
|
96
|
+
return makeRole(spec);
|
|
97
|
+
}
|
|
98
|
+
// ── Evaluation ──────────────────────────────────────────────────────────────
|
|
99
|
+
/**
|
|
100
|
+
* Evaluate a {@link RoleSource} against a context. Absent or falsy fields yield
|
|
101
|
+
* `[]`, so a role whose field isn't present (a user with no `teamIds`, a record
|
|
102
|
+
* with no `deckId`) is a silent no-op.
|
|
103
|
+
*/
|
|
104
|
+
export function extractRoleIds(context, source) {
|
|
105
|
+
const raw = context[source.field];
|
|
106
|
+
if (source.multi) {
|
|
107
|
+
return Array.isArray(raw)
|
|
108
|
+
? raw.filter((t) => typeof t === 'string' && t.length > 0)
|
|
109
|
+
: [];
|
|
110
|
+
}
|
|
111
|
+
return raw ? [String(raw)] : [];
|
|
112
|
+
}
|
|
113
|
+
/** Identity-side name for {@link extractRoleIds}. */
|
|
114
|
+
export const extractIdentityIds = extractRoleIds;
|
|
115
|
+
/** Record-side name for {@link extractRoleIds}. */
|
|
116
|
+
export const extractEntityIds = extractRoleIds;
|
|
117
|
+
/**
|
|
118
|
+
* Compose the sync groups an identity may subscribe to, from the schema's
|
|
119
|
+
* registered {@link IdentityRole}s. Returns `[]` when no role produces an id;
|
|
120
|
+
* the caller treats `[]` as "no scope", not "match everything".
|
|
121
|
+
*/
|
|
122
|
+
export function composeIdentitySyncGroups(identity, schema) {
|
|
123
|
+
const out = new Set();
|
|
124
|
+
for (const role of schema.identityRoles) {
|
|
125
|
+
for (const id of extractRoleIds(identity, role.source)) {
|
|
126
|
+
if (id)
|
|
127
|
+
out.add(syncGroup(role.kind, id));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return Array.from(out);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Compose the sync groups a record belongs to, from the model's registered
|
|
134
|
+
* {@link EntityRole}s. Mirror of {@link composeIdentitySyncGroups}, reading the
|
|
135
|
+
* record instead of an identity. Returns `[]` when the model has no entity
|
|
136
|
+
* roles (the delta then fans on its base `org:`/`user:` groups only).
|
|
137
|
+
*/
|
|
138
|
+
export function composeEntitySyncGroups(record, def) {
|
|
139
|
+
if (!def.entityRoles?.length)
|
|
140
|
+
return [];
|
|
141
|
+
const out = new Set();
|
|
142
|
+
for (const role of def.entityRoles) {
|
|
143
|
+
for (const id of extractRoleIds(record, role.source)) {
|
|
144
|
+
if (id)
|
|
145
|
+
out.add(syncGroup(role.kind, id));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return Array.from(out);
|
|
149
|
+
}
|
package/dist/schema/schema.d.ts
CHANGED
|
@@ -22,105 +22,14 @@
|
|
|
22
22
|
import { z } from 'zod';
|
|
23
23
|
import type { ModelDef, RelationRecord } from './model.js';
|
|
24
24
|
import type { RelationDef } from './relation.js';
|
|
25
|
+
import type { IdentityRole } from './roles.js';
|
|
26
|
+
export { type IdentityRole, type IdentityRoleSource, type IdentityContext, type EntityRole, type EntityRoleSource, type EntityContext, type RoleSource, type RoleContext, type SyncGroup, identityRole, entityRole, extractIdentityIds, extractEntityIds, composeIdentitySyncGroups, composeEntitySyncGroups, syncGroup, syncGroupSchema, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './roles.js';
|
|
25
27
|
/** The set of built-in casing conventions supported by `defineSchema`. */
|
|
26
28
|
export type CasingConvention = 'snake_case' | 'camelCase';
|
|
27
29
|
/** Plug point for custom conventions (e.g. mixed legacy databases). */
|
|
28
30
|
export type CasingFn = (jsField: string) => string;
|
|
29
31
|
/** `defineSchema`'s casing option. Identity when unset. */
|
|
30
32
|
export type Casing = CasingConvention | CasingFn;
|
|
31
|
-
/**
|
|
32
|
-
* The identity shape an {@link IdentityRole} reads from. Free-form
|
|
33
|
-
* `Record<string, unknown>` because each schema chooses what fields
|
|
34
|
-
* its roles read — Ablo's roles read `organizationId`/`userId`/`teamIds`;
|
|
35
|
-
* a tenant model with `regionId`/`customerId` is equally valid. The
|
|
36
|
-
* server hands its resolved identity (whatever shape its AuthProvider
|
|
37
|
-
* returns) straight to {@link extractIdentityIds} without translation.
|
|
38
|
-
*/
|
|
39
|
-
export type IdentityContext = Record<string, unknown>;
|
|
40
|
-
/**
|
|
41
|
-
* Open registration of an identity-anchored sync-group. Each role is
|
|
42
|
-
* pure data: (a) a free-form `kind` label (diagnostics only), (b) a
|
|
43
|
-
* `template` with a single `{id}` placeholder, and (c) a `source`
|
|
44
|
-
* declaring which identity field to read and how.
|
|
45
|
-
*
|
|
46
|
-
* Pure data on purpose — no closures. A `Schema` is then JSON-serializable
|
|
47
|
-
* end to end, so the same schema object works in-process AND after being
|
|
48
|
-
* sent to a hosted server over the control plane.
|
|
49
|
-
*
|
|
50
|
-
* No closed enum. A consumer whose identity shape is
|
|
51
|
-
* `{ regionId, customerId }` registers:
|
|
52
|
-
*
|
|
53
|
-
* ```ts
|
|
54
|
-
* defineSchema({ ... }, {
|
|
55
|
-
* identityRoles: [
|
|
56
|
-
* identityRole({ kind: 'region', template: 'region:{id}', source: 'regionId' }),
|
|
57
|
-
* identityRole({ kind: 'customer', template: 'customer:{id}', source: 'customerId' }),
|
|
58
|
-
* ],
|
|
59
|
-
* });
|
|
60
|
-
* ```
|
|
61
|
-
*
|
|
62
|
-
* {@link composeIdentitySyncGroups} walks every registered role and
|
|
63
|
-
* produces the union — no hardcoded `org:` / `user:` / `team:` anywhere in
|
|
64
|
-
* the engine.
|
|
65
|
-
*/
|
|
66
|
-
export interface IdentityRole {
|
|
67
|
-
/** Free-form label for diagnostics/logging. Not parsed. */
|
|
68
|
-
readonly kind: string;
|
|
69
|
-
/**
|
|
70
|
-
* Sync-group template with a single `{id}` placeholder. Substituted
|
|
71
|
-
* once per id produced from `source`. Example: `'org:{id}'` →
|
|
72
|
-
* `'org:abc-123'`.
|
|
73
|
-
*/
|
|
74
|
-
readonly template: string;
|
|
75
|
-
/** Which identity field to read, and whether it's an array. */
|
|
76
|
-
readonly source: IdentityRoleSource;
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Declarative, JSON-serializable description of how an {@link IdentityRole}
|
|
80
|
-
* pulls ids out of an identity context.
|
|
81
|
-
*/
|
|
82
|
-
export interface IdentityRoleSource {
|
|
83
|
-
/**
|
|
84
|
-
* The identity-context field to read, e.g. `'organizationId'` or
|
|
85
|
-
* `'teamIds'`. The value is coerced per {@link multi}.
|
|
86
|
-
*/
|
|
87
|
-
readonly field: string;
|
|
88
|
-
/**
|
|
89
|
-
* When `true`, `field` holds an array; every non-empty string element
|
|
90
|
-
* yields one sync group. When `false` (default), `field` holds a single
|
|
91
|
-
* scalar; a truthy value yields exactly one group, falsy yields none.
|
|
92
|
-
*/
|
|
93
|
-
readonly multi: boolean;
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Evaluate an {@link IdentityRoleSource} against an identity context.
|
|
97
|
-
* The whole runtime behaviour of a role lives here, as data-driven logic —
|
|
98
|
-
* `composeIdentitySyncGroups` calls this once per role. Absent or falsy
|
|
99
|
-
* fields yield `[]`, so a role whose field isn't present (e.g. a user with
|
|
100
|
-
* no `teamIds`) is a silent no-op.
|
|
101
|
-
*/
|
|
102
|
-
export declare function extractIdentityIds(identity: IdentityContext, source: IdentityRoleSource): readonly string[];
|
|
103
|
-
/**
|
|
104
|
-
* Build an identity-anchored sync-group role. A thin, validated factory
|
|
105
|
-
* over the {@link IdentityRole} data shape — `multi` defaults to `false`.
|
|
106
|
-
*
|
|
107
|
-
* ```ts
|
|
108
|
-
* defineSchema({ ... }, {
|
|
109
|
-
* identityRoles: [
|
|
110
|
-
* identityRole({ kind: 'tenant', template: 'org:{id}', source: 'organizationId' }),
|
|
111
|
-
* identityRole({ kind: 'member', template: 'team:{id}', source: 'teamIds', multi: true }),
|
|
112
|
-
* ],
|
|
113
|
-
* });
|
|
114
|
-
* ```
|
|
115
|
-
*/
|
|
116
|
-
export declare function identityRole(spec: {
|
|
117
|
-
readonly kind: string;
|
|
118
|
-
readonly template: string;
|
|
119
|
-
/** Identity-context field to read. See {@link IdentityRoleSource.field}. */
|
|
120
|
-
readonly source: string;
|
|
121
|
-
/** Treat the field as an array of ids. See {@link IdentityRoleSource.multi}. */
|
|
122
|
-
readonly multi?: boolean;
|
|
123
|
-
}): IdentityRole;
|
|
124
33
|
/** Options for `defineSchema`. */
|
|
125
34
|
export interface DefineSchemaOptions {
|
|
126
35
|
/**
|
|
@@ -289,22 +198,3 @@ export type DeleteId<S extends Schema, ModelName extends keyof S['models']> = {
|
|
|
289
198
|
id: string;
|
|
290
199
|
};
|
|
291
200
|
export declare function defineSchema<const S extends SchemaRecord>(models: S, options?: DefineSchemaOptions): Schema<S>;
|
|
292
|
-
/**
|
|
293
|
-
* Compose the canonical sync-group set this identity is allowed to
|
|
294
|
-
* subscribe to, derived purely from the schema's registered
|
|
295
|
-
* {@link IdentityRole}s. Walks every role, evaluates its `source` against
|
|
296
|
-
* the identity context via {@link extractIdentityIds}, and substitutes each
|
|
297
|
-
* id into the role's `template`. Output is stable, deduped, and never
|
|
298
|
-
* includes a literal convention string from this function itself — the
|
|
299
|
-
* convention lives 100% in the consumer's schema declaration.
|
|
300
|
-
*
|
|
301
|
-
* Reads only data off the schema (`identityRoles` is pure data), so it works
|
|
302
|
-
* identically on an in-process `Schema` and one reconstructed from JSON on a
|
|
303
|
-
* hosted server.
|
|
304
|
-
*
|
|
305
|
-
* Returns `[]` when the schema has no identity roles registered or when no
|
|
306
|
-
* role's source produces an id. Caller decides what to do with `[]`; the
|
|
307
|
-
* server's intersect-with-requested logic treats it as "no scope" rather
|
|
308
|
-
* than "match everything."
|
|
309
|
-
*/
|
|
310
|
-
export declare function composeIdentitySyncGroups(identity: IdentityContext, schema: Pick<Schema, 'identityRoles'>): readonly string[];
|