@abloatai/ablo 0.6.0 → 0.7.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 +45 -0
- package/README.md +64 -35
- package/dist/BaseSyncedStore.d.ts +1 -1
- package/dist/BaseSyncedStore.js +1 -1
- package/dist/client/Ablo.d.ts +1 -0
- package/dist/client/Ablo.js +1 -0
- package/dist/client/createModelProxy.d.ts +26 -3
- package/dist/client/createModelProxy.js +4 -1
- package/dist/client/validateAbloOptions.js +2 -2
- 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 +264 -0
- package/dist/errorCodes.js +251 -0
- package/dist/errors.d.ts +51 -6
- package/dist/errors.js +56 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- 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/react/AbloProvider.d.ts +12 -0
- package/dist/react/AbloProvider.js +11 -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 +13 -9
- package/dist/schema/serialize.js +14 -10
- 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/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +23 -17
- package/dist/sync/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +46 -1
- package/dist/sync/participants.js +5 -14
- package/dist/types/streams.d.ts +53 -33
- package/docs/api-keys.md +44 -0
- package/docs/api.md +11 -22
- package/docs/cli.md +212 -0
- package/docs/client-behavior.md +1 -1
- package/docs/coordination.md +61 -12
- package/docs/data-sources.md +2 -2
- package/docs/examples/existing-python-backend.md +3 -3
- package/docs/examples/scoped-agent.md +78 -0
- package/docs/guarantees.md +5 -2
- package/docs/identity.md +139 -68
- package/docs/index.md +6 -0
- package/docs/integration-guide.md +31 -35
- package/docs/interaction-model.md +3 -0
- package/docs/react.md +3 -3
- package/docs/roadmap.md +14 -2
- package/package.json +8 -1
|
@@ -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[];
|
package/dist/schema/schema.js
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { z } from 'zod';
|
|
23
23
|
import { AbloValidationError } from '../errors.js';
|
|
24
|
+
import { scopeSchema, grantsRefSchema } from './roles.js';
|
|
25
|
+
// Sync-group roles (identity + entity) live in `./roles.js`. Re-exported here
|
|
26
|
+
// so the long-standing `@ablo/schema` / `./schema.js` import paths keep working
|
|
27
|
+
// after the rehome — see roles.ts for the full vocabulary.
|
|
28
|
+
export { identityRole, entityRole, extractIdentityIds, extractEntityIds, composeIdentitySyncGroups, composeEntitySyncGroups, syncGroup, syncGroupSchema, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './roles.js';
|
|
24
29
|
function resolveCasing(fn) {
|
|
25
30
|
if (fn === undefined)
|
|
26
31
|
return (x) => x;
|
|
@@ -39,42 +44,6 @@ function resolveCasing(fn) {
|
|
|
39
44
|
function camelToSnake(identifier) {
|
|
40
45
|
return identifier.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
|
|
41
46
|
}
|
|
42
|
-
/**
|
|
43
|
-
* Evaluate an {@link IdentityRoleSource} against an identity context.
|
|
44
|
-
* The whole runtime behaviour of a role lives here, as data-driven logic —
|
|
45
|
-
* `composeIdentitySyncGroups` calls this once per role. Absent or falsy
|
|
46
|
-
* fields yield `[]`, so a role whose field isn't present (e.g. a user with
|
|
47
|
-
* no `teamIds`) is a silent no-op.
|
|
48
|
-
*/
|
|
49
|
-
export function extractIdentityIds(identity, source) {
|
|
50
|
-
const raw = identity[source.field];
|
|
51
|
-
if (source.multi) {
|
|
52
|
-
return Array.isArray(raw)
|
|
53
|
-
? raw.filter((t) => typeof t === 'string' && t.length > 0)
|
|
54
|
-
: [];
|
|
55
|
-
}
|
|
56
|
-
return raw ? [String(raw)] : [];
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Build an identity-anchored sync-group role. A thin, validated factory
|
|
60
|
-
* over the {@link IdentityRole} data shape — `multi` defaults to `false`.
|
|
61
|
-
*
|
|
62
|
-
* ```ts
|
|
63
|
-
* defineSchema({ ... }, {
|
|
64
|
-
* identityRoles: [
|
|
65
|
-
* identityRole({ kind: 'tenant', template: 'org:{id}', source: 'organizationId' }),
|
|
66
|
-
* identityRole({ kind: 'member', template: 'team:{id}', source: 'teamIds', multi: true }),
|
|
67
|
-
* ],
|
|
68
|
-
* });
|
|
69
|
-
* ```
|
|
70
|
-
*/
|
|
71
|
-
export function identityRole(spec) {
|
|
72
|
-
return {
|
|
73
|
-
kind: spec.kind,
|
|
74
|
-
template: spec.template,
|
|
75
|
-
source: { field: spec.source, multi: spec.multi ?? false },
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
47
|
/**
|
|
79
48
|
* Base fields every synced model gets automatically.
|
|
80
49
|
*
|
|
@@ -184,11 +153,15 @@ export function defineSchema(models, options) {
|
|
|
184
153
|
// (identity) so this is a no-op when `casing` is unset — existing
|
|
185
154
|
// consumers get the same behavior they had before the option landed.
|
|
186
155
|
// When `casing: 'snake_case'` is set, every FK flips to its
|
|
187
|
-
// snake_case DB column name here and nowhere else.
|
|
188
|
-
//
|
|
156
|
+
// snake_case DB column name here and nowhere else. A field-level
|
|
157
|
+
// `.from(column)` override wins over the convention, so legacy
|
|
158
|
+
// columns stay declared in the artifact instead of rediscovered by
|
|
159
|
+
// SQL compilers. Server-side SQL compilers read the resolved value
|
|
160
|
+
// directly.
|
|
189
161
|
for (const relName of Object.keys(def.relations)) {
|
|
190
162
|
const rel = def.relations[relName];
|
|
191
|
-
|
|
163
|
+
const fieldColumn = def.fields[rel.foreignKey]?.column;
|
|
164
|
+
rel.foreignKeyColumn = fieldColumn ?? casing(rel.foreignKey);
|
|
192
165
|
}
|
|
193
166
|
const typename = def.typename ?? name;
|
|
194
167
|
const persist = def.persist
|
|
@@ -196,6 +169,7 @@ export function defineSchema(models, options) {
|
|
|
196
169
|
: undefined;
|
|
197
170
|
resolvedModels[name] = { ...def, typename, persist };
|
|
198
171
|
}
|
|
172
|
+
validateSyncGroupSchema(resolvedModels);
|
|
199
173
|
return {
|
|
200
174
|
// Cast back to S: we only added values to optional fields that were
|
|
201
175
|
// already part of ModelDef, so the shape is structurally unchanged.
|
|
@@ -205,30 +179,44 @@ export function defineSchema(models, options) {
|
|
|
205
179
|
};
|
|
206
180
|
}
|
|
207
181
|
/**
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* id into the role's `template`. Output is stable, deduped, and never
|
|
213
|
-
* includes a literal convention string from this function itself — the
|
|
214
|
-
* convention lives 100% in the consumer's schema declaration.
|
|
215
|
-
*
|
|
216
|
-
* Reads only data off the schema (`identityRoles` is pure data), so it works
|
|
217
|
-
* identically on an in-process `Schema` and one reconstructed from JSON on a
|
|
218
|
-
* hosted server.
|
|
219
|
-
*
|
|
220
|
-
* Returns `[]` when the schema has no identity roles registered or when no
|
|
221
|
-
* role's source produces an id. Caller decides what to do with `[]`; the
|
|
222
|
-
* server's intersect-with-requested logic treats it as "no scope" rather
|
|
223
|
-
* than "match everything."
|
|
182
|
+
* Validate the relation-driven sync-group declarations (`scope` / `grants`)
|
|
183
|
+
* at schema-build time, so a mistyped membership edge fails *here* — with a
|
|
184
|
+
* Stripe-shaped error (`code` + `param` + `doc_url`) pointing at the exact
|
|
185
|
+
* declaration — instead of silently mis-routing deltas at runtime.
|
|
224
186
|
*/
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
187
|
+
function validateSyncGroupSchema(models) {
|
|
188
|
+
for (const [name, def] of Object.entries(models)) {
|
|
189
|
+
// Shape-validate the `scope` declaration via the shared Zod schema.
|
|
190
|
+
if (def.scope !== undefined && !scopeSchema.safeParse(def.scope).success) {
|
|
191
|
+
throw new AbloValidationError(`Model "${name}": scope kind "${String(def.scope)}" must be a lowercase identifier (e.g. 'dataroom').`, { code: 'schema_scope_kind_invalid', param: `${name}.scope` });
|
|
192
|
+
}
|
|
193
|
+
if (!def.grants)
|
|
194
|
+
continue;
|
|
195
|
+
// Shape-validate the `grants` edge via the shared Zod schema before the
|
|
196
|
+
// cross-field (relation-exists / belongsTo) checks below.
|
|
197
|
+
if (!grantsRefSchema.safeParse(def.grants).success) {
|
|
198
|
+
throw new AbloValidationError(`Model "${name}": grants must be { subject, scope } naming two relations on this model.`, { code: 'schema_grants_shape_invalid', param: `${name}.grants` });
|
|
199
|
+
}
|
|
200
|
+
const relations = def.relations;
|
|
201
|
+
for (const role of ['subject', 'scope']) {
|
|
202
|
+
const relName = def.grants[role];
|
|
203
|
+
const rel = relations?.[relName];
|
|
204
|
+
if (!rel) {
|
|
205
|
+
throw new AbloValidationError(`Model "${name}": grants.${role} "${relName}" is not a relation on this model. ` +
|
|
206
|
+
`Declare a \`belongsTo\` relation named "${relName}" first.`, { code: 'schema_grants_relation_missing', param: `${name}.grants.${role}` });
|
|
207
|
+
}
|
|
208
|
+
if (rel.type !== 'belongsTo') {
|
|
209
|
+
throw new AbloValidationError(`Model "${name}": grants.${role} "${relName}" must be a \`belongsTo\` relation ` +
|
|
210
|
+
`(got "${rel.type}"). A membership edge points at a single subject/scope row.`, { code: 'schema_grants_relation_kind', param: `${name}.grants.${role}` });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// The scope edge must target a model that is actually a scope root —
|
|
214
|
+
// otherwise the resolved `<kind>:<id>` group is one nothing fans into.
|
|
215
|
+
const scopeRel = relations[def.grants.scope];
|
|
216
|
+
const target = models[scopeRel.target];
|
|
217
|
+
if (target && !target.scope) {
|
|
218
|
+
throw new AbloValidationError(`Model "${name}": grants.scope "${def.grants.scope}" targets "${scopeRel.target}", ` +
|
|
219
|
+
`which is not a scope root. Add \`scope: true\` to the "${scopeRel.target}" model.`, { code: 'schema_grants_target_not_scope_root', param: `${scopeRel.target}.scope` });
|
|
231
220
|
}
|
|
232
221
|
}
|
|
233
|
-
return Array.from(out);
|
|
234
222
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `selectModels` — project a schema down to a subset of its models.
|
|
3
|
+
*
|
|
4
|
+
* The Prisma-style "one canonical schema, each app selects what it needs"
|
|
5
|
+
* primitive. Instead of re-declaring a model's fields in a second schema (which
|
|
6
|
+
* must then be kept shape-identical by hand), an app picks the models it
|
|
7
|
+
* subscribes to from the canonical schema. Field shapes, resolved FK columns,
|
|
8
|
+
* computeds, typenames, and identity roles all come from the source — so a
|
|
9
|
+
* subset is structurally incapable of drifting from the canonical definition.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { schema as full } from '@ablo/schema';
|
|
13
|
+
* import { selectModels } from '@abloatai/ablo/schema';
|
|
14
|
+
*
|
|
15
|
+
* // Vault subscribes to identity + dataroom content only.
|
|
16
|
+
* export const schema = selectModels(full, ['users', 'organizations', 'datarooms', 'folders', 'files']);
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Relations whose target falls outside the selected set are dropped — the
|
|
20
|
+
* subset only sees its own models. A dropped relation that carries `parent`
|
|
21
|
+
* scope-inheritance throws instead: silently losing it would mis-route a
|
|
22
|
+
* record's fan-out, so the selected set must be closed under `parent` edges.
|
|
23
|
+
*/
|
|
24
|
+
import type { Schema, SchemaRecord } from './schema.js';
|
|
25
|
+
export declare function selectModels<S extends SchemaRecord, K extends keyof S & string>(schema: Schema<S>, keys: readonly K[]): Schema<Pick<S, K>>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `selectModels` — project a schema down to a subset of its models.
|
|
3
|
+
*
|
|
4
|
+
* The Prisma-style "one canonical schema, each app selects what it needs"
|
|
5
|
+
* primitive. Instead of re-declaring a model's fields in a second schema (which
|
|
6
|
+
* must then be kept shape-identical by hand), an app picks the models it
|
|
7
|
+
* subscribes to from the canonical schema. Field shapes, resolved FK columns,
|
|
8
|
+
* computeds, typenames, and identity roles all come from the source — so a
|
|
9
|
+
* subset is structurally incapable of drifting from the canonical definition.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { schema as full } from '@ablo/schema';
|
|
13
|
+
* import { selectModels } from '@abloatai/ablo/schema';
|
|
14
|
+
*
|
|
15
|
+
* // Vault subscribes to identity + dataroom content only.
|
|
16
|
+
* export const schema = selectModels(full, ['users', 'organizations', 'datarooms', 'folders', 'files']);
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Relations whose target falls outside the selected set are dropped — the
|
|
20
|
+
* subset only sees its own models. A dropped relation that carries `parent`
|
|
21
|
+
* scope-inheritance throws instead: silently losing it would mis-route a
|
|
22
|
+
* record's fan-out, so the selected set must be closed under `parent` edges.
|
|
23
|
+
*/
|
|
24
|
+
import { AbloValidationError } from '../errors.js';
|
|
25
|
+
export function selectModels(schema, keys) {
|
|
26
|
+
const keep = new Set(keys);
|
|
27
|
+
const models = {};
|
|
28
|
+
const validators = {};
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
const def = schema.models[key];
|
|
31
|
+
if (!def) {
|
|
32
|
+
throw new AbloValidationError(`selectModels: "${String(key)}" is not a model in the source schema`, { code: 'invalid_schema', param: String(key) });
|
|
33
|
+
}
|
|
34
|
+
// Prune relations whose target isn't in the selected set. A pruned
|
|
35
|
+
// `parent` edge is a routing error, not a silent drop.
|
|
36
|
+
const relations = {};
|
|
37
|
+
for (const [relName, rel] of Object.entries(def.relations)) {
|
|
38
|
+
if (keep.has(rel.target)) {
|
|
39
|
+
relations[relName] = rel;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (rel.options?.parent) {
|
|
43
|
+
throw new AbloValidationError(`selectModels: model "${String(key)}" has a parent relation "${relName}" → "${rel.target}", ` +
|
|
44
|
+
`which is not in the selected set. Include "${rel.target}" so scope inheritance still routes.`, { code: 'invalid_schema', param: `${String(key)}.${relName}` });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
models[key] = { ...def, relations };
|
|
48
|
+
validators[key] = schema.validators[key];
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
models: models,
|
|
52
|
+
validators: validators,
|
|
53
|
+
identityRoles: schema.identityRoles,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* What round-trips:
|
|
15
15
|
* - all model routing/scoping metadata (typename, tableName, load,
|
|
16
|
-
* mutable, orgScoped, scopedVia, bootstrap hints,
|
|
17
|
-
* persist, autoFill, requiredFields, lazyObservable)
|
|
16
|
+
* mutable, orgScoped, scopedVia, bootstrap hints, scope, grants,
|
|
17
|
+
* entityRoles, persist, autoFill, requiredFields, lazyObservable)
|
|
18
18
|
* - relations (incl. resolved `foreignKeyColumn`)
|
|
19
19
|
* - field metadata (names + type tags), from which validators are rebuilt
|
|
20
20
|
* - identity roles (already pure data)
|
|
@@ -25,12 +25,15 @@
|
|
|
25
25
|
* `FieldMeta` (the server does no field-shape validation anyway)
|
|
26
26
|
*/
|
|
27
27
|
import type { FieldMeta } from './field.js';
|
|
28
|
-
import type {
|
|
28
|
+
import type { Tenancy } from './tenancy.js';
|
|
29
|
+
import type { GrantsRef, LoadStrategy, PersistOptions, AutoFillRule } from './model.js';
|
|
29
30
|
import type { RelationType } from './relation.js';
|
|
30
|
-
import { type Schema, type SchemaRecord, type IdentityRole } from './schema.js';
|
|
31
|
+
import { type Schema, type SchemaRecord, type IdentityRole, type EntityRole } from './schema.js';
|
|
31
32
|
/** Current schema-JSON envelope version. Bump on a breaking change to the
|
|
32
|
-
* JSON shape itself (not the user's schema).
|
|
33
|
-
|
|
33
|
+
* JSON shape itself (not the user's schema). v2 replaced the per-model
|
|
34
|
+
* `syncGroupFormat` template string with structured `scope`/`grants`/
|
|
35
|
+
* `entityRoles` (relation-driven sync groups). */
|
|
36
|
+
declare const SCHEMA_JSON_VERSION: 3;
|
|
34
37
|
/** A relation in JSON form. Mirrors the serializable members of {@link RelationDef}. */
|
|
35
38
|
export interface RelationJSON {
|
|
36
39
|
readonly type: RelationType;
|
|
@@ -47,11 +50,12 @@ export interface ModelJSON {
|
|
|
47
50
|
readonly load: LoadStrategy;
|
|
48
51
|
readonly typename: string;
|
|
49
52
|
readonly tableName?: string;
|
|
50
|
-
readonly
|
|
51
|
-
readonly
|
|
53
|
+
readonly tenancy: Tenancy;
|
|
54
|
+
readonly scope?: boolean | string;
|
|
55
|
+
readonly grants?: GrantsRef;
|
|
56
|
+
readonly entityRoles?: readonly EntityRole[];
|
|
52
57
|
readonly bootstrapLimit?: number;
|
|
53
58
|
readonly bootstrapOrderBy?: string;
|
|
54
|
-
readonly syncGroupFormat?: string;
|
|
55
59
|
readonly mutable?: boolean;
|
|
56
60
|
readonly lazyObservable?: boolean;
|
|
57
61
|
readonly persist?: PersistOptions;
|
package/dist/schema/serialize.js
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* What round-trips:
|
|
15
15
|
* - all model routing/scoping metadata (typename, tableName, load,
|
|
16
|
-
* mutable, orgScoped, scopedVia, bootstrap hints,
|
|
17
|
-
* persist, autoFill, requiredFields, lazyObservable)
|
|
16
|
+
* mutable, orgScoped, scopedVia, bootstrap hints, scope, grants,
|
|
17
|
+
* entityRoles, persist, autoFill, requiredFields, lazyObservable)
|
|
18
18
|
* - relations (incl. resolved `foreignKeyColumn`)
|
|
19
19
|
* - field metadata (names + type tags), from which validators are rebuilt
|
|
20
20
|
* - identity roles (already pure data)
|
|
@@ -27,8 +27,10 @@
|
|
|
27
27
|
import { z } from 'zod';
|
|
28
28
|
import { baseFieldsSchema, } from './schema.js';
|
|
29
29
|
/** Current schema-JSON envelope version. Bump on a breaking change to the
|
|
30
|
-
* JSON shape itself (not the user's schema).
|
|
31
|
-
|
|
30
|
+
* JSON shape itself (not the user's schema). v2 replaced the per-model
|
|
31
|
+
* `syncGroupFormat` template string with structured `scope`/`grants`/
|
|
32
|
+
* `entityRoles` (relation-driven sync groups). */
|
|
33
|
+
const SCHEMA_JSON_VERSION = 3;
|
|
32
34
|
// ── Serialize ────────────────────────────────────────────────────────────────
|
|
33
35
|
function relationToJSON(rel) {
|
|
34
36
|
const options = rel.options;
|
|
@@ -54,11 +56,12 @@ function modelToJSON(def) {
|
|
|
54
56
|
// so it is present on a built ModelDef; fall back defensively anyway.
|
|
55
57
|
typename: def.typename ?? '',
|
|
56
58
|
tableName: def.tableName,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
tenancy: def.tenancy,
|
|
60
|
+
scope: def.scope,
|
|
61
|
+
grants: def.grants,
|
|
62
|
+
entityRoles: def.entityRoles,
|
|
59
63
|
bootstrapLimit: def.bootstrapLimit,
|
|
60
64
|
bootstrapOrderBy: def.bootstrapOrderBy,
|
|
61
|
-
syncGroupFormat: def.syncGroupFormat,
|
|
62
65
|
mutable: def.mutable,
|
|
63
66
|
lazyObservable: def.lazyObservable,
|
|
64
67
|
persist: def.persist,
|
|
@@ -157,9 +160,10 @@ function modelFromJSON(json) {
|
|
|
157
160
|
typename: json.typename,
|
|
158
161
|
persist: json.persist,
|
|
159
162
|
tableName: json.tableName,
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
tenancy: json.tenancy,
|
|
164
|
+
scope: json.scope,
|
|
165
|
+
grants: json.grants,
|
|
166
|
+
entityRoles: json.entityRoles,
|
|
163
167
|
mutable: json.mutable,
|
|
164
168
|
lazyObservable: json.lazyObservable,
|
|
165
169
|
// computed getters are closures and intentionally not serialized; a
|
package/dist/schema/sugar.d.ts
CHANGED
|
@@ -73,10 +73,27 @@ export interface SugarOptions<R extends RelationRecord = RelationRecord, C exten
|
|
|
73
73
|
*/
|
|
74
74
|
scopedVia?: ModelOptions['scopedVia'];
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
76
|
+
* Override the row-local tenancy column name. See
|
|
77
|
+
* {@link ModelOptions.orgColumn}.
|
|
78
78
|
*/
|
|
79
|
-
|
|
79
|
+
orgColumn?: ModelOptions['orgColumn'];
|
|
80
|
+
/** Canonical tenancy descriptor. See {@link ModelOptions.tenancy}. */
|
|
81
|
+
tenancy?: ModelOptions['tenancy'];
|
|
82
|
+
/**
|
|
83
|
+
* Mark this model a scope root — its records form the group `<kind>:<id>`
|
|
84
|
+
* (kind defaults from typename). See {@link ModelOptions.scope}.
|
|
85
|
+
*/
|
|
86
|
+
scope?: ModelOptions['scope'];
|
|
87
|
+
/**
|
|
88
|
+
* Membership edge granting identity → scope-root access. Both fields are
|
|
89
|
+
* relation names on this model. See {@link ModelOptions.grants}.
|
|
90
|
+
*/
|
|
91
|
+
grants?: ModelOptions['grants'];
|
|
92
|
+
/**
|
|
93
|
+
* Explicit non-relational record→group roles (e.g. inbox fan-out keyed on a
|
|
94
|
+
* field). See {@link ModelOptions.entityRoles}.
|
|
95
|
+
*/
|
|
96
|
+
entityRoles?: ModelOptions['entityRoles'];
|
|
80
97
|
/** Max rows loaded during bootstrap. Only applies to `.instant`. */
|
|
81
98
|
bootstrapLimit?: number;
|
|
82
99
|
/** Bootstrap sort order (e.g. `'created_at DESC'`). */
|
package/dist/schema/sugar.js
CHANGED
|
@@ -48,7 +48,11 @@ function build(shape, opts, baseline) {
|
|
|
48
48
|
tableName: opts?.tableName,
|
|
49
49
|
orgScoped: opts?.orgScoped,
|
|
50
50
|
scopedVia: opts?.scopedVia,
|
|
51
|
-
|
|
51
|
+
orgColumn: opts?.orgColumn,
|
|
52
|
+
tenancy: opts?.tenancy,
|
|
53
|
+
scope: opts?.scope,
|
|
54
|
+
grants: opts?.grants,
|
|
55
|
+
entityRoles: opts?.entityRoles,
|
|
52
56
|
bootstrapLimit: opts?.bootstrapLimit,
|
|
53
57
|
bootstrapOrderBy: opts?.bootstrapOrderBy,
|
|
54
58
|
persist: opts?.persist,
|