@abloatai/ablo 0.32.0 → 0.34.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +24 -12
  3. package/dist/cli.cjs +148 -21
  4. package/dist/client/Ablo.js +3 -2
  5. package/dist/client/createModelProxy.d.ts +53 -10
  6. package/dist/client/createModelProxy.js +24 -5
  7. package/dist/client/httpTransport.js +1 -0
  8. package/dist/client/resourceTypes.d.ts +10 -1
  9. package/dist/client/wsMutationExecutor.d.ts +2 -2
  10. package/dist/client/wsMutationExecutor.js +1 -1
  11. package/dist/coordination/index.d.ts +2 -2
  12. package/dist/coordination/index.js +1 -1
  13. package/dist/coordination/schema.d.ts +20 -0
  14. package/dist/coordination/schema.js +22 -0
  15. package/dist/errorCodes.d.ts +1 -1
  16. package/dist/errorCodes.js +1 -1
  17. package/dist/interfaces/index.d.ts +13 -1
  18. package/dist/react/AbloProvider.d.ts +8 -8
  19. package/dist/react/AbloProvider.js +6 -6
  20. package/dist/react/index.d.ts +2 -2
  21. package/dist/react/index.js +2 -2
  22. package/dist/schema/index.d.ts +1 -1
  23. package/dist/schema/index.js +1 -1
  24. package/dist/schema/schema.d.ts +37 -40
  25. package/dist/schema/schema.js +24 -30
  26. package/dist/schema/select.js +1 -1
  27. package/dist/schema/serialize.d.ts +3 -3
  28. package/dist/schema/serialize.js +4 -2
  29. package/dist/server/commit.d.ts +8 -1
  30. package/dist/surface.d.ts +1 -1
  31. package/dist/surface.js +2 -1
  32. package/dist/sync/SyncWebSocket.d.ts +3 -3
  33. package/dist/sync/SyncWebSocket.js +4 -4
  34. package/dist/sync/commitFrames.d.ts +2 -2
  35. package/dist/sync/commitFrames.js +5 -1
  36. package/dist/transactions/TransactionQueue.d.ts +4 -1
  37. package/dist/transactions/TransactionQueue.js +18 -0
  38. package/dist/transactions/commitEnvelope.d.ts +8 -0
  39. package/dist/transactions/commitEnvelope.js +2 -1
  40. package/dist/transactions/durableWriteStore.d.ts +8 -0
  41. package/dist/wire/frames.d.ts +17 -1
  42. package/dist/wire/frames.js +2 -1
  43. package/docs/client-behavior.md +1 -1
  44. package/docs/coordination.md +18 -6
  45. package/docs/groups.md +50 -0
  46. package/docs/identity.md +2 -2
  47. package/docs/migration.md +29 -5
  48. package/docs/react.md +9 -9
  49. package/llms.txt +1 -1
  50. package/package.json +1 -1
@@ -9,7 +9,7 @@
9
9
  * Exports are listed by name rather than re-exported wholesale, so every symbol
10
10
  * that becomes part of this package's public API is a deliberate choice.
11
11
  */
12
- export { targetRangeSchema, participantKindSchema, wireParticipantKindSchema, participantKindFromWire, descriptionFromMeta, targetRefSchema, onStaleModeSchema, writeGuardSchema, staleNotificationSchema, readDependencySchema, claimStatusSchema, wireClaimSummarySchema, claimErrorSchema, wireClaimSchema, claimRejectionSchema, modelTargetSchema, modelClaimSchema, claimBeginPayloadSchema, claimAbandonPayloadSchema, claimReorderPayloadSchema, claimHeartbeatPayloadSchema, claimHeartbeatAckPayloadSchema, claimHeartbeatBatchPayloadSchema, claimHeartbeatBatchAckPayloadSchema, updateSubscriptionPayloadSchema, subscriptionAckPayloadSchema, commitOperationTypeSchema, commitOperationSchema, presenceKindSchema, presenceActivitySchema, presenceUpdateFrameSchema, } from './schema.js';
12
+ export { targetRangeSchema, participantKindSchema, wireParticipantKindSchema, participantKindFromWire, descriptionFromMeta, targetRefSchema, onStaleModeSchema, writeGuardSchema, staleNotificationSchema, readDependencySchema, trackDependencySchema, claimStatusSchema, wireClaimSummarySchema, claimErrorSchema, wireClaimSchema, claimRejectionSchema, modelTargetSchema, modelClaimSchema, claimBeginPayloadSchema, claimAbandonPayloadSchema, claimReorderPayloadSchema, claimHeartbeatPayloadSchema, claimHeartbeatAckPayloadSchema, claimHeartbeatBatchPayloadSchema, claimHeartbeatBatchAckPayloadSchema, updateSubscriptionPayloadSchema, subscriptionAckPayloadSchema, commitOperationTypeSchema, commitOperationSchema, presenceKindSchema, presenceActivitySchema, presenceUpdateFrameSchema, } from './schema.js';
13
13
  export { defaultPolicy, capabilityPreemptPolicy, interpretConflictAxis } from '../policy/types.js';
14
14
  export type { Conflict, ConflictAxis, ConflictDecision, ConflictKind, ConflictOperation, ConflictPolicy, StaleContextConflict, ClaimHeldConflict, } from '../policy/types.js';
15
- export type { TargetRange, ParticipantKind, TargetRef, OnStaleMode, WriteGuard, StaleNotification, ReadDependency, ClaimStatus, WireClaimSummary, ClaimError, WireClaim, ClaimRejection, ModelTarget, ModelClaim, ClaimBeginPayload, ClaimAbandonPayload, ClaimReorderPayload, ClaimHeartbeatPayload, ClaimHeartbeatAckPayload, ClaimHeartbeatBatchPayload, ClaimHeartbeatBatchAckPayload, UpdateSubscriptionPayload, SubscriptionAckPayload, CommitOperationType, CommitOperation, AnyCommitOperation, PresenceKind, PresenceActivity, PresenceUpdateFrame, } from './schema.js';
15
+ export type { TargetRange, ParticipantKind, TargetRef, OnStaleMode, WriteGuard, StaleNotification, ReadDependency, TrackDependency, ClaimStatus, WireClaimSummary, ClaimError, WireClaim, ClaimRejection, ModelTarget, ModelClaim, ClaimBeginPayload, ClaimAbandonPayload, ClaimReorderPayload, ClaimHeartbeatPayload, ClaimHeartbeatAckPayload, ClaimHeartbeatBatchPayload, ClaimHeartbeatBatchAckPayload, UpdateSubscriptionPayload, SubscriptionAckPayload, CommitOperationType, CommitOperation, AnyCommitOperation, PresenceKind, PresenceActivity, PresenceUpdateFrame, } from './schema.js';
@@ -14,7 +14,7 @@ export {
14
14
  // Shared primitives
15
15
  targetRangeSchema, participantKindSchema, wireParticipantKindSchema, participantKindFromWire, descriptionFromMeta, targetRefSchema,
16
16
  // Layer 3 — optimistic stale-context
17
- onStaleModeSchema, writeGuardSchema, staleNotificationSchema, readDependencySchema,
17
+ onStaleModeSchema, writeGuardSchema, staleNotificationSchema, readDependencySchema, trackDependencySchema,
18
18
  // Layer 2 — pessimistic claim / claim-lease
19
19
  claimStatusSchema, wireClaimSummarySchema, claimErrorSchema, wireClaimSchema, claimRejectionSchema, modelTargetSchema, modelClaimSchema, claimBeginPayloadSchema, claimAbandonPayloadSchema, claimReorderPayloadSchema, claimHeartbeatPayloadSchema, claimHeartbeatAckPayloadSchema, claimHeartbeatBatchPayloadSchema, claimHeartbeatBatchAckPayloadSchema,
20
20
  // Read interest — area-of-interest navigation
@@ -181,6 +181,26 @@ export declare const readDependencySchema: z.ZodUnion<readonly [z.ZodObject<{
181
181
  }>>;
182
182
  }, z.core.$strip>]>;
183
183
  export type ReadDependency = z.infer<typeof readDependencySchema>;
184
+ /**
185
+ * A durable read-dependency — what a participant is watching so that a later
186
+ * change to it opens a {@link StaleNotification}. It is the persisted sibling of
187
+ * a {@link ReadDependency}: the same reference shape, minus the disposition (a
188
+ * track always notifies — that is what tracking is), with an optional `readAt`
189
+ * that defaults to the watermark of the commit that registered it. The row form
190
+ * watches one object; the group form watches a whole sync group ("anything in
191
+ * `deck:abc`"). Where a `ReadDependency` is checked once at commit and discarded,
192
+ * a `TrackDependency` is kept and re-checked against every future delta. See
193
+ * `packages/sync-engine/docs/groups.md` for how it drives change propagation.
194
+ */
195
+ export declare const trackDependencySchema: z.ZodUnion<readonly [z.ZodObject<{
196
+ model: z.ZodString;
197
+ id: z.ZodString;
198
+ readAt: z.ZodOptional<z.ZodNumber>;
199
+ }, z.core.$strip>, z.ZodObject<{
200
+ group: z.ZodString;
201
+ readAt: z.ZodOptional<z.ZodNumber>;
202
+ }, z.core.$strip>]>;
203
+ export type TrackDependency = z.infer<typeof trackDependencySchema>;
184
204
  /**
185
205
  * The lifecycle of a claim. When absent on the wire it means `'active'` (an
186
206
  * additive back-compat default). The server stamps `'active'` on `claim_begin`
@@ -183,6 +183,28 @@ export const readDependencySchema = z.union([
183
183
  onStale: onStaleModeSchema.optional(),
184
184
  }),
185
185
  ]);
186
+ /**
187
+ * A durable read-dependency — what a participant is watching so that a later
188
+ * change to it opens a {@link StaleNotification}. It is the persisted sibling of
189
+ * a {@link ReadDependency}: the same reference shape, minus the disposition (a
190
+ * track always notifies — that is what tracking is), with an optional `readAt`
191
+ * that defaults to the watermark of the commit that registered it. The row form
192
+ * watches one object; the group form watches a whole sync group ("anything in
193
+ * `deck:abc`"). Where a `ReadDependency` is checked once at commit and discarded,
194
+ * a `TrackDependency` is kept and re-checked against every future delta. See
195
+ * `packages/sync-engine/docs/groups.md` for how it drives change propagation.
196
+ */
197
+ export const trackDependencySchema = z.union([
198
+ z.object({
199
+ model: z.string(),
200
+ id: z.string(),
201
+ readAt: z.number().optional(),
202
+ }),
203
+ z.object({
204
+ group: z.string(),
205
+ readAt: z.number().optional(),
206
+ }),
207
+ ]);
186
208
  // ─────────────────────────────────────────────────────────────────────────
187
209
  // Layer 2 — pessimistic claims and leases
188
210
  // ─────────────────────────────────────────────────────────────────────────
@@ -161,7 +161,7 @@ export declare const ERROR_CODES: {
161
161
  readonly model_claimed: ErrorCodeSpec;
162
162
  readonly model_claimed_timeout: ErrorCodeSpec;
163
163
  readonly model_claim_not_configured: ErrorCodeSpec;
164
- readonly model_watch_not_configured: ErrorCodeSpec;
164
+ readonly model_join_not_configured: ErrorCodeSpec;
165
165
  readonly stale_context: ErrorCodeSpec;
166
166
  readonly contention_exhausted: ErrorCodeSpec;
167
167
  readonly update_aborted: ErrorCodeSpec;
@@ -157,7 +157,7 @@ export const ERROR_CODES = {
157
157
  model_claimed: wire('claim', 409, false, 'Another participant holds a claim on this row. Read `claim.state` to see who holds it, or queue behind them with a claim of your own.'),
158
158
  model_claimed_timeout: wire('claim', 409, false, 'Another participant held a claim on this row and did not release it in time. Retry, or read `claim.state` to see who holds it.'),
159
159
  model_claim_not_configured: client('claim', 'Claiming requires the collaboration runtime, which the standard Ablo({ schema, apiKey }) client wires up for every model automatically — there is no per-model claim configuration to add. This appears only when a model proxy is constructed directly without that runtime (an internal/advanced path).'),
160
- model_watch_not_configured: client('claim', 'watch() opens a presence/claim subscription and needs a live WebSocket, so it is unavailable on the HTTP transport and on model proxies built without a socket. Use the standard Ablo({ schema, apiKey }) client (default WebSocket transport).'),
160
+ model_join_not_configured: client('claim', 'join() opens a presence/claim subscription and needs a live WebSocket, so it is unavailable on the HTTP transport and on model proxies built without a socket. Use the standard Ablo({ schema, apiKey }) client (default WebSocket transport).'),
161
161
  // ── stale context / idempotency (409) ──────────────────────────────
162
162
  // Not retryable at the transport: the rejected request carries its frozen
163
163
  // `readAt`, so resending the identical payload can never succeed. Recovery
@@ -6,7 +6,7 @@
6
6
  * session-error detection, online-status checks, and the transport that carries
7
7
  * mutations to your backend. The SDK ships sensible no-op defaults where it can.
8
8
  */
9
- import type { ReadDependency, ParticipantKind } from '../coordination/schema.js';
9
+ import type { ReadDependency, TrackDependency, ParticipantKind } from '../coordination/schema.js';
10
10
  import type { CommitStatus, MutationCommitResultInput } from '../wire/commit.js';
11
11
  export interface SyncLogger {
12
12
  debug(message: string, ...args: unknown[]): void;
@@ -232,6 +232,18 @@ export interface MutationOptions {
232
232
  * footprints, §4 the read-set) for the governing convention.
233
233
  */
234
234
  reads?: ReadDependency[] | null;
235
+ /**
236
+ * Durable read-dependencies — what this write (or the record it produces) should
237
+ * keep watching. Unlike `reads`, which is checked once at commit and discarded,
238
+ * each `track` entry is persisted and re-checked against every future delta; a
239
+ * later matching change opens a `StaleNotification` for the tracking participant,
240
+ * delivered at their next commit or live to a held claim. Each entry is a row
241
+ * (`{ model, id }`) or a sync group (`{ group }`), optionally pinned to a `readAt`
242
+ * baseline (defaults to this commit's watermark).
243
+ *
244
+ * See `packages/sync-engine/docs/groups.md` for how `track` drives propagation.
245
+ */
246
+ track?: TrackDependency[] | null;
235
247
  }
236
248
  /**
237
249
  * The subset of {@link MutationOptions} that travels with each write as it is
@@ -15,7 +15,7 @@ import { type SyncStoreContract } from './context.js';
15
15
  * - **One component, one import.** Consumers write the provider
16
16
  * once at the root; nothing else needs to plumb the engine.
17
17
  * - **Multiplayer is default.** React consumers are always browsers doing
18
- * multiplayer UI, so `useWatch()` / `useAblo()` are always
18
+ * multiplayer UI, so `useJoin()` / `useAblo()` are always
19
19
  * available. No opt-in prop.
20
20
  * - **Declarative props for app glue.** `preventUnsavedChanges`,
21
21
  * `onSessionExpired`, `postBootstrap`, `resolveUsers` — each
@@ -110,11 +110,11 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
110
110
  export declare function AbloProvider<R extends SchemaRecord = SchemaRecord>(props: AbloProviderProps<R>): React.ReactElement;
111
111
  export type { EngineParticipant, ParticipantScope, ParticipantStatus };
112
112
  /**
113
- * Options for `useWatch`. The hook reuses the engine's single
113
+ * Options for `useJoin`. The hook reuses the engine's single
114
114
  * WebSocket and opens a scoped claim on it when `scope` is provided:
115
115
  * one TCP connection, N logical sub-syncgroup participants.
116
116
  */
117
- export interface UseWatchOptions {
117
+ export interface UseJoinOptions {
118
118
  readonly scope?: ParticipantScope;
119
119
  readonly ttlSeconds?: number | string | null;
120
120
  /** Tear down + don't re-join while true. */
@@ -145,7 +145,7 @@ export interface UseWatchOptions {
145
145
  }
146
146
  /** @deprecated Use `ParticipantStatus`. */
147
147
  export type MeshParticipantStatus = ParticipantStatus;
148
- export interface UseWatchReturn {
148
+ export interface UseJoinReturn {
149
149
  readonly participant: EngineParticipant | null;
150
150
  /** Everyone else on the engine's sync groups (`participant.presence.others`), bridged to React. */
151
151
  readonly peers: readonly Peer[];
@@ -159,7 +159,7 @@ export interface UseWatchReturn {
159
159
  * lifecycle status. Auto-cleans up on unmount or when `paused`
160
160
  * flips to true.
161
161
  *
162
- * `useWatch` is the React form of `ablo.<model>.watch` — scope-level
162
+ * `useJoin` is the React form of `ablo.<model>.join` — scope-level
163
163
  * read-interest + presence; returns the reactive participant facade
164
164
  * (peers/claims/status).
165
165
  *
@@ -168,10 +168,10 @@ export interface UseWatchReturn {
168
168
  * headless-bot patterns (a separate identity in the same browser
169
169
  * tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
170
170
  */
171
- export declare function useWatch(opts: UseWatchOptions): UseWatchReturn;
171
+ export declare function useJoin(opts: UseJoinOptions): UseJoinReturn;
172
172
  /**
173
173
  * Read-only presence: the OTHER participants currently visible to this
174
- * connection, bridged to React. Unlike {@link useWatch}, this does
174
+ * connection, bridged to React. Unlike {@link useJoin}, this does
175
175
  * NOT enter/leave a scope (no `update_subscription`, no warm-TTL churn) —
176
176
  * it is a pure reader of the engine's already-flowing presence stream.
177
177
  *
@@ -184,7 +184,7 @@ export declare function useWatch(opts: UseWatchOptions): UseWatchReturn;
184
184
  * Use this to answer "is anyone else here?" — e.g. suppressing live-cursor
185
185
  * broadcasts while alone — when some OTHER mount already owns the scope's
186
186
  * read interest (scope `leave` is not reference-counted, so a second
187
- * `useWatch` on the same scope would warm-drop the owner's
187
+ * `useJoin` on the same scope would warm-drop the owner's
188
188
  * subscription on unmount).
189
189
  *
190
190
  * ```ts
@@ -211,7 +211,7 @@ const EMPTY_INTENTS = Object.freeze([]);
211
211
  * lifecycle status. Auto-cleans up on unmount or when `paused`
212
212
  * flips to true.
213
213
  *
214
- * `useWatch` is the React form of `ablo.<model>.watch` — scope-level
214
+ * `useJoin` is the React form of `ablo.<model>.join` — scope-level
215
215
  * read-interest + presence; returns the reactive participant facade
216
216
  * (peers/claims/status).
217
217
  *
@@ -220,7 +220,7 @@ const EMPTY_INTENTS = Object.freeze([]);
220
220
  * headless-bot patterns (a separate identity in the same browser
221
221
  * tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
222
222
  */
223
- export function useWatch(opts) {
223
+ export function useJoin(opts) {
224
224
  const ctx = useContext(AbloInternalContext);
225
225
  const engine = ctx?.engine ?? null;
226
226
  const { paused = false } = opts;
@@ -353,7 +353,7 @@ export function useWatch(opts) {
353
353
  }
354
354
  /**
355
355
  * Read-only presence: the OTHER participants currently visible to this
356
- * connection, bridged to React. Unlike {@link useWatch}, this does
356
+ * connection, bridged to React. Unlike {@link useJoin}, this does
357
357
  * NOT enter/leave a scope (no `update_subscription`, no warm-TTL churn) —
358
358
  * it is a pure reader of the engine's already-flowing presence stream.
359
359
  *
@@ -366,7 +366,7 @@ export function useWatch(opts) {
366
366
  * Use this to answer "is anyone else here?" — e.g. suppressing live-cursor
367
367
  * broadcasts while alone — when some OTHER mount already owns the scope's
368
368
  * read interest (scope `leave` is not reference-counted, so a second
369
- * `useWatch` on the same scope would warm-drop the owner's
369
+ * `useJoin` on the same scope would warm-drop the owner's
370
370
  * subscription on unmount).
371
371
  *
372
372
  * ```ts
@@ -377,7 +377,7 @@ export function useWatch(opts) {
377
377
  export function usePeers(scope) {
378
378
  const ctx = useContext(AbloInternalContext);
379
379
  const engine = ctx?.engine ?? null;
380
- // Resolve scope → groups through the schema (same idiom as useWatch).
380
+ // Resolve scope → groups through the schema (same idiom as useJoin).
381
381
  // The stringified, sorted key is the stable effect dependency.
382
382
  const scopeKey = JSON.stringify(resolveParticipantSyncGroups(scope, engine?.schema).sort());
383
383
  const groups = useMemo(() => JSON.parse(scopeKey), [scopeKey]);
@@ -394,7 +394,7 @@ export function usePeers(scope) {
394
394
  // Plain useState + onChange — presence changes on join/leave/activity
395
395
  // only (never on cursor traffic, a separate channel), so this fires
396
396
  // rarely; a frame of stale presence is harmless (same rationale as
397
- // useWatch's peers bridge).
397
+ // useJoin's peers bridge).
398
398
  setPeers(compute());
399
399
  return presence.onChange(() => { setPeers(compute()); });
400
400
  }, [engine, scopeKey]);
@@ -41,10 +41,10 @@
41
41
  * client:
42
42
  *
43
43
  * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
44
- * useWatch({ scope }) — join a scope to get its peers and claims
44
+ * useJoin({ scope }) — join a scope to get its peers and claims
45
45
  */
46
46
  export type { DefaultSyncShape, ResolveSchema, ResolveUserMeta, ResolveModelKey, } from '../types/global.js';
47
- export { AbloProvider, useWatch, usePeers, useSync, useSyncStore, type AbloProviderProps, type ParticipantScope, type ParticipantStatus, type UseWatchOptions, type UseWatchReturn, type MeshParticipantStatus, } from './AbloProvider.js';
47
+ export { AbloProvider, useJoin, usePeers, useSync, useSyncStore, type AbloProviderProps, type ParticipantScope, type ParticipantStatus, type UseJoinOptions, type UseJoinReturn, type MeshParticipantStatus, } from './AbloProvider.js';
48
48
  export { ClientSideSuspense, type ClientSideSuspenseProps, } from './ClientSideSuspense.js';
49
49
  export { DefaultFallback } from './DefaultFallback.js';
50
50
  export type { SyncStoreContract } from './context.js';
@@ -41,10 +41,10 @@
41
41
  * client:
42
42
  *
43
43
  * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
44
- * useWatch({ scope }) — join a scope to get its peers and claims
44
+ * useJoin({ scope }) — join a scope to get its peers and claims
45
45
  */
46
46
  // ── Umbrella provider + lifecycle hooks ────────────────────────────
47
- export { AbloProvider, useWatch, usePeers, useSync, useSyncStore, } from './AbloProvider.js';
47
+ export { AbloProvider, useJoin, usePeers, useSync, useSyncStore, } from './AbloProvider.js';
48
48
  export { ClientSideSuspense, } from './ClientSideSuspense.js';
49
49
  export { DefaultFallback } from './DefaultFallback.js';
50
50
  // ── Status + errors + identity ─────────────────────────────────────
@@ -30,7 +30,7 @@ export { syncDeltaActionSchema, wireDeltaDataSchema, participantRefSchema, syncD
30
30
  export { model, scopeKindOf, type ModelDef, type ModelOptions, type LoadStrategy, type PersistOptions, type RelationRecord, type GrantsRef, type ConflictAxis, } from './model.js';
31
31
  export { coordination, humansOverwrite, humansReject, humansNotify, agentsOverwrite, agentsReject, agentsNotify, systemOverwrite, systemReject, systemNotify, type ConflictRule, } from './coordination.js';
32
32
  export { mutable, readOnly, type SugarOptions } from './sugar.js';
33
- export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type Model, type Row, type InferModel, type InferCreate, type InferRow, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, composeEntitySyncGroups, intersectRequestedWithAllowed, type IdentityRole, type IdentityContext, type IdentityRoleSource, type EntityRole, type EntityContext, type EntityRoleSource, type RoleSource, type RoleContext, type SyncGroup, type SyncGroupInput, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, type GroupsInput, type TenantContextMapping, type TenantContextSource, RESERVED_TENANT_CONTEXT_GUCS, } from './schema.js';
33
+ export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type Model, type Row, type InferModel, type InferCreate, type InferRow, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, composeEntitySyncGroups, intersectRequestedWithAllowed, type IdentityRole, type IdentityContext, type IdentityRoleSource, type EntityRole, type EntityContext, type EntityRoleSource, type RoleSource, type RoleContext, type SyncGroup, type SyncGroupInput, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, type GroupsInput, type SessionSettings, type SessionSettingSource, RESERVED_SESSION_SETTINGS, } from './schema.js';
34
34
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, type SchemaJSON, type ModelJSON, type RelationJSON, } from './serialize.js';
35
35
  export { selectModels, omitModels } from './select.js';
36
36
  export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, type ProvisionPlan, type MigrationPlan, } from './ddl.js';
@@ -56,7 +56,7 @@ export { coordination, humansOverwrite, humansReject, humansNotify, agentsOverwr
56
56
  // sensible defaults for everything else.
57
57
  export { mutable, readOnly } from './sugar.js';
58
58
  // Schema definition + type inference
59
- export { defineSchema, composeIdentitySyncGroups, composeEntitySyncGroups, intersectRequestedWithAllowed, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, RESERVED_TENANT_CONTEXT_GUCS, } from './schema.js';
59
+ export { defineSchema, composeIdentitySyncGroups, composeEntitySyncGroups, intersectRequestedWithAllowed, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, RESERVED_SESSION_SETTINGS, } from './schema.js';
60
60
  // Schema ⇄ JSON — serialize a schema for transport and rebuild it on the far side.
61
61
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, } from './serialize.js';
62
62
  // Schema projection — derive an app's subset from one canonical schema.
@@ -30,36 +30,32 @@ export type CasingConvention = 'snake_case' | 'camelCase';
30
30
  export type CasingFn = (jsField: string) => string;
31
31
  /** `defineSchema`'s casing option. Identity when unset. */
32
32
  export type Casing = CasingConvention | CasingFn;
33
- /** Options for `defineSchema`. */
34
33
  /**
35
- * A server-authored context value that can fill a customer's row-level-security
36
- * GUC. Deliberately a closed set — every member is resolved by Ablo from the
37
- * authenticated `ek_` and the plane, never from client-supplied data — so a
38
- * mapping can forward the tenant identity Ablo already trusts but can never
34
+ * A server-authored identity value that can fill one of a customer's Postgres
35
+ * session settings. Deliberately a closed set — every member is resolved by Ablo
36
+ * from the authenticated `ek_` and the plane, never from client-supplied data —
37
+ * so a mapping can forward the tenant identity Ablo already trusts but can never
39
38
  * widen a writer's scope. Mirrors the fields the engine sets on the direct-write
40
39
  * connection (`app.current_org_id`, `app.current_project_id`, …).
41
40
  */
42
- export type TenantContextSource = 'orgId' | 'projectId' | 'environment' | 'sandboxId' | 'participantId' | 'participantKind';
41
+ export type SessionSettingSource = 'orgId' | 'projectId' | 'environment' | 'sandboxId' | 'participantId' | 'participantKind';
43
42
  /**
44
- * Maps a Postgres session GUC the customer's RLS policies read to the Ablo
45
- * context value that fills it. See {@link DefineSchemaOptions.tenantContext} and
46
- * ADR 0011.
43
+ * A map from a Postgres session-setting name the customer's RLS policies read to
44
+ * the Ablo identity that fills it — e.g. `{ 'app.current_org': 'orgId' }`. The
45
+ * setting name is the key (it takes exactly one source), so a duplicate is
46
+ * unrepresentable rather than validated away. See
47
+ * {@link DefineSchemaOptions.sessionSettings} and ADR 0011.
47
48
  */
48
- export interface TenantContextMapping {
49
- /** The GUC name the customer's policies read, e.g. `app.current_app_org_id`. */
50
- readonly guc: string;
51
- /** Which server-authored context value fills it. */
52
- readonly from: TenantContextSource;
53
- }
49
+ export type SessionSettings = Readonly<Record<string, SessionSettingSource>>;
54
50
  /**
55
- * GUCs Ablo already sets on its direct-write connection. A `tenantContext`
56
- * mapping FORWARDS Ablo's trusted context into a customer's own policy GUC — it
57
- * may never reassign one of these, which would let a schema push relax the
58
- * engine's own scoping, timeouts, or `row_security`. Shared with the engine's
59
- * direct-write seam so authoring-time validation and the runtime guard read one
60
- * list. See ADR 0011.
51
+ * Session settings Ablo already sets on its direct-write connection. A
52
+ * `sessionSettings` entry FORWARDS Ablo's trusted context into a setting the
53
+ * customer's own policies read — it may never reassign one of these, which would
54
+ * let a schema push relax the engine's own scoping, timeouts, or `row_security`.
55
+ * Shared with the engine's direct-write seam so authoring-time validation and
56
+ * the runtime guard read one list. See ADR 0011.
61
57
  */
62
- export declare const RESERVED_TENANT_CONTEXT_GUCS: readonly string[];
58
+ export declare const RESERVED_SESSION_SETTINGS: readonly string[];
63
59
  export interface DefineSchemaOptions {
64
60
  /**
65
61
  * How to translate camelCase JS field names into database column
@@ -90,26 +86,27 @@ export interface DefineSchemaOptions {
90
86
  */
91
87
  readonly identityRoles?: readonly IdentityRole[];
92
88
  /**
93
- * Declares how Ablo's direct-write connection carries tenant context into the
94
- * GUCs your row-level-security policies read (ADR 0011). Before every direct
95
- * write, Ablo already sets a fixed bundle (`app.current_org_id`,
96
- * `app.current_project_id`, …) with `SET LOCAL`. If your policies read a
97
- * differently-named GUC that your own app sets per-connection — e.g. a
98
- * restrictive policy on `current_setting('app.current_app_org_id')` — declare
99
- * the mapping here so Ablo forwards its trusted context into that GUC too, and
100
- * your RLS governs Ablo's writes:
89
+ * Extra Postgres session settings Ablo's direct-write connection applies from
90
+ * your authenticated identity, so your row-level-security policies read them
91
+ * (ADR 0011). Before every direct write, Ablo already `SET LOCAL`s a fixed
92
+ * bundle (`app.current_org_id`, `app.current_project_id`, …). If your policies
93
+ * read a differently-named setting your own app sets per-connection — e.g. a
94
+ * restrictive policy on `current_setting('app.current_org')` — map that
95
+ * setting to the identity that fills it, and Ablo sets it too:
101
96
  *
102
97
  * ```ts
103
98
  * defineSchema({ ... }, {
104
- * tenantContext: [{ guc: 'app.current_app_org_id', from: 'orgId' }],
99
+ * sessionSettings: { 'app.current_org': 'orgId' },
105
100
  * })
106
101
  * ```
107
102
  *
108
- * Do NOT carve a policy exception for the writer role that exempts exactly
109
- * the writes you want governed. Leave unset when your policies read the GUCs
110
- * Ablo already sets (or when a table has no RLS). Empty array when unset.
103
+ * The key is the setting name your policies read; the value is a closed set of
104
+ * identities Ablo authenticates, so a mapping can narrow what the writer sees
105
+ * but never widen it. Do NOT carve a policy exception for the writer role
106
+ * that exempts exactly the writes you want governed. Leave unset when your
107
+ * policies read the settings Ablo already applies (or when a table has no RLS).
111
108
  */
112
- readonly tenantContext?: readonly TenantContextMapping[];
109
+ readonly sessionSettings?: SessionSettings;
113
110
  }
114
111
  /** A record of model names → model definitions */
115
112
  export type SchemaRecord = Record<string, ModelDef>;
@@ -165,12 +162,12 @@ export interface Schema<S extends SchemaRecord = SchemaRecord> {
165
162
  */
166
163
  readonly identityRoles: readonly IdentityRole[];
167
164
  /**
168
- * Tenant-context GUC mappings registered via `defineSchema({...},
169
- * { tenantContext })` (ADR 0011). The engine's direct-write path reads this to
170
- * `SET LOCAL` each declared GUC from server-authored context before applying
171
- * DML, so the customer's RLS governs Ablo's writes. Empty array when unset.
165
+ * Session settings registered via `defineSchema({...}, { sessionSettings })`
166
+ * (ADR 0011). The engine's direct-write path reads this to `SET LOCAL` each
167
+ * named setting from server-authored context before applying DML, so the
168
+ * customer's RLS governs Ablo's writes. Empty object when unset.
172
169
  */
173
- readonly tenantContext: readonly TenantContextMapping[];
170
+ readonly sessionSettings: SessionSettings;
174
171
  /**
175
172
  * Set only on a projection produced by `selectModels`/`omitModels`: the
176
173
  * content hash of the FULL source schema the subset was cut from. A subset
@@ -45,14 +45,14 @@ function camelToSnake(identifier) {
45
45
  return identifier.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
46
46
  }
47
47
  /**
48
- * GUCs Ablo already sets on its direct-write connection. A `tenantContext`
49
- * mapping FORWARDS Ablo's trusted context into a customer's own policy GUC — it
50
- * may never reassign one of these, which would let a schema push relax the
51
- * engine's own scoping, timeouts, or `row_security`. Shared with the engine's
52
- * direct-write seam so authoring-time validation and the runtime guard read one
53
- * list. See ADR 0011.
48
+ * Session settings Ablo already sets on its direct-write connection. A
49
+ * `sessionSettings` entry FORWARDS Ablo's trusted context into a setting the
50
+ * customer's own policies read — it may never reassign one of these, which would
51
+ * let a schema push relax the engine's own scoping, timeouts, or `row_security`.
52
+ * Shared with the engine's direct-write seam so authoring-time validation and
53
+ * the runtime guard read one list. See ADR 0011.
54
54
  */
55
- export const RESERVED_TENANT_CONTEXT_GUCS = [
55
+ export const RESERVED_SESSION_SETTINGS = [
56
56
  'statement_timeout',
57
57
  'lock_timeout',
58
58
  'row_security',
@@ -221,45 +221,39 @@ export function defineSchema(models, options) {
221
221
  resolvedModels[name] = { ...def, typename, tableName, persist };
222
222
  }
223
223
  validateSyncGroupSchema(resolvedModels);
224
- validateTenantContext(options?.tenantContext ?? []);
224
+ validateSessionSettings(options?.sessionSettings ?? {});
225
225
  return {
226
226
  // Cast back to S: we only added values to optional fields that were
227
227
  // already part of ModelDef, so the shape is structurally unchanged.
228
228
  models: resolvedModels,
229
229
  validators: validators,
230
230
  identityRoles: options?.identityRoles ?? [],
231
- tenantContext: options?.tenantContext ?? [],
231
+ sessionSettings: options?.sessionSettings ?? {},
232
232
  };
233
233
  }
234
234
  /**
235
- * Reject tenant-context mappings that couldn't do what the author intends —
235
+ * Reject session-setting mappings that couldn't do what the author intends —
236
236
  * caught here at definition time rather than silently dropped on the write path.
237
- * A mapping must name a non-empty customer GUC, must not reassign one of the
238
- * engine's reserved GUCs, and each GUC may be mapped at most once.
237
+ * Each key must name a non-empty setting the customer's policies read, and must
238
+ * not reassign one Ablo already manages. Uniqueness needs no check: the map is
239
+ * keyed by the setting name, so a duplicate is unrepresentable.
239
240
  */
240
- function validateTenantContext(mappings) {
241
- const seen = new Set();
242
- for (const { guc } of mappings) {
243
- const name = guc.trim();
241
+ function validateSessionSettings(settings) {
242
+ for (const setting of Object.keys(settings)) {
243
+ const name = setting.trim();
244
244
  if (name === '') {
245
- throw new AbloValidationError(`[defineSchema] tenantContext: a mapping has an empty \`guc\`. Name the ` +
246
- `Postgres setting your row-level-security policies read, e.g. ` +
247
- `\`{ guc: 'app.current_app_org_id', from: 'orgId' }\`.`, { code: 'schema_definition_invalid', param: 'tenantContext' });
245
+ throw new AbloValidationError(`[defineSchema] sessionSettings: a key is empty. Name the Postgres ` +
246
+ `setting your row-level-security policies read, e.g. ` +
247
+ `\`{ 'app.current_org': 'orgId' }\`.`, { code: 'schema_definition_invalid', param: 'sessionSettings' });
248
248
  }
249
- if (RESERVED_TENANT_CONTEXT_GUCS.includes(name)) {
250
- throw new AbloValidationError(`[defineSchema] tenantContext: \`${name}\` is a setting Ablo already ` +
249
+ if (RESERVED_SESSION_SETTINGS.includes(name)) {
250
+ throw new AbloValidationError(`[defineSchema] sessionSettings: \`${name}\` is a setting Ablo already ` +
251
251
  `manages on its write connection, so a mapping can't reassign it — ` +
252
252
  `that would let a schema relax the engine's own scoping. Point your ` +
253
- `policies at a GUC your app owns (e.g. \`app.current_app_org_id\`) and ` +
254
- `map Ablo's trusted context into that instead. Reserved: ` +
255
- `${RESERVED_TENANT_CONTEXT_GUCS.join(', ')}.`, { code: 'schema_definition_invalid', param: `tenantContext.${name}` });
253
+ `policies at a setting your app owns (e.g. \`app.current_org\`) and ` +
254
+ `map Ablo's trusted identity into that instead. Reserved: ` +
255
+ `${RESERVED_SESSION_SETTINGS.join(', ')}.`, { code: 'schema_definition_invalid', param: `sessionSettings.${name}` });
256
256
  }
257
- if (seen.has(name)) {
258
- throw new AbloValidationError(`[defineSchema] tenantContext: \`${name}\` is mapped more than once. ` +
259
- `A GUC takes exactly one source — keep the mapping you mean and drop ` +
260
- `the duplicate.`, { code: 'schema_definition_invalid', param: `tenantContext.${name}` });
261
- }
262
- seen.add(name);
263
257
  }
264
258
  }
265
259
  /**
@@ -52,7 +52,7 @@ export function selectModels(schema, keys) {
52
52
  models: models,
53
53
  validators: validators,
54
54
  identityRoles: schema.identityRoles,
55
- tenantContext: schema.tenantContext,
55
+ sessionSettings: schema.sessionSettings,
56
56
  // Record the full source's hash so the drift check can recognize this subset
57
57
  // as current against a server running that full schema. Prefer the source's
58
58
  // OWN `sourceSchemaHash` when it is itself a projection, so a subset-of-a-
@@ -31,7 +31,7 @@ import type { Tenancy } from './tenancy.js';
31
31
  import type { ModelResidency } from './residency.js';
32
32
  import type { GrantsRef, LoadStrategy, PersistOptions, AutoFillRule, ConflictAxis } from './model.js';
33
33
  import type { RelationType } from './relation.js';
34
- import { type Schema, type IdentityRole, type EntityRole, type TenantContextMapping } from './schema.js';
34
+ import { type Schema, type IdentityRole, type EntityRole, type SessionSettings } from './schema.js';
35
35
  /** Current schema-JSON envelope version. Bump this on a breaking change to the
36
36
  * JSON shape itself — not to a user's schema. */
37
37
  declare const SCHEMA_JSON_VERSION: 3;
@@ -75,8 +75,8 @@ export interface SchemaJSON {
75
75
  readonly v: typeof SCHEMA_JSON_VERSION;
76
76
  readonly models: Record<string, ModelJSON>;
77
77
  readonly identityRoles: readonly IdentityRole[];
78
- /** Optional so schemas pushed before ADR 0011 still parse (defaults to `[]`). */
79
- readonly tenantContext?: readonly TenantContextMapping[];
78
+ /** Optional so schemas pushed before ADR 0011 still parse (defaults to `{}`). */
79
+ readonly sessionSettings?: SessionSettings;
80
80
  }
81
81
  /**
82
82
  * Project a `Schema` to its JSON form. Drops the client-only closures
@@ -93,7 +93,9 @@ export function toSchemaJSON(schema) {
93
93
  v: SCHEMA_JSON_VERSION,
94
94
  models,
95
95
  identityRoles: schema.identityRoles,
96
- ...(schema.tenantContext.length > 0 ? { tenantContext: schema.tenantContext } : {}),
96
+ ...(Object.keys(schema.sessionSettings).length > 0
97
+ ? { sessionSettings: schema.sessionSettings }
98
+ : {}),
97
99
  };
98
100
  }
99
101
  /** Serialize a `Schema` to a JSON string (the `ablo push` payload). */
@@ -205,7 +207,7 @@ export function fromSchemaJSON(json) {
205
207
  models: models,
206
208
  validators: validators,
207
209
  identityRoles: json.identityRoles,
208
- tenantContext: json.tenantContext ?? [],
210
+ sessionSettings: json.sessionSettings ?? {},
209
211
  };
210
212
  }
211
213
  /** Parse a `Schema` from a JSON string (inverse of {@link serializeSchema}). */
@@ -17,7 +17,7 @@ import type { ParticipantKind, ConfirmationState } from '../schema/syncDeltaRow.
17
17
  import type { ParticipantRef } from '../wire/delta.js';
18
18
  import type { CommitExecutionResultInput } from '../wire/commit.js';
19
19
  import type { Environment } from '../environment.js';
20
- import type { ReadDependency } from '../coordination/schema.js';
20
+ import type { ReadDependency, TrackDependency } from '../coordination/schema.js';
21
21
  export interface CommitContext {
22
22
  participantId: string;
23
23
  /**
@@ -97,6 +97,13 @@ export interface CommitContext {
97
97
  * validates only the rows being written. Omit it to check the write targets alone.
98
98
  */
99
99
  reads?: ReadDependency[] | null;
100
+ /**
101
+ * Durable read-dependencies to persist for this commit's participant. Each entry
102
+ * is kept in `track_dependencies` and re-checked against every future delta; a
103
+ * later match opens a `StaleNotification` delivered out of band. Distinct from
104
+ * `reads`, which is checked once here and discarded.
105
+ */
106
+ track?: TrackDependency[] | null;
100
107
  }
101
108
  /**
102
109
  * The server execution receipt persisted in `mutation_log`. Its runtime schema
package/dist/surface.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * tuple, so it is the one list of model-verb names a generated summary can
20
20
  * describe.
21
21
  */
22
- export declare const PUBLIC_MODEL_VERBS: readonly ["retrieve", "list", "get", "getAll", "getCount", "create", "update", "delete", "claim", "watch", "onChange"];
22
+ export declare const PUBLIC_MODEL_VERBS: readonly ["retrieve", "list", "get", "getAll", "getCount", "create", "update", "delete", "claim", "track", "join", "onChange"];
23
23
  /**
24
24
  * The option keys accepted by `list`, `getAll`, and `onChange`, matching the
25
25
  * keys of {@link LocalReadOptions}. Note that the lifecycle filter is named
package/dist/surface.js CHANGED
@@ -30,7 +30,8 @@ export const PUBLIC_MODEL_VERBS = [
30
30
  'update',
31
31
  'delete',
32
32
  'claim',
33
- 'watch',
33
+ 'track',
34
+ 'join',
34
35
  'onChange',
35
36
  ];
36
37
  // ── the read/list query option surface ─────────────────────────────────────