@abloatai/ablo 0.27.0 → 0.28.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 (73) hide show
  1. package/CHANGELOG.md +29 -3
  2. package/README.md +1 -1
  3. package/dist/BaseSyncedStore.js +8 -9
  4. package/dist/Database.d.ts +14 -1
  5. package/dist/Database.js +225 -28
  6. package/dist/Model.d.ts +17 -0
  7. package/dist/Model.js +32 -1
  8. package/dist/SyncClient.d.ts +10 -6
  9. package/dist/SyncClient.js +379 -76
  10. package/dist/adapters/inMemoryStorage.d.ts +1 -0
  11. package/dist/adapters/inMemoryStorage.js +12 -0
  12. package/dist/cli.cjs +6 -2
  13. package/dist/client/Ablo.d.ts +24 -1
  14. package/dist/client/Ablo.js +4 -3
  15. package/dist/client/ApiClient.js +309 -43
  16. package/dist/client/createInternalComponents.d.ts +2 -0
  17. package/dist/client/createInternalComponents.js +1 -1
  18. package/dist/client/httpClient.d.ts +2 -0
  19. package/dist/client/modelRegistration.js +11 -0
  20. package/dist/client/options.d.ts +23 -0
  21. package/dist/client/wsMutationExecutor.d.ts +3 -2
  22. package/dist/client/wsMutationExecutor.js +3 -2
  23. package/dist/commit/contract.d.ts +493 -0
  24. package/dist/commit/contract.js +187 -0
  25. package/dist/commit/index.d.ts +6 -0
  26. package/dist/commit/index.js +5 -0
  27. package/dist/core/StoreManager.d.ts +2 -0
  28. package/dist/core/StoreManager.js +12 -0
  29. package/dist/errorCodes.js +6 -2
  30. package/dist/index.d.ts +6 -0
  31. package/dist/index.js +2 -0
  32. package/dist/interfaces/index.d.ts +2 -2
  33. package/dist/mutators/UndoManager.d.ts +2 -0
  34. package/dist/mutators/UndoManager.js +32 -0
  35. package/dist/react/useAblo.d.ts +6 -4
  36. package/dist/react/useAblo.js +25 -3
  37. package/dist/schema/index.d.ts +1 -1
  38. package/dist/schema/schema.d.ts +31 -1
  39. package/dist/stores/ObjectStore.d.ts +14 -1
  40. package/dist/stores/ObjectStore.js +27 -4
  41. package/dist/stores/ObjectStoreContract.d.ts +2 -0
  42. package/dist/surface.d.ts +1 -1
  43. package/dist/surface.js +2 -0
  44. package/dist/sync/SyncWebSocket.d.ts +2 -1
  45. package/dist/sync/persistedPrefix.d.ts +12 -0
  46. package/dist/sync/persistedPrefix.js +22 -0
  47. package/dist/testing/index.d.ts +2 -0
  48. package/dist/testing/index.js +1 -0
  49. package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
  50. package/dist/testing/mocks/FakeDatabase.js +10 -0
  51. package/dist/testing/mocks/MockWebSocket.d.ts +2 -1
  52. package/dist/transactions/TransactionQueue.d.ts +66 -8
  53. package/dist/transactions/TransactionQueue.js +607 -89
  54. package/dist/transactions/commitEnvelope.d.ts +132 -0
  55. package/dist/transactions/commitEnvelope.js +139 -0
  56. package/dist/transactions/commitOutboxStore.d.ts +32 -0
  57. package/dist/transactions/commitOutboxStore.js +26 -0
  58. package/dist/transactions/commitPayload.d.ts +15 -0
  59. package/dist/transactions/commitPayload.js +6 -0
  60. package/dist/transactions/httpCommitEnvelope.d.ts +43 -0
  61. package/dist/transactions/httpCommitEnvelope.js +179 -0
  62. package/dist/transactions/replayValidation.d.ts +83 -0
  63. package/dist/transactions/replayValidation.js +46 -1
  64. package/dist/wire/bootstrapReason.d.ts +9 -0
  65. package/dist/wire/bootstrapReason.js +8 -0
  66. package/dist/wire/frames.d.ts +236 -0
  67. package/dist/wire/frames.js +21 -0
  68. package/dist/wire/index.d.ts +4 -2
  69. package/dist/wire/index.js +2 -1
  70. package/docs/api.md +10 -10
  71. package/docs/coordination.md +2 -2
  72. package/docs/mcp.md +1 -1
  73. package/package.json +7 -2
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The transport-independent v2 commit contract.
3
+ *
4
+ * A commit is a caller's atomic write intent. It contains ordered operations,
5
+ * a stable idempotency key, and explicit write preconditions. HTTP and
6
+ * WebSocket transports wrap these schemas; they do not redefine their data.
7
+ * Every public data type in this module is inferred from the Zod schema that
8
+ * validates it at a trust boundary.
9
+ */
10
+ import { z } from 'zod';
11
+ import { ERROR_CODES } from '../errorCodes.js';
12
+ /** Current version of the transport-independent commit contract. */
13
+ export const COMMIT_CONTRACT_VERSION = 2;
14
+ // ── Opaque identifiers ───────────────────────────────────────────────────
15
+ /** Stable identity of one logical commit. Reused unchanged for every retry. */
16
+ export const idempotencyKeySchema = z.string().min(1).max(255).brand();
17
+ /** Server-issued identity of a durable commit execution. */
18
+ export const commitIdSchema = z.uuid().brand();
19
+ /** Stable identity of one operation within a commit. */
20
+ export const operationIdSchema = z.string().min(1).max(255).brand();
21
+ /** Identity of a pessimistic claim whose generation fences a write. */
22
+ export const claimIdSchema = z.string().min(1).max(255).brand();
23
+ /**
24
+ * Internal committed-log offset. Decimal strings preserve PostgreSQL BIGINT
25
+ * precision across JavaScript and JSON boundaries.
26
+ */
27
+ export const syncOffsetSchema = z
28
+ .string()
29
+ .regex(/^(0|[1-9]\d*)$/, 'Expected a non-negative decimal sync offset')
30
+ .brand();
31
+ /** Opaque resumable cursor returned to clients; consumers must not inspect it. */
32
+ export const syncCursorSchema = z.string().min(1).max(4096).brand();
33
+ // ── JSON values ──────────────────────────────────────────────────────────
34
+ /** JSON-safe value accepted by a persisted or fingerprinted commit payload. */
35
+ export const jsonValueSchema = z.json();
36
+ /** JSON object accepted for entity values, patches, and structured details. */
37
+ export const jsonObjectSchema = z.record(z.string(), jsonValueSchema);
38
+ const nonEmptyJsonObjectSchema = jsonObjectSchema.refine((value) => Object.keys(value).length > 0, 'Expected at least one changed field');
39
+ // ── Intent ───────────────────────────────────────────────────────────────
40
+ /** Model and row addressed by an operation. */
41
+ export const entityRefSchema = z.strictObject({
42
+ model: z.string().min(1).max(255),
43
+ id: z.string().min(1).max(1024),
44
+ });
45
+ const fieldPathSchema = z.string().min(1).max(1024);
46
+ /**
47
+ * A condition that must still hold when an operation is applied. Preconditions
48
+ * are data rather than option flags so they are included in the request
49
+ * fingerprint and can be reported precisely when a commit conflicts.
50
+ */
51
+ export const writePreconditionSchema = z.discriminatedUnion('type', [
52
+ z.strictObject({
53
+ type: z.literal('unchanged_since'),
54
+ cursor: syncOffsetSchema,
55
+ fields: z.array(fieldPathSchema).min(1).max(256).optional(),
56
+ }),
57
+ z.strictObject({
58
+ type: z.literal('version_matches'),
59
+ version: z.number().int().nonnegative(),
60
+ }),
61
+ z.strictObject({
62
+ type: z.literal('claim_fence'),
63
+ claimId: claimIdSchema,
64
+ generation: z.number().int().positive(),
65
+ }),
66
+ ]);
67
+ const operationBaseShape = {
68
+ operationId: operationIdSchema,
69
+ target: entityRefSchema,
70
+ preconditions: z.array(writePreconditionSchema).max(16).default([]),
71
+ };
72
+ /**
73
+ * One ordered entity change within a commit. The discriminant makes illegal
74
+ * combinations unrepresentable: only create carries `value`, only patch
75
+ * carries `changes`, and destructive actions carry neither.
76
+ */
77
+ export const commitOperationSchema = z.discriminatedUnion('type', [
78
+ z.strictObject({
79
+ ...operationBaseShape,
80
+ type: z.literal('create'),
81
+ value: jsonObjectSchema,
82
+ }),
83
+ z.strictObject({
84
+ ...operationBaseShape,
85
+ type: z.literal('patch'),
86
+ changes: nonEmptyJsonObjectSchema,
87
+ }),
88
+ z.strictObject({
89
+ ...operationBaseShape,
90
+ type: z.literal('delete'),
91
+ }),
92
+ z.strictObject({
93
+ ...operationBaseShape,
94
+ type: z.literal('archive'),
95
+ }),
96
+ z.strictObject({
97
+ ...operationBaseShape,
98
+ type: z.literal('unarchive'),
99
+ }),
100
+ ]);
101
+ /** Semantic lineage included in the durable intent and its fingerprint. */
102
+ export const commitMetadataSchema = z.strictObject({
103
+ label: z.string().min(1).max(255).optional(),
104
+ causedByTaskId: z.string().min(1).max(255).optional(),
105
+ });
106
+ const commitIntentShape = {
107
+ schemaVersion: z.literal(COMMIT_CONTRACT_VERSION),
108
+ operations: z.array(commitOperationSchema).min(1).max(500),
109
+ metadata: commitMetadataSchema.optional(),
110
+ };
111
+ /** The portion of a request that is fingerprinted, preserving operation order. */
112
+ export const commitIntentSchema = z.strictObject(commitIntentShape);
113
+ /** A caller's stable idempotency identity plus the intent it identifies. */
114
+ export const commitRequestSchema = z.strictObject({
115
+ ...commitIntentShape,
116
+ idempotencyKey: idempotencyKeySchema,
117
+ });
118
+ /** Returns the exact semantic value that an idempotency fingerprint covers. */
119
+ export function commitIntentOf(request) {
120
+ const { idempotencyKey, ...intent } = request;
121
+ void idempotencyKey;
122
+ return intent;
123
+ }
124
+ // ── Result ───────────────────────────────────────────────────────────────
125
+ const registeredWireErrorCodes = Object.entries(ERROR_CODES)
126
+ .filter(([, spec]) => spec.surface === 'wire')
127
+ .map(([code]) => code);
128
+ const policyErrorCodeSchema = z.custom((value) => typeof value === 'string' && /^policy:.+$/.test(value), 'Expected a registered error code or policy:<reason>');
129
+ /** Machine-readable failure code carried by a rejected v2 commit. */
130
+ export const commitErrorCodeSchema = z.union([
131
+ z.enum(registeredWireErrorCodes),
132
+ policyErrorCodeSchema,
133
+ ]);
134
+ /** Structured capability a rejected commit would require on retry. */
135
+ export const requiredCapabilitySchema = z.strictObject({
136
+ scope: z.string().min(1),
137
+ constraints: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional(),
138
+ issuer: z.string().min(1).optional(),
139
+ ttlSeconds: z.number().int().positive().optional(),
140
+ nonce: z.string().min(1).optional(),
141
+ });
142
+ /** Structured terminal error for a commit that did not apply. */
143
+ export const commitErrorSchema = z.strictObject({
144
+ code: commitErrorCodeSchema,
145
+ message: z.string().min(1),
146
+ field: fieldPathSchema.optional(),
147
+ requiredCapability: requiredCapabilitySchema.optional(),
148
+ details: jsonObjectSchema.optional(),
149
+ });
150
+ /** One failed precondition and the smallest current state needed to reconcile. */
151
+ export const commitConflictSchema = z.strictObject({
152
+ operationId: operationIdSchema,
153
+ target: entityRefSchema,
154
+ fields: z.array(fieldPathSchema).max(256),
155
+ currentValues: jsonObjectSchema,
156
+ observedAt: syncOffsetSchema,
157
+ });
158
+ const commitReceiptBaseShape = {
159
+ object: z.literal('commit_receipt'),
160
+ schemaVersion: z.literal(COMMIT_CONTRACT_VERSION),
161
+ idempotencyKey: idempotencyKeySchema,
162
+ commitId: commitIdSchema,
163
+ };
164
+ /**
165
+ * Terminal result of a commit execution. Expected outcomes are a discriminated
166
+ * union, so committed, conflicted, and rejected results cannot expose fields
167
+ * belonging to another state.
168
+ */
169
+ export const commitReceiptSchema = z.discriminatedUnion('status', [
170
+ z.strictObject({
171
+ ...commitReceiptBaseShape,
172
+ status: z.literal('committed'),
173
+ syncCursor: syncCursorSchema,
174
+ operationCount: z.number().int().min(1).max(500),
175
+ }),
176
+ z.strictObject({
177
+ ...commitReceiptBaseShape,
178
+ status: z.literal('conflicted'),
179
+ syncCursor: syncCursorSchema,
180
+ conflicts: z.array(commitConflictSchema).min(1).max(500),
181
+ }),
182
+ z.strictObject({
183
+ ...commitReceiptBaseShape,
184
+ status: z.literal('rejected'),
185
+ error: commitErrorSchema,
186
+ }),
187
+ ]);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Stable commit-contract entry point. Import from `@abloatai/ablo/commit`
3
+ * when validating, persisting, or fingerprinting v2 commit data.
4
+ */
5
+ export { COMMIT_CONTRACT_VERSION, idempotencyKeySchema, commitIdSchema, operationIdSchema, claimIdSchema, syncOffsetSchema, syncCursorSchema, jsonValueSchema, jsonObjectSchema, entityRefSchema, writePreconditionSchema, commitOperationSchema, commitMetadataSchema, commitIntentSchema, commitRequestSchema, commitIntentOf, commitErrorCodeSchema, requiredCapabilitySchema, commitErrorSchema, commitConflictSchema, commitReceiptSchema, } from './contract.js';
6
+ export type { IdempotencyKey, CommitId, OperationId, ClaimId, SyncOffset, SyncCursor, JsonValue, JsonObject, EntityRef, WritePrecondition, CommitOperationInput, CommitOperation, CommitMetadata, CommitIntentInput, CommitIntent, CommitRequestInput, CommitRequest, CommitErrorCode, RequiredCapability, CommitError, CommitConflict, CommitReceiptInput, CommitReceipt, CommittedCommitReceipt, ConflictedCommitReceipt, RejectedCommitReceipt, } from './contract.js';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Stable commit-contract entry point. Import from `@abloatai/ablo/commit`
3
+ * when validating, persisting, or fingerprinting v2 commit data.
4
+ */
5
+ export { COMMIT_CONTRACT_VERSION, idempotencyKeySchema, commitIdSchema, operationIdSchema, claimIdSchema, syncOffsetSchema, syncCursorSchema, jsonValueSchema, jsonObjectSchema, entityRefSchema, writePreconditionSchema, commitOperationSchema, commitMetadataSchema, commitIntentSchema, commitRequestSchema, commitIntentOf, commitErrorCodeSchema, requiredCapabilitySchema, commitErrorSchema, commitConflictSchema, commitReceiptSchema, } from './contract.js';
@@ -21,6 +21,8 @@ import { LoadStrategy } from '../types/index.js';
21
21
  */
22
22
  export declare class StoreManager {
23
23
  private stores;
24
+ /** Strict-durability wrapper for the client write journal and commit outbox. */
25
+ private transactionStore;
24
26
  private syncactionStore;
25
27
  private db;
26
28
  private isInitialized;
@@ -22,6 +22,8 @@ import { AbloValidationError } from '../errors.js';
22
22
  */
23
23
  export class StoreManager {
24
24
  stores = new Map();
25
+ /** Strict-durability wrapper for the client write journal and commit outbox. */
26
+ transactionStore = null;
25
27
  syncactionStore = null;
26
28
  db = null;
27
29
  isInitialized = false;
@@ -46,6 +48,12 @@ export class StoreManager {
46
48
  for (const modelName of allModels) {
47
49
  await this.createStoreForModel(modelName);
48
50
  }
51
+ // Special stores are created during the IDB upgrade, but unlike model
52
+ // stores they have no registry metadata and therefore need an explicit
53
+ // runtime wrapper. Outbox acknowledgement must mean disk-backed, so this
54
+ // store opts into strict durability rather than the cache stores' relaxed
55
+ // mode.
56
+ this.transactionStore = new ObjectStore(this.db, '__transactions', '__transactions', { loadStrategy: LoadStrategy.instant }, 'strict');
49
57
  // Initialize SyncactionStore
50
58
  this.syncactionStore = new SyncActionStore(this.db);
51
59
  await this.syncactionStore.initialize();
@@ -142,6 +150,9 @@ export class StoreManager {
142
150
  * Get ObjectStore for a model
143
151
  */
144
152
  getStore(modelName) {
153
+ if (modelName === '__transactions') {
154
+ return this.transactionStore ?? undefined;
155
+ }
145
156
  return this.stores.get(modelName);
146
157
  }
147
158
  /**
@@ -295,6 +306,7 @@ export class StoreManager {
295
306
  for (const store of this.stores.values()) {
296
307
  store.markAsClosing();
297
308
  }
309
+ this.transactionStore?.markAsClosing();
298
310
  // SyncActionStore is a standalone store that does not extend ObjectStore
299
311
  // and has no markAsClosing equivalent, so it is intentionally skipped here.
300
312
  // If closing-state coordination is ever needed for sync actions, add it
@@ -112,7 +112,7 @@ export const ERROR_CODES = {
112
112
  jwt_invalid: wire('auth', 401, false, "The bearer JWT failed validation for a reason the server could not classify further. Check the token's issuer, signature, audience, and expiry."),
113
113
  jwt_malformed: wire('auth', 401, false, 'The bearer token is not a well-formed JWT and could not be decoded. Check that the full, unmodified token was sent.'),
114
114
  jwt_missing_issuer: wire('auth', 401, false, 'The bearer JWT has no `iss` (issuer) claim, so it cannot be routed to a trusted issuer.'),
115
- jwt_issuer_untrusted: wire('auth', 401, false, "The bearer JWT's `iss` is not a registered trusted issuer. Register it via POST /v1/trusted-issuers, or check the token's issuer claim."),
115
+ jwt_issuer_untrusted: wire('auth', 401, false, "The bearer JWT's `iss` is not a registered trusted issuer. Check the token's issuer claim, or register the issuer with your deployment before retrying."),
116
116
  jwt_signature_invalid: wire('auth', 401, false, "The bearer JWT's signature could not be verified against the issuer's JWKS (wrong key, rotated key, or forged token)."),
117
117
  jwt_audience_mismatch: wire('auth', 401, false, "The bearer JWT's `aud` (audience) claim does not match the audience this issuer is registered with."),
118
118
  jwt_missing_subject: wire('auth', 401, false, 'The bearer JWT has no `sub` (subject) claim to identify the user.'),
@@ -161,7 +161,11 @@ export const ERROR_CODES = {
161
161
  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).'),
162
162
  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).'),
163
163
  // ── stale context / idempotency (409) ──────────────────────────────
164
- stale_context: wire('conflict', 409, true, "The row changed after you read it — the write's `readAt` watermark is older than the current row version. Re-read the row and retry."),
164
+ // Not retryable at the transport: the rejected request carries its frozen
165
+ // `readAt`, so resending the identical payload can never succeed. Recovery
166
+ // is a caller-level re-read that produces a NEW request with a fresh
167
+ // watermark — the same shape as `claim_conflict`.
168
+ stale_context: wire('conflict', 409, false, "The row changed after you read it — the write's `readAt` watermark is older than the current row version. Re-read the row and retry."),
165
169
  // Raised by the functional `update(id, current => next)` form once its
166
170
  // internal reconcile budget is exhausted, because the row stayed continuously
167
171
  // contended. The SDK has already retried; the caller decides whether to back
package/dist/index.d.ts CHANGED
@@ -55,10 +55,16 @@ export type { MutationExecutor } from './interfaces/index.js';
55
55
  export type { ModelUpdater, ContentionOptions } from './client/functionalUpdate.js';
56
56
  export { DEFAULT_CONTENTION_RETRIES } from './client/functionalUpdate.js';
57
57
  export type { HttpClaimApi, InternalAbloOptions } from './client/Ablo.js';
58
+ export type { AbloReads } from './client/Ablo.js';
58
59
  export { type AbloHttpClientOptions, type AbloHttpClient, type HttpModelClient, } from './client/httpClient.js';
59
60
  export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './client/auth.js';
60
61
  export type { AbloOptions, LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, ModelOperations, } from './client/Ablo.js';
61
62
  export type { AbloPersistence } from './client/persistence.js';
63
+ export type { CommitOutboxRecord, CommitOutboxStore, } from './transactions/commitOutboxStore.js';
64
+ export { durableCommitEnvelopeSchema, } from './transactions/commitEnvelope.js';
65
+ export type { CommitOutboxScope, DurableCommitEnvelope, } from './transactions/commitEnvelope.js';
66
+ export { durableHttpCommitEnvelopeSchema, } from './transactions/httpCommitEnvelope.js';
67
+ export type { DurableHttpCommitEnvelope, } from './transactions/httpCommitEnvelope.js';
62
68
  import { Ablo } from './client/Ablo.js';
63
69
  export default Ablo;
64
70
  export { dataSource, abloSource, sourceEventForOperation, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
package/dist/index.js CHANGED
@@ -63,6 +63,8 @@ export { DEFAULT_CONTENTION_RETRIES } from './client/functionalUpdate.js';
63
63
  // `AbloHttpClient` type, which is the return type of that call.
64
64
  export {} from './client/httpClient.js';
65
65
  export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './client/auth.js';
66
+ export { durableCommitEnvelopeSchema, } from './transactions/commitEnvelope.js';
67
+ export { durableHttpCommitEnvelopeSchema, } from './transactions/httpCommitEnvelope.js';
66
68
  // Participant types live under `Ablo.Participant.*` —
67
69
  // `Ablo.Participant.Joined`, `Ablo.Participant.Manager`,
68
70
  // `Ablo.Participant.JoinOptions`, etc. Same dot-access shape as
@@ -200,8 +200,8 @@ export interface CommitResult {
200
200
  * - `idempotencyKey` — when set, the server caches the response for 24 hours and
201
201
  * returns the cached result on any retry using the same key. When omitted, the
202
202
  * SDK generates a fresh UUID per mutation, so every call is retry-safe by
203
- * default. Pass `{ idempotencyKey: null }` for the rare case where you want a
204
- * write that is not retry-safe.
203
+ * default. `null` is retained for source compatibility and is treated like
204
+ * omission; write retries never opt out of request identity.
205
205
  * - `label` — a human-readable tag recorded with the mutation for debugging, such
206
206
  * as "nightly cleanup" or "user click".
207
207
  */
@@ -250,6 +250,8 @@ export declare class UndoManager<S extends Schema> {
250
250
  private readonly store;
251
251
  private readonly organizationId;
252
252
  private readonly scopes;
253
+ /** The options each scope was constructed with, for the mismatch warning below. */
254
+ private readonly creationOptions;
253
255
  constructor(schema: S, store: SyncStoreContract, organizationId: string);
254
256
  getScope(name: string, options?: UndoScopeOptions): UndoScope<S>;
255
257
  clearAll(): void;
@@ -569,6 +569,8 @@ export class UndoManager {
569
569
  store;
570
570
  organizationId;
571
571
  scopes = new Map();
572
+ /** The options each scope was constructed with, for the mismatch warning below. */
573
+ creationOptions = new Map();
572
574
  constructor(schema, store, organizationId) {
573
575
  this.schema = schema;
574
576
  this.store = store;
@@ -579,6 +581,36 @@ export class UndoManager {
579
581
  if (!scope) {
580
582
  scope = new UndoScope(this.schema, this.store, this.organizationId, options);
581
583
  this.scopes.set(name, scope);
584
+ this.creationOptions.set(name, options);
585
+ return scope;
586
+ }
587
+ // A scope keeps the options it was created with; later calls cannot change
588
+ // them. Requesting the shared scope with no options is the normal pattern
589
+ // and stays silent — but passing options that conflict with the creation
590
+ // values means one caller believes it configured a scope that another
591
+ // caller already configured differently, which is how a surface silently
592
+ // ends up with, say, no stream recording. Surface that instead of letting
593
+ // it pass.
594
+ if (options) {
595
+ const created = this.creationOptions.get(name);
596
+ const conflicts = [];
597
+ if (options.recordFromStream !== undefined &&
598
+ options.recordFromStream !== (created?.recordFromStream ?? false)) {
599
+ conflicts.push('recordFromStream');
600
+ }
601
+ if (options.maxHistory !== undefined && options.maxHistory !== (created?.maxHistory ?? 100)) {
602
+ conflicts.push('maxHistory');
603
+ }
604
+ if (options.conflictPolicy !== undefined &&
605
+ options.conflictPolicy !== (created?.conflictPolicy ?? DEFAULT_UNDO_CONFLICT_POLICY)) {
606
+ conflicts.push('conflictPolicy');
607
+ }
608
+ if (conflicts.length > 0) {
609
+ getContext().logger.warn(`The undo scope "${name}" already exists with different options — ` +
610
+ `${conflicts.join(', ')} cannot be changed after creation and the requested ` +
611
+ `values are ignored. Create the scope with its full options before any ` +
612
+ `caller requests it without them, or use a differently named scope.`);
613
+ }
582
614
  }
583
615
  return scope;
584
616
  }
@@ -1,4 +1,4 @@
1
- import type { Ablo, ModelClaim } from '../client/Ablo.js';
1
+ import type { Ablo, AbloReads, ModelClaim } from '../client/Ablo.js';
2
2
  import type { ModelOperations } from '../client/createModelProxy.js';
3
3
  import type { SchemaRecord } from '../schema/schema.js';
4
4
  import type { ResolveSchema } from '../types/global.js';
@@ -11,8 +11,8 @@ import type { ResolveSchema } from '../types/global.js';
11
11
  type DefaultModels = ResolveSchema extends {
12
12
  models: infer M;
13
13
  } ? M extends SchemaRecord ? M : SchemaRecord : SchemaRecord;
14
- type ModelClientSelector<R extends SchemaRecord, T, C> = (ablo: Ablo<R>) => ModelOperations<T, C>;
15
- type AbloSelector<R extends SchemaRecord, T> = (ablo: Ablo<R>) => T;
14
+ type ModelClientSelector<R extends SchemaRecord, T, C> = (ablo: AbloReads<R>) => ModelOperations<T, C>;
15
+ type AbloSelector<R extends SchemaRecord, T> = (ablo: AbloReads<R>) => T;
16
16
  export interface UseAbloModelOptions<T> {
17
17
  /**
18
18
  * An initial row, usually from a server component or a route loader. The hook
@@ -48,7 +48,9 @@ export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'>
48
48
  * if (!ablo) return <Loading />;
49
49
  * const doc = await ablo.documents.retrieve({ id }); // async server read
50
50
  *
51
- * // Reactive selector (a synchronous local snapshot):
51
+ * // Reactive selector (a synchronous local snapshot). The selector's reads
52
+ * // are typed as snapshot rows — data fields + computeds, no relation
53
+ * // accessors — matching what the hook actually returns:
52
54
  * const doc = useAblo((ablo) => ablo.documents.get(id)) ?? serverDoc;
53
55
  * const active = useAblo((ablo) => ablo.documents.claim.state({ id }));
54
56
  *
@@ -5,6 +5,24 @@ import { getModelClientMeta } from '../client/createModelProxy.js';
5
5
  import { Model } from '../Model.js';
6
6
  import { useReactive } from './useReactive.js';
7
7
  const EMPTY_CLAIMS = Object.freeze([]);
8
+ /**
9
+ * Restore the caller's schema generics on the context-held engine. React
10
+ * context erases generics (see `AbloInternalContextValue.engine`), so this is
11
+ * the one deliberate rebind point: the runtime value is the fully typed
12
+ * client, and `R` is the compile-time view the calling hook declared.
13
+ */
14
+ function rebindEngine(engine) {
15
+ return engine;
16
+ }
17
+ /**
18
+ * The same rebind viewed through the reactive-read surface — the identical
19
+ * runtime object, with model reads typed as snapshot rows, because everything
20
+ * a selector returns is converted through `snapshotValue` before the hook
21
+ * hands it back.
22
+ */
23
+ function reactiveReads(engine) {
24
+ return rebindEngine(engine);
25
+ }
8
26
  function readModelResult(engine, modelClient, id, initial) {
9
27
  if (!modelClient || id === undefined) {
10
28
  return { data: initial, claims: EMPTY_CLAIMS, claimed: false };
@@ -43,7 +61,7 @@ export function useAblo(modelOrSelect, id, options) {
43
61
  const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
44
62
  const modelClient = typeof modelOrSelect === 'function' && id !== undefined
45
63
  ? engine
46
- ? modelOrSelect(engine)
64
+ ? modelOrSelect(reactiveReads(engine))
47
65
  : undefined
48
66
  : typeof modelOrSelect === 'function'
49
67
  ? undefined
@@ -66,7 +84,11 @@ export function useAblo(modelOrSelect, id, options) {
66
84
  if (!engine || !isSelectorOnly || typeof modelOrSelect !== 'function') {
67
85
  return undefined;
68
86
  }
69
- return snapshotValue(modelOrSelect(engine));
87
+ // The selector runs against the real engine — reads inside it return the
88
+ // pool's model instances. `snapshotValue` then converts the RESULT to
89
+ // plain snapshot rows, which is what the selector's `AbloReads`
90
+ // parameter type already promised.
91
+ return snapshotValue(modelOrSelect(reactiveReads(engine)));
70
92
  });
71
93
  const modelResult = useReactive(() => {
72
94
  void claimVersion;
@@ -76,5 +98,5 @@ export function useAblo(modelOrSelect, id, options) {
76
98
  return selected;
77
99
  if (modelOrSelect)
78
100
  return modelResult;
79
- return engine;
101
+ return engine === null ? null : rebindEngine(engine);
80
102
  }
@@ -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 InferModel, type InferCreate, 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, } 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, } from './schema.js';
34
34
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, type SchemaJSON, type ModelJSON, type RelationJSON, } from './serialize.js';
35
35
  export { selectModels } from './select.js';
36
36
  export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, type ProvisionPlan, type MigrationPlan, } from './ddl.js';
@@ -162,8 +162,38 @@ Omit<z.infer<z.ZodObject<Shape>>, keyof BaseModelFields> & BaseModelFields & Bas
162
162
  * to `slide.layers` would have no effect at runtime.
163
163
  */
164
164
  export type InferRelations<S extends Schema, R extends RelationRecord> = string extends keyof R ? unknown : {
165
- readonly [K in keyof R]: R[K] extends RelationDef<infer Type, infer Target, infer _F, infer _O> ? Target extends keyof S['models'] ? Type extends 'hasMany' ? InferModel<S, Target>[] : Type extends 'hasOne' | 'belongsTo' ? InferModel<S, Target> | undefined : never : never : never;
165
+ readonly [K in keyof R]: R[K] extends RelationDef<infer Type, infer Target> ? Target extends keyof S['models'] ? Type extends 'hasMany' ? InferModel<S, Target>[] : Type extends 'hasOne' | 'belongsTo' ? InferModel<S, Target> | undefined : never : never : never;
166
166
  };
167
+ /**
168
+ * The row shape a reactive read returns: the model's data fields, base fields,
169
+ * and schema computed getters — WITHOUT relation accessors and WITHOUT model
170
+ * methods. Derived from the model definition, exactly like {@link InferModel},
171
+ * mirroring what `toReactiveSnapshot()` produces at runtime.
172
+ *
173
+ * Relations (`hasMany` / `belongsTo`) are store-backed getters that exist only
174
+ * on the pool's model instances, so a reactive row honestly omits them —
175
+ * reading `row.layers` is a compile error instead of a silent `undefined`.
176
+ * Compose relations through a selector or hook instead.
177
+ *
178
+ * The same pairing other data layers converged on: Zero's data-only `Row<...>`
179
+ * with relations added per-query, Prisma's scalar-only model types with
180
+ * `GetPayload<{ include }>`, mobx-state-tree's `Instance<T>` / `SnapshotOut<T>`.
181
+ */
182
+ export type InferRow<S extends Schema, ModelName extends keyof S['models']> = S['models'][ModelName] extends ModelDef<infer Shape, RelationRecord, infer C> ? // Same reserved-field guard as InferModel — see the comment there.
183
+ Omit<z.infer<z.ZodObject<Shape>>, keyof BaseModelFields> & BaseModelFields & InferComputed<C> : never;
184
+ /**
185
+ * The reactive-row companion to {@link Model}. Once your project's
186
+ * `ablo/register.ts` registers the schema, a single argument is all it takes:
187
+ *
188
+ * ```ts
189
+ * type SlideRow = Row<'slides'>; // data fields + computeds, no relations
190
+ * type Slide = Model<'slides'>; // the pool's model instance
191
+ * ```
192
+ *
193
+ * Without that registration, or for a second schema, pass the schema
194
+ * explicitly: `Row<typeof schema, 'slides'>`.
195
+ */
196
+ export type Row<A, B = never> = [B] extends [never] ? A extends keyof RegisteredSchema['models'] ? InferRow<RegisteredSchema, A> : never : A extends Schema ? InferRow<A, B extends keyof A['models'] ? B : never> : never;
167
197
  /**
168
198
  * Infer the return types of computed getters.
169
199
  * Maps each computed function's return type into a readonly property.
@@ -4,6 +4,15 @@
4
4
  */
5
5
  import type { ModelMetadata } from '../types/index.js';
6
6
  import type { ObjectStoreContract } from './ObjectStoreContract.js';
7
+ /**
8
+ * IDB transaction options type (TypeScript's lib.dom.d.ts may be outdated)
9
+ * durability: 'relaxed' provides ~16x write performance improvement
10
+ * by not requiring fsync on each transaction commit.
11
+ * Safe for optimistic sync engines that can recover from server state.
12
+ */
13
+ interface IDBTransactionOptionsWithDurability {
14
+ durability?: 'default' | 'relaxed' | 'strict';
15
+ }
7
16
  /**
8
17
  * An IndexedDB-backed store holding the records of a single model.
9
18
  *
@@ -21,8 +30,11 @@ export declare class ObjectStore implements ObjectStoreContract {
21
30
  protected modelName: string;
22
31
  protected storeName: string;
23
32
  protected metadata: ModelMetadata;
33
+ private readonly writeDurability;
24
34
  private isClosing;
25
- constructor(db: IDBDatabase, modelName: string, storeName: string, metadata: ModelMetadata);
35
+ constructor(db: IDBDatabase, modelName: string, storeName: string, metadata: ModelMetadata, writeDurability?: NonNullable<IDBTransactionOptionsWithDurability['durability']>);
36
+ /** Insert without replacing an existing key (used by the commit outbox). */
37
+ add(data: Record<string, unknown>): Promise<void>;
26
38
  /**
27
39
  * Mark this store as closing to prevent new operations
28
40
  */
@@ -100,3 +112,4 @@ export declare class ObjectStore implements ObjectStoreContract {
100
112
  */
101
113
  performMaintenance(): Promise<void>;
102
114
  }
115
+ export {};
@@ -19,12 +19,35 @@ export class ObjectStore {
19
19
  modelName;
20
20
  storeName;
21
21
  metadata;
22
+ writeDurability;
22
23
  isClosing = false;
23
- constructor(db, modelName, storeName, metadata) {
24
+ constructor(db, modelName, storeName, metadata, writeDurability = 'relaxed') {
24
25
  this.db = db;
25
26
  this.modelName = modelName;
26
27
  this.storeName = storeName;
27
28
  this.metadata = metadata;
29
+ this.writeDurability = writeDurability;
30
+ }
31
+ /** Insert without replacing an existing key (used by the commit outbox). */
32
+ async add(data) {
33
+ if (!this.checkDatabaseAvailable()) {
34
+ return Promise.reject(new Error('IndexedDB not available (closing or invalid)'));
35
+ }
36
+ return new Promise((resolve, reject) => {
37
+ try {
38
+ const tx = this.db.transaction([this.storeName], 'readwrite', {
39
+ durability: this.writeDurability,
40
+ });
41
+ const store = tx.objectStore(this.storeName);
42
+ const request = store.add(data);
43
+ tx.oncomplete = () => { resolve(); };
44
+ tx.onerror = () => { reject(tx.error || new Error('IndexedDB transaction error')); };
45
+ request.onerror = () => { reject(request.error || new Error('IndexedDB request error')); };
46
+ }
47
+ catch (error) {
48
+ reject(error instanceof Error ? error : new Error(String(error)));
49
+ }
50
+ });
28
51
  }
29
52
  /**
30
53
  * Mark this store as closing to prevent new operations
@@ -65,7 +88,7 @@ export class ObjectStore {
65
88
  try {
66
89
  // Use relaxed durability for ~16x write performance (safe with optimistic sync)
67
90
  const tx = this.db.transaction([this.storeName], 'readwrite', {
68
- durability: 'relaxed',
91
+ durability: this.writeDurability,
69
92
  });
70
93
  const store = tx.objectStore(this.storeName);
71
94
  const request = store.put(data);
@@ -169,7 +192,7 @@ export class ObjectStore {
169
192
  try {
170
193
  // Use relaxed durability for ~16x write performance (safe with optimistic sync)
171
194
  const tx = this.db.transaction([this.storeName], 'readwrite', {
172
- durability: 'relaxed',
195
+ durability: this.writeDurability,
173
196
  });
174
197
  const store = tx.objectStore(this.storeName);
175
198
  const request = store.delete(id);
@@ -205,7 +228,7 @@ export class ObjectStore {
205
228
  try {
206
229
  // Use relaxed durability for ~16x write performance (safe with optimistic sync)
207
230
  const tx = this.db.transaction([this.storeName], 'readwrite', {
208
- durability: 'relaxed',
231
+ durability: this.writeDurability,
209
232
  });
210
233
  const store = tx.objectStore(this.storeName);
211
234
  const request = store.clear();
@@ -15,6 +15,8 @@
15
15
  * a silent failure in a caller.
16
16
  */
17
17
  export interface ObjectStoreContract {
18
+ /** Insert a record only when its key is absent. */
19
+ add(data: Record<string, unknown>): Promise<void>;
18
20
  /** Insert or update a record. The record must carry an `id` field. */
19
21
  put(data: Record<string, unknown>): Promise<void>;
20
22
  /** Look up a record by id. */
package/dist/surface.d.ts CHANGED
@@ -30,7 +30,7 @@ export declare const PUBLIC_LIST_OPTION_KEYS: readonly ["where", "filter", "orde
30
30
  * The keys of the client constructor options, {@link AbloOptions}. Only
31
31
  * `schema` is required; every other key is optional.
32
32
  */
33
- export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "authEndpoint", "databaseUrl", "persistence", "transport", "debug", "logLevel", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser"];
33
+ export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "authEndpoint", "databaseUrl", "persistence", "commitOutbox", "commitOutboxScope", "transport", "debug", "logLevel", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser"];
34
34
  export type ModelVerb = (typeof PUBLIC_MODEL_VERBS)[number];
35
35
  export type ListOptionKey = (typeof PUBLIC_LIST_OPTION_KEYS)[number];
36
36
  export type AbloOptionKey = (typeof PUBLIC_ABLO_OPTION_KEYS)[number];
package/dist/surface.js CHANGED
@@ -58,6 +58,8 @@ export const PUBLIC_ABLO_OPTION_KEYS = [
58
58
  'authEndpoint',
59
59
  'databaseUrl',
60
60
  'persistence',
61
+ 'commitOutbox',
62
+ 'commitOutboxScope',
61
63
  'transport',
62
64
  'debug',
63
65
  'logLevel',