@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,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenancy — the single source of truth for how a model's rows are scoped to a
|
|
3
|
+
* tenant. This replaces three scattered mechanisms (a hardcoded
|
|
4
|
+
* `organization_id` literal, an `orgScoped` boolean, and a `scopedVia` ref) with
|
|
5
|
+
* one Zod discriminated union, resolved in one place and consumed everywhere
|
|
6
|
+
* (provision/RLS, introspection, runtime, CLI).
|
|
7
|
+
*
|
|
8
|
+
* Why a union: every consumer used to re-derive "how is this table scoped?" from
|
|
9
|
+
* a flag plus a literal — a missed branch was a silent cross-tenant scoping bug.
|
|
10
|
+
* A discriminated union makes the `switch` exhaustive, so the type system holds
|
|
11
|
+
* the isolation boundary, and the physical column name lives in exactly one
|
|
12
|
+
* place (the `column` variant) instead of being hardcoded across the codebase.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
/** Default physical tenancy column. The ONLY place this literal is canonical. */
|
|
16
|
+
export declare const DEFAULT_ORG_COLUMN = "organization_id";
|
|
17
|
+
/**
|
|
18
|
+
* Scope a table's rows through a parent table (for rows that carry no tenancy
|
|
19
|
+
* column of their own — e.g. `slide_layers` → slide → deck → org).
|
|
20
|
+
*/
|
|
21
|
+
export declare const scopedViaRefSchema: z.ZodObject<{
|
|
22
|
+
localKey: z.ZodString;
|
|
23
|
+
parentTable: z.ZodString;
|
|
24
|
+
parentKey: z.ZodOptional<z.ZodString>;
|
|
25
|
+
parentOrgColumn: z.ZodOptional<z.ZodString>;
|
|
26
|
+
}, z.core.$strip>;
|
|
27
|
+
export type ScopedViaRef = z.infer<typeof scopedViaRefSchema>;
|
|
28
|
+
/** How a model's rows are scoped to a tenant. */
|
|
29
|
+
export declare const tenancySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
30
|
+
kind: z.ZodLiteral<"column">;
|
|
31
|
+
column: z.ZodString;
|
|
32
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
33
|
+
kind: z.ZodLiteral<"parent">;
|
|
34
|
+
via: z.ZodObject<{
|
|
35
|
+
localKey: z.ZodString;
|
|
36
|
+
parentTable: z.ZodString;
|
|
37
|
+
parentKey: z.ZodOptional<z.ZodString>;
|
|
38
|
+
parentOrgColumn: z.ZodOptional<z.ZodString>;
|
|
39
|
+
}, z.core.$strip>;
|
|
40
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
41
|
+
kind: z.ZodLiteral<"none">;
|
|
42
|
+
}, z.core.$strip>], "kind">;
|
|
43
|
+
export type Tenancy = z.infer<typeof tenancySchema>;
|
|
44
|
+
/**
|
|
45
|
+
* Ergonomic authoring shortcuts accepted on a model. These are *input only* —
|
|
46
|
+
* `resolveTenancy` normalizes them into the canonical {@link Tenancy} at
|
|
47
|
+
* model-build time, so they never reach the serialized JSON or any consumer.
|
|
48
|
+
*/
|
|
49
|
+
export interface TenancyInput {
|
|
50
|
+
tenancy?: Tenancy;
|
|
51
|
+
/** `false` → not tenant-scoped. */
|
|
52
|
+
orgScoped?: boolean;
|
|
53
|
+
/** Scope through a parent table. */
|
|
54
|
+
scopedVia?: ScopedViaRef;
|
|
55
|
+
/** Override the column name for a column-scoped model. */
|
|
56
|
+
orgColumn?: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Normalize authoring sugar into the one canonical {@link Tenancy}. Called once,
|
|
60
|
+
* at model-build, so `ModelDef`/`ModelJSON` and every consumer see only
|
|
61
|
+
* `tenancy`. Precedence: explicit `tenancy` → `scopedVia` → `orgScoped:false` →
|
|
62
|
+
* column (default or `orgColumn`).
|
|
63
|
+
*/
|
|
64
|
+
export declare function resolveTenancy(input: TenancyInput): Tenancy;
|
|
65
|
+
/** The physical tenancy column for a column-scoped model, else `null`. */
|
|
66
|
+
export declare function tenancyColumn(t: Tenancy): string | null;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenancy — the single source of truth for how a model's rows are scoped to a
|
|
3
|
+
* tenant. This replaces three scattered mechanisms (a hardcoded
|
|
4
|
+
* `organization_id` literal, an `orgScoped` boolean, and a `scopedVia` ref) with
|
|
5
|
+
* one Zod discriminated union, resolved in one place and consumed everywhere
|
|
6
|
+
* (provision/RLS, introspection, runtime, CLI).
|
|
7
|
+
*
|
|
8
|
+
* Why a union: every consumer used to re-derive "how is this table scoped?" from
|
|
9
|
+
* a flag plus a literal — a missed branch was a silent cross-tenant scoping bug.
|
|
10
|
+
* A discriminated union makes the `switch` exhaustive, so the type system holds
|
|
11
|
+
* the isolation boundary, and the physical column name lives in exactly one
|
|
12
|
+
* place (the `column` variant) instead of being hardcoded across the codebase.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
/** Default physical tenancy column. The ONLY place this literal is canonical. */
|
|
16
|
+
export const DEFAULT_ORG_COLUMN = 'organization_id';
|
|
17
|
+
/**
|
|
18
|
+
* Scope a table's rows through a parent table (for rows that carry no tenancy
|
|
19
|
+
* column of their own — e.g. `slide_layers` → slide → deck → org).
|
|
20
|
+
*/
|
|
21
|
+
export const scopedViaRefSchema = z.object({
|
|
22
|
+
/** Column on THIS table pointing at the parent (e.g. `'team_id'`). */
|
|
23
|
+
localKey: z.string().min(1),
|
|
24
|
+
/** Parent table name (e.g. `'team'`). */
|
|
25
|
+
parentTable: z.string().min(1),
|
|
26
|
+
/** Column on the parent that `localKey` references. Default `'id'`. */
|
|
27
|
+
parentKey: z.string().min(1).optional(),
|
|
28
|
+
/** Column on the parent holding the tenant id. Default {@link DEFAULT_ORG_COLUMN}. */
|
|
29
|
+
parentOrgColumn: z.string().min(1).optional(),
|
|
30
|
+
});
|
|
31
|
+
/** How a model's rows are scoped to a tenant. */
|
|
32
|
+
export const tenancySchema = z.discriminatedUnion('kind', [
|
|
33
|
+
/** Row-local tenancy column (default name `organization_id`, overridable). */
|
|
34
|
+
z.object({ kind: z.literal('column'), column: z.string().min(1) }),
|
|
35
|
+
/** Scoped through a parent table's tenancy. */
|
|
36
|
+
z.object({ kind: z.literal('parent'), via: scopedViaRefSchema }),
|
|
37
|
+
/** Not tenant-scoped (global / reference data). */
|
|
38
|
+
z.object({ kind: z.literal('none') }),
|
|
39
|
+
]);
|
|
40
|
+
/**
|
|
41
|
+
* Normalize authoring sugar into the one canonical {@link Tenancy}. Called once,
|
|
42
|
+
* at model-build, so `ModelDef`/`ModelJSON` and every consumer see only
|
|
43
|
+
* `tenancy`. Precedence: explicit `tenancy` → `scopedVia` → `orgScoped:false` →
|
|
44
|
+
* column (default or `orgColumn`).
|
|
45
|
+
*/
|
|
46
|
+
export function resolveTenancy(input) {
|
|
47
|
+
if (input.tenancy)
|
|
48
|
+
return input.tenancy;
|
|
49
|
+
if (input.scopedVia)
|
|
50
|
+
return { kind: 'parent', via: input.scopedVia };
|
|
51
|
+
if (input.orgScoped === false)
|
|
52
|
+
return { kind: 'none' };
|
|
53
|
+
return { kind: 'column', column: input.orgColumn ?? DEFAULT_ORG_COLUMN };
|
|
54
|
+
}
|
|
55
|
+
/** The physical tenancy column for a column-scoped model, else `null`. */
|
|
56
|
+
export function tenancyColumn(t) {
|
|
57
|
+
return t.kind === 'column' ? t.column : null;
|
|
58
|
+
}
|
|
@@ -113,6 +113,8 @@ export declare class HydrationCoordinator {
|
|
|
113
113
|
private hydrateExpanded;
|
|
114
114
|
private persistToIdb;
|
|
115
115
|
private resolveTypename;
|
|
116
|
+
private columnizeField;
|
|
117
|
+
private columnizeClause;
|
|
116
118
|
}
|
|
117
119
|
/**
|
|
118
120
|
* Normalize `LoadWhere<T>` input to the canonical `readonly WhereClause[]`
|
|
@@ -161,10 +161,10 @@ export class HydrationCoordinator {
|
|
|
161
161
|
const firstOrder = orderEntries[0];
|
|
162
162
|
const query = {
|
|
163
163
|
model: typename,
|
|
164
|
-
where: clauses.map((c) => columnizeClause(c)),
|
|
164
|
+
where: clauses.map((c) => this.columnizeClause(modelName, c)),
|
|
165
165
|
...(firstOrder
|
|
166
166
|
? {
|
|
167
|
-
orderBy:
|
|
167
|
+
orderBy: this.columnizeField(modelName, firstOrder[0]),
|
|
168
168
|
order: firstOrder[1] ?? 'asc',
|
|
169
169
|
}
|
|
170
170
|
: {}),
|
|
@@ -256,6 +256,27 @@ export class HydrationCoordinator {
|
|
|
256
256
|
.models?.[modelName];
|
|
257
257
|
return def?.typename ?? modelName;
|
|
258
258
|
}
|
|
259
|
+
columnizeField(modelName, field) {
|
|
260
|
+
const fields = this.opts.schema.models?.[modelName]?.fields;
|
|
261
|
+
if (fields) {
|
|
262
|
+
const direct = fields[field]?.column;
|
|
263
|
+
if (direct)
|
|
264
|
+
return direct;
|
|
265
|
+
for (const [fieldName, meta] of Object.entries(fields)) {
|
|
266
|
+
const conventional = columnize(fieldName);
|
|
267
|
+
if (field === fieldName || field === conventional || field === meta.column) {
|
|
268
|
+
return meta.column ?? conventional;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return /[A-Z]/.test(field) ? columnize(field) : field;
|
|
273
|
+
}
|
|
274
|
+
columnizeClause(modelName, clause) {
|
|
275
|
+
const finalCol = this.columnizeField(modelName, clause[0]);
|
|
276
|
+
if (clause.length === 2)
|
|
277
|
+
return [finalCol, clause[1]];
|
|
278
|
+
return [finalCol, clause[1], clause[2]];
|
|
279
|
+
}
|
|
259
280
|
}
|
|
260
281
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
261
282
|
function stableKey(modelName, clauses, orderBy, limit) {
|
|
@@ -350,21 +371,6 @@ export function normalizeWhere(where) {
|
|
|
350
371
|
}
|
|
351
372
|
return [];
|
|
352
373
|
}
|
|
353
|
-
/**
|
|
354
|
-
* Apply `columnize` to the column name of a wire-bound clause so the
|
|
355
|
-
* server sees `slide_id` instead of `slideId`. Tuple-form clauses from
|
|
356
|
-
* callers are passed through unchanged — they already supply the wire
|
|
357
|
-
* column name (matches what existing `postQuery` consumers do).
|
|
358
|
-
*/
|
|
359
|
-
function columnizeClause(clause) {
|
|
360
|
-
const col = clause[0];
|
|
361
|
-
// If the column already looks snake_case (no uppercase letters), assume
|
|
362
|
-
// the caller is already using server-side naming. Otherwise camelize→snake.
|
|
363
|
-
const finalCol = /[A-Z]/.test(col) ? columnize(col) : col;
|
|
364
|
-
if (clause.length === 2)
|
|
365
|
-
return [finalCol, clause[1]];
|
|
366
|
-
return [finalCol, clause[1], clause[2]];
|
|
367
|
-
}
|
|
368
374
|
/** Equality-only subset of clauses, keyed by column. Used by IDB fast paths. */
|
|
369
375
|
function extractEqClauses(clauses) {
|
|
370
376
|
const out = {};
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* Wire contract (apps/sync-server/src/hub/types.ts):
|
|
12
12
|
* • Outbound: `{ type: 'intent_begin', payload: { intentId,
|
|
13
13
|
* entityType, entityId, action, field?, estimatedMs? } }`
|
|
14
|
-
* • Outbound: `{ type: 'intent_abandon', payload: { intentId
|
|
14
|
+
* • Outbound: `{ type: 'intent_abandon', payload: { intentId,
|
|
15
|
+
* entityType?, entityId? } }`
|
|
15
16
|
* • Inbound (via presence): `event.activeIntents: IntentClaim[]`
|
|
16
17
|
* stamped with `declaredAt`, `expiresAt`.
|
|
17
18
|
* • Inbound: `intent_rejected` event with conflict metadata.
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* Wire contract (apps/sync-server/src/hub/types.ts):
|
|
12
12
|
* • Outbound: `{ type: 'intent_begin', payload: { intentId,
|
|
13
13
|
* entityType, entityId, action, field?, estimatedMs? } }`
|
|
14
|
-
* • Outbound: `{ type: 'intent_abandon', payload: { intentId
|
|
14
|
+
* • Outbound: `{ type: 'intent_abandon', payload: { intentId,
|
|
15
|
+
* entityType?, entityId? } }`
|
|
15
16
|
* • Inbound (via presence): `event.activeIntents: IntentClaim[]`
|
|
16
17
|
* stamped with `declaredAt`, `expiresAt`.
|
|
17
18
|
* • Inbound: `intent_rejected` event with conflict metadata.
|
|
@@ -38,6 +39,7 @@ export function createIntentStream(config, transport = null) {
|
|
|
38
39
|
// ── Subscribers ──────────────────────────────────────────────────
|
|
39
40
|
const listeners = new Set();
|
|
40
41
|
const rejectionListeners = new Set();
|
|
42
|
+
const lostListeners = new Set();
|
|
41
43
|
const notifyListeners = () => {
|
|
42
44
|
intentsSnapshot = Object.freeze(Array.from(activeByIntentId.values()));
|
|
43
45
|
for (const l of listeners) {
|
|
@@ -131,6 +133,24 @@ export function createIntentStream(config, transport = null) {
|
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
135
|
}));
|
|
136
|
+
// (2a) Server-side LOSS frames — you held it, then lost it (preempted /
|
|
137
|
+
// expired). Distinct from a rejection (a claim the server refused).
|
|
138
|
+
unsubs.push(t.subscribe('intent_lost', (payload) => {
|
|
139
|
+
const lost = payload;
|
|
140
|
+
if (!lost.intentId)
|
|
141
|
+
return;
|
|
142
|
+
// Drop the lost own-claim so reconnect doesn't re-announce a lease we
|
|
143
|
+
// no longer hold.
|
|
144
|
+
ownIntents.delete(lost.intentId);
|
|
145
|
+
for (const l of lostListeners) {
|
|
146
|
+
try {
|
|
147
|
+
l(lost);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* isolate */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}));
|
|
134
154
|
// (2b) Per-entity wait-queue snapshots. The server fans the full line
|
|
135
155
|
// out on every queue mutation; we replace our cached line for that
|
|
136
156
|
// entity and notify so `queue(target)` reads reactively.
|
|
@@ -178,6 +198,20 @@ export function createIntentStream(config, transport = null) {
|
|
|
178
198
|
},
|
|
179
199
|
});
|
|
180
200
|
}
|
|
201
|
+
function sendReorder(entityType, entityId, order) {
|
|
202
|
+
if (!attached?.isConnected())
|
|
203
|
+
return;
|
|
204
|
+
attached.send({
|
|
205
|
+
type: 'intent_reorder',
|
|
206
|
+
payload: {
|
|
207
|
+
entityType,
|
|
208
|
+
entityId,
|
|
209
|
+
// The wire shape identifies a waiter by heldBy + intentId; map the
|
|
210
|
+
// ergonomic `Intent[]` (what `queueFor` returns) down to that.
|
|
211
|
+
order: order.map((i) => ({ heldBy: i.heldBy, intentId: i.id })),
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
181
215
|
function sendAbandon(intentId, intent) {
|
|
182
216
|
if (!attached?.isConnected())
|
|
183
217
|
return;
|
|
@@ -252,6 +286,10 @@ export function createIntentStream(config, transport = null) {
|
|
|
252
286
|
const ref = resolveTarget(target);
|
|
253
287
|
return queueByEntity.get(entityKey(ref.type, ref.id)) ?? EMPTY_QUEUE;
|
|
254
288
|
},
|
|
289
|
+
reorder(target, order) {
|
|
290
|
+
const ref = resolveTarget(target);
|
|
291
|
+
sendReorder(ref.type, ref.id, order);
|
|
292
|
+
},
|
|
255
293
|
onChange: (listener) => {
|
|
256
294
|
listeners.add(listener);
|
|
257
295
|
return () => {
|
|
@@ -264,6 +302,12 @@ export function createIntentStream(config, transport = null) {
|
|
|
264
302
|
rejectionListeners.delete(listener);
|
|
265
303
|
};
|
|
266
304
|
},
|
|
305
|
+
onLost: (listener) => {
|
|
306
|
+
lostListeners.add(listener);
|
|
307
|
+
return () => {
|
|
308
|
+
lostListeners.delete(listener);
|
|
309
|
+
};
|
|
310
|
+
},
|
|
267
311
|
[Symbol.asyncIterator]() {
|
|
268
312
|
return asyncIteratorFrom((onChange) => {
|
|
269
313
|
listeners.add(onChange);
|
|
@@ -279,6 +323,7 @@ export function createIntentStream(config, transport = null) {
|
|
|
279
323
|
unsubs.length = 0;
|
|
280
324
|
listeners.clear();
|
|
281
325
|
rejectionListeners.clear();
|
|
326
|
+
lostListeners.clear();
|
|
282
327
|
activeByIntentId.clear();
|
|
283
328
|
ownIntents.clear();
|
|
284
329
|
queueByEntity.clear();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { scopeKindOf } from '../schema/model.js';
|
|
1
2
|
export function createParticipantManager(config) {
|
|
2
3
|
return {
|
|
3
4
|
async join(input, overrides) {
|
|
@@ -74,17 +75,13 @@ export function resolveParticipantSyncGroups(scope, schema) {
|
|
|
74
75
|
}
|
|
75
76
|
export function syncGroupFromEntityRef(ref, schema) {
|
|
76
77
|
const match = findModelForEntityRef(ref, schema);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
return `${ref.type.toLowerCase()}:${ref.id}`;
|
|
78
|
+
const kind = match ? scopeKindOf(match.def, match.key) : undefined;
|
|
79
|
+
return `${kind ?? ref.type.toLowerCase()}:${ref.id}`;
|
|
81
80
|
}
|
|
82
81
|
function syncGroupFromSchemaKey(schemaKey, id, schema) {
|
|
83
82
|
const def = schema?.models?.[schemaKey];
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
return `${schemaKey}:${id}`;
|
|
83
|
+
const kind = def ? scopeKindOf(def, schemaKey) : undefined;
|
|
84
|
+
return `${kind ?? schemaKey}:${id}`;
|
|
88
85
|
}
|
|
89
86
|
function findModelForEntityRef(ref, schema) {
|
|
90
87
|
if (!schema?.models)
|
|
@@ -98,12 +95,6 @@ function findModelForEntityRef(ref, schema) {
|
|
|
98
95
|
}
|
|
99
96
|
return null;
|
|
100
97
|
}
|
|
101
|
-
function renderSyncGroupFormat(format, values) {
|
|
102
|
-
return format.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_match, key) => {
|
|
103
|
-
const value = values[key];
|
|
104
|
-
return value === undefined ? `{${key}}` : value;
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
98
|
export function parseParticipantTtlSeconds(value) {
|
|
108
99
|
if (typeof value === 'number' && Number.isFinite(value))
|
|
109
100
|
return value;
|
package/dist/types/streams.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* the shared coordination substrate.
|
|
10
10
|
*/
|
|
11
11
|
import type { InferModel, Schema } from '../schema/schema.js';
|
|
12
|
+
import type { TargetRange, OnStaleMode, IntentClaim, PresenceKind } from '../coordination/schema.js';
|
|
13
|
+
export type { TargetRange, OnStaleMode, IntentClaim, PresenceKind };
|
|
12
14
|
/**
|
|
13
15
|
* Any JSON-serializable value. Used where the SDK accepts free-form
|
|
14
16
|
* metadata that will be persisted / transported as JSON — avoids
|
|
@@ -113,13 +115,6 @@ export interface ContextChange {
|
|
|
113
115
|
* snapshot. Defaults to `'reject'` when `readAt` is provided without
|
|
114
116
|
* `onStale`.
|
|
115
117
|
*/
|
|
116
|
-
export type OnStaleMode = 'reject' | 'flag' | 'merge' | 'force';
|
|
117
|
-
export interface TargetRange {
|
|
118
|
-
readonly startLine: number;
|
|
119
|
-
readonly endLine: number;
|
|
120
|
-
readonly startColumn?: number;
|
|
121
|
-
readonly endColumn?: number;
|
|
122
|
-
}
|
|
123
118
|
/**
|
|
124
119
|
* A pointer to one entity, optionally narrowed to a structured
|
|
125
120
|
* subtarget. `type` and `id` are customer schema vocabulary; `path`,
|
|
@@ -304,32 +299,6 @@ export interface Peer {
|
|
|
304
299
|
/** Pending-mutation intents this participant has declared. */
|
|
305
300
|
readonly activeIntents?: ReadonlyArray<IntentClaim>;
|
|
306
301
|
}
|
|
307
|
-
/**
|
|
308
|
-
* Pending-mutation intent on the wire. Declared via `intent_begin`,
|
|
309
|
-
* cleared on `intent_abandon` / commit / disconnect / TTL expiry.
|
|
310
|
-
* Server stamps `declaredAt` and `expiresAt` (ms epoch). The SDK's
|
|
311
|
-
* `IntentStream.others` exposes a richer `ActiveIntent` view (defined
|
|
312
|
-
* below) that adds `heldBy` so callers know which participant owns it.
|
|
313
|
-
*/
|
|
314
|
-
export interface IntentClaim {
|
|
315
|
-
readonly intentId: string;
|
|
316
|
-
readonly entityType: string;
|
|
317
|
-
readonly entityId: string;
|
|
318
|
-
readonly path?: string;
|
|
319
|
-
readonly range?: TargetRange;
|
|
320
|
-
readonly action: string;
|
|
321
|
-
readonly field?: string;
|
|
322
|
-
readonly meta?: Record<string, unknown>;
|
|
323
|
-
readonly declaredAt: number;
|
|
324
|
-
readonly expiresAt: number;
|
|
325
|
-
}
|
|
326
|
-
/**
|
|
327
|
-
* Transition type carried on every presence frame from the server.
|
|
328
|
-
* - `'enter'` — first frame the receiver sees for this peer.
|
|
329
|
-
* - `'update'` — activity / intent change on an already-known peer.
|
|
330
|
-
* - `'leave'` — peer departed (explicit disconnect or TTL expiry).
|
|
331
|
-
*/
|
|
332
|
-
export type PresenceKind = 'enter' | 'update' | 'leave';
|
|
333
302
|
/** Outbound `presence_update` payload. */
|
|
334
303
|
export interface PresenceUpdatePayload {
|
|
335
304
|
readonly status: 'online' | 'away' | 'offline' | (string & {});
|
|
@@ -411,6 +380,15 @@ export interface IntentStream {
|
|
|
411
380
|
* `subscribe(...)` for change notifications.
|
|
412
381
|
*/
|
|
413
382
|
queueFor(target: PresenceTarget): readonly Intent[];
|
|
383
|
+
/**
|
|
384
|
+
* Re-rank the wait queue on a target — move the listed waiters to the front
|
|
385
|
+
* in the given order; unlisted waiters keep their relative FIFO order behind
|
|
386
|
+
* them. Pass the `Intent[]` from `queueFor(target)` in the order you want
|
|
387
|
+
* (each `Intent` carries its `heldBy` + `id`). Privileged: the server gates
|
|
388
|
+
* it (a participant lacking the `intent.reorder` capability is denied), so
|
|
389
|
+
* this is fire-and-forget — the new order arrives reactively via `queueFor`.
|
|
390
|
+
*/
|
|
391
|
+
reorder(target: PresenceTarget, order: readonly Intent[]): void;
|
|
414
392
|
/**
|
|
415
393
|
* Framework-agnostic reactivity. Same contract as
|
|
416
394
|
* `PresenceStream.subscribe` — register a listener fired on every
|
|
@@ -435,6 +413,23 @@ export interface IntentStream {
|
|
|
435
413
|
* Returns an unsubscribe fn.
|
|
436
414
|
*/
|
|
437
415
|
onRejected(listener: (rejection: IntentRejection) => void): () => void;
|
|
416
|
+
/**
|
|
417
|
+
* Observe LOSING an intent you held — distinct from `onRejected` (a claim the
|
|
418
|
+
* server refused). Fires on the server's `intent_lost` frame, carrying why:
|
|
419
|
+
* `'preempted'` (a privileged participant evicted you) or `'expired'` (your
|
|
420
|
+
* TTL lapsed). Lets a holder react — re-plan vs re-claim — instead of
|
|
421
|
+
* silently discovering the lease gone via presence.
|
|
422
|
+
*
|
|
423
|
+
* ```ts
|
|
424
|
+
* participant.intents.onLost((lost) => {
|
|
425
|
+
* if (lost.reason === 'preempted') replanAgainst(lost.target);
|
|
426
|
+
* else reclaim(lost.target);
|
|
427
|
+
* });
|
|
428
|
+
* ```
|
|
429
|
+
*
|
|
430
|
+
* Returns an unsubscribe fn.
|
|
431
|
+
*/
|
|
432
|
+
onLost(listener: (lost: IntentLost) => void): () => void;
|
|
438
433
|
/**
|
|
439
434
|
* Async-iterable view of everyone else's open intents. Each
|
|
440
435
|
* iteration yields the current snapshot on every mutation.
|
|
@@ -473,6 +468,31 @@ export interface IntentRejection {
|
|
|
473
468
|
/** When the existing claim expires (ms since epoch). */
|
|
474
469
|
readonly heldByExpiresAt: number;
|
|
475
470
|
}
|
|
471
|
+
/**
|
|
472
|
+
* You LOST an intent you were HOLDING — distinct from `IntentRejection` (a
|
|
473
|
+
* claim the server refused you). Delivered via `onLost`.
|
|
474
|
+
*/
|
|
475
|
+
export interface IntentLost {
|
|
476
|
+
/** The held claim's id that you just lost. */
|
|
477
|
+
readonly intentId: string;
|
|
478
|
+
/**
|
|
479
|
+
* How you lost it. `'preempted'`: a privileged participant (one holding the
|
|
480
|
+
* `intent.preempt` capability) evicted you and took the lease — its work now
|
|
481
|
+
* supersedes yours, so re-plan against the new holder rather than blindly
|
|
482
|
+
* re-claiming. `'expired'`: your TTL lapsed without finishing — re-claim if
|
|
483
|
+
* you still need it.
|
|
484
|
+
*/
|
|
485
|
+
readonly reason: 'expired' | 'preempted';
|
|
486
|
+
/** The target you no longer hold. */
|
|
487
|
+
readonly target: {
|
|
488
|
+
readonly entityType: string;
|
|
489
|
+
readonly entityId: string;
|
|
490
|
+
readonly path?: string;
|
|
491
|
+
readonly range?: TargetRange;
|
|
492
|
+
readonly field?: string;
|
|
493
|
+
readonly meta?: Record<string, unknown>;
|
|
494
|
+
};
|
|
495
|
+
}
|
|
476
496
|
export interface IntentDeclaration {
|
|
477
497
|
readonly target: EntityRef;
|
|
478
498
|
/** Human-readable reason — "rewriting title" / "restyling chart". */
|
package/docs/api-keys.md
CHANGED
|
@@ -22,3 +22,47 @@ Use API keys from trusted runtimes:
|
|
|
22
22
|
- webhooks
|
|
23
23
|
|
|
24
24
|
Never ship a secret API key to a browser bundle.
|
|
25
|
+
|
|
26
|
+
## Test mode and sandboxes
|
|
27
|
+
|
|
28
|
+
Test and live keys are the same shape; the prefix names the environment:
|
|
29
|
+
|
|
30
|
+
- `sk_test_…` — a key bound to a **sandbox**. Its reads and writes are isolated
|
|
31
|
+
to that sandbox and are invisible to live keys (and to other sandboxes).
|
|
32
|
+
- `sk_live_…` — a key against your live data.
|
|
33
|
+
|
|
34
|
+
Every org has a default **Test mode** sandbox, plus any number of additional
|
|
35
|
+
sandboxes you create. **Data is isolated per sandbox; the schema is shared
|
|
36
|
+
across the whole org.** A schema you push from a test key defines the same
|
|
37
|
+
models your live keys see — only the rows differ. This mirrors how Stripe
|
|
38
|
+
separates test and live data while keeping the API shape identical.
|
|
39
|
+
|
|
40
|
+
## Scopes
|
|
41
|
+
|
|
42
|
+
Keys carry scopes following the principle of least privilege — each key gets
|
|
43
|
+
only what its job needs. A secret key with **no scopes** has full org authority
|
|
44
|
+
(the default for a `sk_live_` backend key); a key with a non-empty scope set is
|
|
45
|
+
restricted to exactly those grants:
|
|
46
|
+
|
|
47
|
+
- `schema:push` — author the org schema (`ablo schema push`, `ablo dev`). A
|
|
48
|
+
high-risk, org-wide grant: because schema is shared, a push affects the live
|
|
49
|
+
table shape. A full-authority key has it implicitly; a *restricted* key (such
|
|
50
|
+
as a sandbox key) needs it granted explicitly.
|
|
51
|
+
- `sandbox:<id>` — marks the key as belonging to a sandbox (its data isolation
|
|
52
|
+
comes from the sandbox binding, not this scope string).
|
|
53
|
+
|
|
54
|
+
A key minted from the default **Test mode** sandbox carries `schema:push`, so
|
|
55
|
+
`ablo dev` works out of the box. Keys from other sandboxes are **data-only** by
|
|
56
|
+
default — enable "schema authoring" when minting if you want that key to push
|
|
57
|
+
schema too. Hand data-only keys to embedded apps and CI agents; reserve
|
|
58
|
+
schema-authoring keys for the developer running `ablo dev`.
|
|
59
|
+
|
|
60
|
+
### `ablo dev`
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
ABLO_API_KEY=sk_test_… npx ablo dev
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Pushes your `ablo/schema.ts` to the test sandbox, prints the one line you need
|
|
67
|
+
in `.env.local`, and re-pushes on every save. It refuses `sk_live_` keys so a
|
|
68
|
+
tight save loop can never churn production data.
|
package/docs/api.md
CHANGED
|
@@ -36,31 +36,23 @@ Each schema model becomes a typed model on the client:
|
|
|
36
36
|
- `ablo.weatherReports.update(id, data, options?)` updates a row.
|
|
37
37
|
- `ablo.weatherReports.delete(id, options?)` deletes a row.
|
|
38
38
|
|
|
39
|
-
`load` and `retrieve` are not aliases. Use `load` when the row may not be
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
`load` and `retrieve` are not aliases. Use `load` when the row may not be loaded
|
|
40
|
+
yet. Use `retrieve` after `ready()` or `load()` when you want a cheap
|
|
41
|
+
synchronous read.
|
|
42
42
|
|
|
43
43
|
| Method | Returns | Use when |
|
|
44
44
|
|---|---|---|
|
|
45
45
|
| `load({ where })` | `Promise<T[]>` | You need to hydrate rows from local store and server. |
|
|
46
|
-
| `retrieve(id)` | `T \| undefined` | You already loaded the row and want a synchronous
|
|
47
|
-
| `list(options?)` | `T[]` | You want a synchronous
|
|
48
|
-
| `count(options?)` | `number` | You want a synchronous
|
|
46
|
+
| `retrieve(id)` | `T \| undefined` | You already loaded the row and want a synchronous read. |
|
|
47
|
+
| `list(options?)` | `T[]` | You want a synchronous list of loaded rows. |
|
|
48
|
+
| `count(options?)` | `number` | You want a synchronous count of loaded rows. |
|
|
49
49
|
| `create(data, options?)` | `Promise<T>` | You want to create through the schema model. |
|
|
50
50
|
| `update(id, data, options?)` | `Promise<T>` | You want to update through the schema model. |
|
|
51
51
|
| `delete(id, options?)` | `Promise<void>` | You want to delete through the schema model. |
|
|
52
52
|
|
|
53
|
-
`
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const readyReports = ablo.weatherReports.list({
|
|
57
|
-
where: { status: 'ready' },
|
|
58
|
-
filter: (report) => !report.location.startsWith('[archived]'),
|
|
59
|
-
orderBy: { updatedAt: 'desc' },
|
|
60
|
-
limit: 20,
|
|
61
|
-
scope: 'live', // 'live' | 'archived' | 'all'
|
|
62
|
-
});
|
|
63
|
-
```
|
|
53
|
+
`load`, `create`, `update`, and `delete` are the main path — they go through the
|
|
54
|
+
server. `retrieve` / `list` / `count` are **synchronous reads** off the rows a
|
|
55
|
+
session has already loaded, so a cheap re-read needs no round-trip.
|
|
64
56
|
|
|
65
57
|
## Protected Writes
|
|
66
58
|
|
|
@@ -150,20 +142,17 @@ Default reads stay open; server/model reads can opt into `ifClaimed: 'wait'` or
|
|
|
150
142
|
`ifClaimed: 'fail'` when they should not read through active work.
|
|
151
143
|
|
|
152
144
|
```ts
|
|
153
|
-
// Read side — who is working on this target right now?
|
|
154
145
|
const claim = ablo.weatherReports.claimState('report_stockholm');
|
|
155
146
|
if (claim) {
|
|
156
|
-
claim.heldBy;
|
|
157
|
-
claim.action;
|
|
147
|
+
claim.heldBy;
|
|
148
|
+
claim.action;
|
|
158
149
|
}
|
|
159
150
|
|
|
160
|
-
// Write side — claim for the duration of the callback.
|
|
161
151
|
const updated = await ablo.weatherReports.claim(
|
|
162
152
|
'report_stockholm',
|
|
163
153
|
async (report) => ablo.weatherReports.update(report.id, { status: 'ready' }),
|
|
164
154
|
{ action: 'editing', ttl: '2m' },
|
|
165
155
|
);
|
|
166
|
-
updated.status; // 'ready'
|
|
167
156
|
```
|
|
168
157
|
|
|
169
158
|
Writes go through the normal flat `ablo.<model>.update(id, data)`. While you hold
|