@abloatai/ablo 0.9.2 → 0.9.4

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 (67) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +47 -27
  4. package/dist/BaseSyncedStore.d.ts +7 -38
  5. package/dist/BaseSyncedStore.js +20 -67
  6. package/dist/Database.js +7 -1
  7. package/dist/NetworkMonitor.js +4 -1
  8. package/dist/SyncClient.d.ts +18 -5
  9. package/dist/SyncClient.js +72 -1
  10. package/dist/SyncEngineContext.js +5 -1
  11. package/dist/auth/index.js +3 -1
  12. package/dist/cli.cjs +282241 -0
  13. package/dist/client/Ablo.d.ts +12 -3
  14. package/dist/client/Ablo.js +36 -3
  15. package/dist/client/ApiClient.js +39 -6
  16. package/dist/client/auth.d.ts +1 -1
  17. package/dist/client/auth.js +14 -5
  18. package/dist/client/createInternalComponents.js +1 -1
  19. package/dist/client/createModelProxy.d.ts +9 -0
  20. package/dist/client/createModelProxy.js +34 -10
  21. package/dist/client/persistence.d.ts +6 -1
  22. package/dist/client/persistence.js +1 -1
  23. package/dist/client/registerDataSource.d.ts +4 -4
  24. package/dist/client/registerDataSource.js +39 -31
  25. package/dist/client/writeOptionsSchema.d.ts +50 -0
  26. package/dist/client/writeOptionsSchema.js +57 -0
  27. package/dist/core/index.d.ts +18 -26
  28. package/dist/core/index.js +22 -46
  29. package/dist/errorCodes.d.ts +13 -0
  30. package/dist/errorCodes.js +16 -1
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +7 -0
  33. package/dist/interfaces/index.d.ts +10 -0
  34. package/dist/mutators/UndoManager.d.ts +31 -5
  35. package/dist/mutators/UndoManager.js +113 -1
  36. package/dist/schema/ddl.js +12 -3
  37. package/dist/schema/field.js +2 -1
  38. package/dist/schema/model.d.ts +9 -7
  39. package/dist/schema/model.js +1 -1
  40. package/dist/schema/schema.js +7 -1
  41. package/dist/schema/serialize.js +2 -1
  42. package/dist/server/storage-mode.d.ts +7 -0
  43. package/dist/server/storage-mode.js +6 -0
  44. package/dist/source/adapters/drizzle.js +3 -2
  45. package/dist/source/adapters/kysely.d.ts +68 -0
  46. package/dist/source/adapters/kysely.js +210 -0
  47. package/dist/source/adapters/memory.js +2 -1
  48. package/dist/source/adapters/prisma.js +3 -2
  49. package/dist/source/index.js +2 -1
  50. package/dist/sync/syncPosition.d.ts +78 -0
  51. package/dist/sync/syncPosition.js +111 -0
  52. package/dist/transactions/TransactionQueue.d.ts +22 -8
  53. package/dist/transactions/TransactionQueue.js +76 -34
  54. package/dist/utils/duration.js +3 -2
  55. package/docs/api-keys.md +4 -4
  56. package/docs/cli.md +6 -6
  57. package/docs/client-behavior.md +1 -1
  58. package/docs/data-sources.md +61 -42
  59. package/docs/guarantees.md +2 -2
  60. package/docs/index.md +2 -2
  61. package/docs/integration-guide.md +4 -7
  62. package/docs/mcp.md +1 -1
  63. package/docs/quickstart.md +84 -37
  64. package/docs/schema-contract.md +2 -4
  65. package/llms-full.txt +365 -0
  66. package/llms.txt +14 -9
  67. package/package.json +26 -4
@@ -1,36 +1,28 @@
1
1
  /**
2
2
  * @abloatai/ablo/core — Framework extension
3
3
  *
4
- * Only imported by SyncedStore.ts and ApplicationStore.ts
5
- * the 2-3 files that extend or orchestrate the sync engine.
6
- * Regular model files and components should NOT import from here.
4
+ * Only imported by the handful of files that extend or orchestrate the
5
+ * sync engine (the app-shell store/provider stack, sync adapters, demo
6
+ * harnesses). Regular model files and components should NOT import from
7
+ * here — the consumer surface is `Ablo({ schema })` on the root.
8
+ *
9
+ * TRIMMED to what framework-level consumers actually import (verified by
10
+ * a monorepo-wide import scan). Everything else the engine defines stays
11
+ * module-private: if a new framework concern genuinely needs another
12
+ * primitive, add the export deliberately — don't re-widen the barrel.
7
13
  */
8
- export { BaseSyncedStore, type SyncStatus, type UserContext, type SyncedStoreConfig, type QueryResult, type SmartSyncOptions, type ModelConstructor, type ConcreteModelConstructor, BOOTSTRAP_CONFIG, } from '../BaseSyncedStore.js';
9
- export { SyncClient, type RehydrationStats } from '../SyncClient.js';
10
- export { Database, type BootstrapResult, type BootstrapRequirements } from '../Database.js';
14
+ export { BaseSyncedStore, type ModelConstructor, type ConcreteModelConstructor, } from '../BaseSyncedStore.js';
15
+ export { SyncClient } from '../SyncClient.js';
16
+ export { Database } from '../Database.js';
11
17
  export { ObjectPool, ModelScope } from '../ObjectPool.js';
12
18
  export { Model } from '../Model.js';
13
- export { LazyReferenceCollection, type LazyCollectionOptions } from '../LazyReferenceCollection.js';
19
+ export { LazyReferenceCollection, type LazyCollectionOptions, } from '../LazyReferenceCollection.js';
20
+ export { ModelRegistry, getActiveRegistry, } from '../ModelRegistry.js';
14
21
  export { postQuery, type PostQueryOptions } from '../query/client.js';
15
- export { probeNetwork, type ProbeResult } from '../sync/NetworkProbe.js';
16
- export { ConnectionManager, type ConnectionState, type ConnectionEvent, type ConnectionCallbacks, type ConnectionManagerOptions, } from '../sync/ConnectionManager.js';
17
- export { ModelRegistry, getActiveRegistry, type ExtendedReferenceMetadata, type BackReferenceMetadata } from '../ModelRegistry.js';
18
22
  export { computeFKDepthPriority, type InternalAbloOptions } from '../client/Ablo.js';
19
- export { TransactionQueue } from '../transactions/TransactionQueue.js';
20
- export { initSyncEngine, resetSyncEngine, isSyncEngineInitialized } from '../context.js';
21
- export { noopLogger, noopObservability, noopAnalytics, browserOnlineStatus, defaultSessionErrorDetector, emptyConfig, type SyncEngineContext, } from '../SyncEngineContext.js';
22
- export type { SyncEngineConfig, SyncLogger, SyncObservabilityProvider, SyncAnalytics, MutationExecutor, MutationDispatcher, SessionErrorDetector, OnlineStatusProvider, CommitResult, MutationOperation, BreadcrumbLevel, SyncBreadcrumbCategory, TransactionFailureDetails, BootstrapFailureDetails, WebSocketErrorDetails, RollbackDetails, SpanAttributes, } from '../interfaces/index.js';
23
- export { SyncSessionError } from '../errors.js';
24
- export { QueryProcessor } from './QueryProcessor.js';
25
- export { QueryView, type QueryViewOptions } from './QueryView.js';
26
- export { ViewRegistry } from './ViewRegistry.js';
27
- export { ObjectStore } from '../stores/ObjectStore.js';
28
- export { NetworkMonitor } from '../NetworkMonitor.js';
29
- export { SyncWebSocket, type SyncDelta, type VersionVector, type BootstrapHint, type SyncGroupChangePayload, type BootstrapDataEvent, type PresenceUpdateEvent, type SyncWebSocketOptions, } from '../sync/SyncWebSocket.js';
30
- export { BootstrapHelper, type BootstrapData, type BootstrapOptions, type BootstrapFetchResult } from '../sync/BootstrapHelper.js';
23
+ export type { SyncLogger, SyncObservabilityProvider, MutationExecutor, MutationDispatcher, SessionErrorDetector, OnlineStatusProvider, CommitResult, MutationOperation, } from '../interfaces/index.js';
24
+ export { SyncWebSocket, type SyncDelta, type SyncWebSocketOptions, } from '../sync/SyncWebSocket.js';
25
+ export { BootstrapHelper } from '../sync/BootstrapHelper.js';
31
26
  export { createIntentStream, type AttachableIntentStream, type IntentStreamConfig, } from '../sync/createIntentStream.js';
32
27
  export { awaitIntentGrant, type GrantTransport, } from '../sync/awaitIntentGrant.js';
33
- export { OfflineTransactionStore, offlineTxStore, Priority } from '../sync/OfflineTransactionStore.js';
34
- export { PropertyType, LoadStrategy, MutationOperationType } from '../types/index.js';
35
- export type { PropertyMetadata, ReferenceMetadata, ModelMetadata, SyncAction, DeltaPacket, BootstrapMetadata, DatabaseMetadata, } from '../types/index.js';
36
- export type { ModelData } from '../BaseSyncedStore.js';
28
+ export { LoadStrategy } from '../types/index.js';
@@ -1,52 +1,33 @@
1
1
  /**
2
2
  * @abloatai/ablo/core — Framework extension
3
3
  *
4
- * Only imported by SyncedStore.ts and ApplicationStore.ts
5
- * the 2-3 files that extend or orchestrate the sync engine.
6
- * Regular model files and components should NOT import from here.
4
+ * Only imported by the handful of files that extend or orchestrate the
5
+ * sync engine (the app-shell store/provider stack, sync adapters, demo
6
+ * harnesses). Regular model files and components should NOT import from
7
+ * here — the consumer surface is `Ablo({ schema })` on the root.
8
+ *
9
+ * TRIMMED to what framework-level consumers actually import (verified by
10
+ * a monorepo-wide import scan). Everything else the engine defines stays
11
+ * module-private: if a new framework concern genuinely needs another
12
+ * primitive, add the export deliberately — don't re-widen the barrel.
7
13
  */
8
- // Base store class
9
- export { BaseSyncedStore, BOOTSTRAP_CONFIG, } from '../BaseSyncedStore.js';
10
- // Core infrastructure
14
+ // Base store class + the constructor shapes subclasses reference
15
+ export { BaseSyncedStore, } from '../BaseSyncedStore.js';
16
+ // Core infrastructure classes
11
17
  export { SyncClient } from '../SyncClient.js';
12
18
  export { Database } from '../Database.js';
13
19
  export { ObjectPool, ModelScope } from '../ObjectPool.js';
14
20
  export { Model } from '../Model.js';
15
- export { LazyReferenceCollection } from '../LazyReferenceCollection.js';
16
- // Undo runtime `useUndoScope` hook from `@abloatai/ablo/react` is
17
- // the canonical access path. Type counterparts (`Ablo.Mutator.UndoScope`,
18
- // `Ablo.Mutator.UndoEntry`, `Ablo.Mutator.InverseOp`) live on the main `Ablo`
19
- // namespace. Direct class access (tests, non-React hosts) imports via
20
- // the package's internal subpath.
21
- // Lower-level network primitives — exposed here for the per-app demand
22
- // loaders. The main barrel hides these so consumer code converges on
23
- // `ablo.<model>.fetch(...)` for hydration. Loaders that haven't been
24
- // migrated yet can keep importing from `/core`.
21
+ export { LazyReferenceCollection, } from '../LazyReferenceCollection.js';
22
+ export { ModelRegistry, getActiveRegistry, } from '../ModelRegistry.js';
23
+ // Lower-level network read for per-app demand loaders that haven't
24
+ // migrated to `ablo.<model>.list(...)` yet.
25
25
  export { postQuery } from '../query/client.js';
26
- export { probeNetwork } from '../sync/NetworkProbe.js';
27
- export { ConnectionManager, } from '../sync/ConnectionManager.js';
28
- export { ModelRegistry, getActiveRegistry } from '../ModelRegistry.js';
29
26
  // FK-cycle / dependency-order helper — used by schema-aware test
30
- // fixtures and scaffolding tools to compute commit ordering. Lives
31
- // here because it traverses model relations and isn't part of the
32
- // consumer-facing API.
27
+ // fixtures and scaffolding tools to compute commit ordering.
33
28
  export { computeFKDepthPriority } from '../client/Ablo.js';
34
- export { TransactionQueue } from '../transactions/TransactionQueue.js';
35
- // ── Provider-facing DI types (was the deleted `/config` subpath) ──
36
- // Adapters that the consumer wires into `<AbloProvider>` props
37
- // (logger, observability, mutation executor, session-error
38
- // detector, etc.) implement these interfaces. Lives on `/core`
39
- // because most consumers don't need them; only apps that wrap the
40
- // provider with custom adapters reach for them.
41
- export { initSyncEngine, resetSyncEngine, isSyncEngineInitialized } from '../context.js';
42
- export { noopLogger, noopObservability, noopAnalytics, browserOnlineStatus, defaultSessionErrorDetector, emptyConfig, } from '../SyncEngineContext.js';
43
- export { SyncSessionError } from '../errors.js';
44
- export { QueryProcessor } from './QueryProcessor.js';
45
- export { QueryView } from './QueryView.js';
46
- export { ViewRegistry } from './ViewRegistry.js';
47
- export { ObjectStore } from '../stores/ObjectStore.js';
48
- export { NetworkMonitor } from '../NetworkMonitor.js';
49
- // Sync layer
29
+ // Sync layer the wire socket + delta shape, for sync adapters and the
30
+ // multi-agent demo harnesses.
50
31
  export { SyncWebSocket, } from '../sync/SyncWebSocket.js';
51
32
  export { BootstrapHelper } from '../sync/BootstrapHelper.js';
52
33
  // Intent coordination primitives (the lower-level pieces behind the
@@ -56,11 +37,6 @@ export { BootstrapHelper } from '../sync/BootstrapHelper.js';
56
37
  // orchestration and e2e harnesses — NOT on the consumer `.` root.
57
38
  export { createIntentStream, } from '../sync/createIntentStream.js';
58
39
  export { awaitIntentGrant, } from '../sync/awaitIntentGrant.js';
59
- // Offline transaction queuemoved out of the main barrel in the headless
60
- // audit cleanup (see docs/headless-audit.md §4.1 Task 23). The class
61
- // touches indexedDB + crypto.subtle and therefore cannot live on the main
62
- // headless-clean import path. Framework-level consumers (the few files
63
- // that orchestrate sync) import from /core explicitly.
64
- export { OfflineTransactionStore, offlineTxStore, Priority } from '../sync/OfflineTransactionStore.js';
65
- // Types used by framework-level code
66
- export { PropertyType, LoadStrategy, MutationOperationType } from '../types/index.js';
40
+ // Schema/model load strategy enum referenced by model registration in
41
+ // framework code.
42
+ export { LoadStrategy } from '../types/index.js';
@@ -134,9 +134,15 @@ export declare const ERROR_CODES: {
134
134
  readonly browser_apikey_blocked: ErrorCodeSpec;
135
135
  readonly browser_database_url_blocked: ErrorCodeSpec;
136
136
  readonly datasource_registration_failed: ErrorCodeSpec;
137
+ readonly datasource_connection_unsupported: ErrorCodeSpec;
137
138
  readonly capability_scope_denied: ErrorCodeSpec;
138
139
  readonly issuer_register_forbidden: ErrorCodeSpec;
139
140
  readonly capability_invalid: ErrorCodeSpec;
141
+ readonly test_database_not_registered: ErrorCodeSpec;
142
+ readonly database_role_cannot_enforce_rls: ErrorCodeSpec;
143
+ readonly database_role_unreadable: ErrorCodeSpec;
144
+ readonly database_tables_unforced_rls: ErrorCodeSpec;
145
+ readonly database_host_not_allowed: ErrorCodeSpec;
140
146
  readonly byo_role_cannot_enforce_rls: ErrorCodeSpec;
141
147
  readonly byo_role_unreadable: ErrorCodeSpec;
142
148
  readonly byo_tenant_tables_unforced_rls: ErrorCodeSpec;
@@ -152,6 +158,13 @@ export declare const ERROR_CODES: {
152
158
  readonly stale_context: ErrorCodeSpec;
153
159
  readonly idempotency_conflict: ErrorCodeSpec;
154
160
  readonly idempotency_key_too_long: ErrorCodeSpec;
161
+ readonly write_options_invalid: ErrorCodeSpec;
162
+ readonly source_operation_id_required: ErrorCodeSpec;
163
+ readonly source_adapter_misconfigured: ErrorCodeSpec;
164
+ readonly source_event_invalid: ErrorCodeSpec;
165
+ readonly duration_invalid: ErrorCodeSpec;
166
+ readonly schema_definition_invalid: ErrorCodeSpec;
167
+ readonly cli_invalid_arguments: ErrorCodeSpec;
155
168
  readonly turn_validation_failed: ErrorCodeSpec;
156
169
  readonly commit_operation_required: ErrorCodeSpec;
157
170
  readonly commit_operation_model_required: ErrorCodeSpec;
@@ -123,11 +123,19 @@ export const ERROR_CODES = {
123
123
  file_upload_auth_required: wire('auth', 401, false, 'File upload requires an authenticated session.'),
124
124
  browser_apikey_blocked: client('auth', 'Raw API keys must not be used from a browser context.'),
125
125
  browser_database_url_blocked: client('auth', 'A database connection string must not be used from a browser context — it carries DB credentials.'),
126
- datasource_registration_failed: client('auth', 'Failed to register the provided databaseUrl for the direct Postgres connector.'),
126
+ datasource_registration_failed: client('auth', 'Failed to register the provided databaseUrl as a datasource.'),
127
+ datasource_connection_unsupported: wire('validation', 400, false, 'This deployment cannot register a direct (connection string) datasource — use the signed endpoint kind.'),
127
128
  // ── permission / capability (403) ──────────────────────────────────
128
129
  capability_scope_denied: wire('capability', 403, false, "The connection's resolved scope does not cover the attempted action."),
129
130
  issuer_register_forbidden: wire('permission', 403, false, 'Registering a trusted issuer requires a secret (sk_) API key.'),
130
131
  capability_invalid: wire('capability', 403, false, 'The capability is unknown, revoked, or expired.'),
132
+ test_database_not_registered: wire('permission', 403, false, 'Test mode requires a registered dev database for this org — run `npx ablo init`, or construct the client with `databaseUrl` using your test key.'),
133
+ database_role_cannot_enforce_rls: wire('permission', 403, false, 'The connected database role cannot enforce row-level security (superuser or BYPASSRLS).'),
134
+ database_role_unreadable: wire('permission', 403, false, 'The connected database role could not be introspected.'),
135
+ database_tables_unforced_rls: wire('permission', 403, false, 'Synced tables in the connected database do not have FORCE ROW LEVEL SECURITY applied.'),
136
+ database_host_not_allowed: wire('permission', 403, false, 'The connected database host resolves to a private, loopback, or link-local address and cannot be used.'),
137
+ // Deprecated spellings of the `database_*` codes above — still emitted by
138
+ // older servers; kept so they classify identically. Do not use in new code.
131
139
  byo_role_cannot_enforce_rls: wire('permission', 403, false, 'The direct Postgres connector role cannot enforce row-level security.'),
132
140
  byo_role_unreadable: wire('permission', 403, false, 'The direct Postgres connector role could not be introspected.'),
133
141
  byo_tenant_tables_unforced_rls: wire('permission', 403, false, 'Tenant tables do not have RLS forced under the direct Postgres connector role.'),
@@ -146,6 +154,13 @@ export const ERROR_CODES = {
146
154
  idempotency_conflict: wire('conflict', 409, false, 'The same Idempotency-Key was reused with a different request body.'),
147
155
  idempotency_key_too_long: wire('validation', 400, false, 'The supplied Idempotency-Key exceeds the maximum length.'),
148
156
  // ── validation (400 / 422) ─────────────────────────────────────────
157
+ write_options_invalid: client('validation', 'The write options (`idempotencyKey` / `label` / `wait` / `readAt` / `onStale` / `intent`) failed validation against the write-options schema.'),
158
+ source_operation_id_required: client('validation', 'A data-source operation arrived without the entity `id` it targets.'),
159
+ source_adapter_misconfigured: client('validation', 'The data-source ORM adapter could not map a schema model onto the backing client (missing delegate or model).'),
160
+ source_event_invalid: client('validation', 'A data-source outbox event could not be built — the operation carries no entity id and none was supplied.'),
161
+ duration_invalid: client('validation', 'A duration value was not a number of seconds or a "500ms" | "30s" | "3m" | "24h" string.'),
162
+ schema_definition_invalid: client('validation', 'A schema definition value was invalid (bad column identifier, non-finite backfill, or unsupported schema-JSON version).'),
163
+ cli_invalid_arguments: client('validation', 'The CLI was invoked with an unknown flag or a malformed flag value.'),
149
164
  turn_validation_failed: wire('validation', 422, false, 'The agent turn failed server-side validation.'),
150
165
  commit_operation_required: wire('validation', 400, false, 'A commit must carry `operation` or `operations`.'),
151
166
  commit_operation_model_required: wire('validation', 400, false, 'A commit operation is missing its `model`.'),
package/dist/index.d.ts CHANGED
@@ -64,6 +64,9 @@ export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionErr
64
64
  export type { CommitReceipt, RequiredCapability } from './errors.js';
65
65
  export type { ErrorCode, WireErrorCode, ErrorCategory, ErrorCodeSpec, RecoveryClass } from './errors.js';
66
66
  export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from './auth/credentialSource.js';
67
+ export { writeOptionsSchema, onStaleModeSchema, assertWriteOptions, } from './client/writeOptionsSchema.js';
68
+ export type { WriteOptionsInput } from './client/writeOptionsSchema.js';
69
+ export type { WriteOptions, MutationOptions } from './interfaces/index.js';
67
70
  export { IDBOpenTimeoutError, isStorageOpenTimeout } from './core/openIDBWithTimeout.js';
68
71
  export type { Register, DefaultSyncShape } from './types/global.js';
69
72
  export { defineMutators } from './mutators/defineMutators.js';
package/dist/index.js CHANGED
@@ -89,6 +89,13 @@ export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
89
89
  // `e.type === 'AbloX'`) plus the HTTP-response translator.
90
90
  export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
91
91
  export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from './auth/credentialSource.js';
92
+ // THE write-options contract — the one Zod schema for the option bag every
93
+ // write door accepts (`ablo.<model>.create/update/delete`, `commits.create`,
94
+ // the HTTP model routes). The SDK validates against it at each boundary;
95
+ // it's exported so consumers can validate/compose options ahead of a call
96
+ // (e.g. an agent tool's input schema). Runtime twin of `MutationOptions`,
97
+ // drift-guarded at compile time.
98
+ export { writeOptionsSchema, onStaleModeSchema, assertWriteOptions, } from './client/writeOptionsSchema.js';
92
99
  // Storage-wedge detection — lets app shells render a recovery screen when the
93
100
  // IndexedDB backing store is stuck (see core/openIDBWithTimeout.ts).
94
101
  export { IDBOpenTimeoutError, isStorageOpenTimeout } from './core/openIDBWithTimeout.js';
@@ -171,6 +171,16 @@ export interface MutationOptions {
171
171
  */
172
172
  causedByTaskId?: string | null;
173
173
  }
174
+ /**
175
+ * The `MutationOptions` subset carried per-write through the offline
176
+ * transaction lane (SyncClient → TransactionQueue → wire operation).
177
+ * ONE shared type so the proxy's public params, the queue, and the wire
178
+ * can never narrow each other silently again — `wait` and `intent` are
179
+ * deliberately absent because they resolve client-side before staging
180
+ * (`wait` at the proxy's confirmation await, `intent` server-side via
181
+ * the active lease on the entity).
182
+ */
183
+ export type WriteOptions = Pick<MutationOptions, 'readAt' | 'onStale' | 'idempotencyKey' | 'label'>;
174
184
  /** A single mutation operation in a batch. `options` rides along so the
175
185
  * server can cache+replay via `mutation_log`. */
176
186
  export interface MutationOperation {
@@ -52,11 +52,6 @@ export interface UndoScopeOptions {
52
52
  */
53
53
  recordFromStream?: boolean;
54
54
  }
55
- /**
56
- * A single undo stack for one surface. Access via `UndoManager.getScope(name)`.
57
- * Consumers call `record(entry)` after each mutator; `undo()` / `redo()` to
58
- * traverse the stacks.
59
- */
60
55
  export declare class UndoScope<S extends Schema> {
61
56
  private readonly schema;
62
57
  private readonly store;
@@ -121,6 +116,23 @@ export declare class UndoScope<S extends Schema> {
121
116
  * response) collapses into ONE Cmd+Z. `endGroup()` flushes it.
122
117
  */
123
118
  private group;
119
+ /**
120
+ * ASYNC replay-echo suppression, keyed by `${modelKey}:${id}`.
121
+ *
122
+ * The synchronous {@link replaying} flag only catches echoes delivered INLINE
123
+ * during `applyOps`. The real engine doesn't emit `transaction:created`
124
+ * synchronously: `SyncClient` defers the commit behind `scheduleSync()` +
125
+ * `await persistMutationQueue()` (an IndexedDB write), so a replayed write's
126
+ * echo lands on the stream AFTER `undo()`/`redo()` has already reset
127
+ * `replaying` and pushed the entry. That late echo would be recorded as a
128
+ * NEW edit — and `record()` clears the redo stack, so every undo silently
129
+ * destroyed its own redo. We mark the (modelKey,id) of every op we're about
130
+ * to replay here (synchronously, before the write), and consume one mark when
131
+ * the matching mutation arrives — independent of WHEN it arrives. Entries
132
+ * carry a TTL so a never-arriving echo (offline: the commit is skipped) can't
133
+ * leak and wrongly suppress a much-later genuine edit to the same row.
134
+ */
135
+ private readonly pendingReplayEchoes;
124
136
  constructor(schema: S, store: SyncStoreContract, organizationId: string, options?: UndoScopeOptions);
125
137
  /**
126
138
  * Open a grouping session: every stream-recorded op until {@link endGroup}
@@ -131,6 +143,20 @@ export declare class UndoScope<S extends Schema> {
131
143
  beginGroup(label?: string): void;
132
144
  /** Close the grouping session and record the accumulated ops as one entry. */
133
145
  endGroup(label?: string): void;
146
+ /** Every `${modelKey}:${id}` a set of ops will touch (all op kinds). */
147
+ private replayEchoKeys;
148
+ /**
149
+ * Arm async-echo suppression for the rows a replay is about to write. Called
150
+ * synchronously, before `applyOps`, so the marks exist no matter how long the
151
+ * engine takes to surface the echo on the stream. See {@link pendingReplayEchoes}.
152
+ */
153
+ private markReplayEchoes;
154
+ /**
155
+ * If `${schemaKey}:${modelId}` has an armed echo mark, consume one and report
156
+ * that this mutation is our own replay echo (caller drops it). Prunes expired
157
+ * marks opportunistically so a skipped/never-arriving echo can't leak.
158
+ */
159
+ private consumeReplayEcho;
134
160
  /** Resolve a stream mutation's registered name to its schema key, or null. */
135
161
  private resolveSchemaKey;
136
162
  /**
@@ -28,6 +28,13 @@ const normalizeModelAlias = (modelName) => modelName.replace('Model', '').toLowe
28
28
  * Consumers call `record(entry)` after each mutator; `undo()` / `redo()` to
29
29
  * traverse the stacks.
30
30
  */
31
+ /**
32
+ * How long a marked replay-echo stays armed before it's pruned. The real echo
33
+ * arrives within a couple of IndexedDB round-trips (tens of ms); this is a
34
+ * generous safety ceiling so a never-arriving echo (e.g. the commit was skipped
35
+ * offline) can't suppress a genuine later edit to the same row indefinitely.
36
+ */
37
+ const REPLAY_ECHO_TTL_MS = 5000;
31
38
  export class UndoScope {
32
39
  schema;
33
40
  store;
@@ -92,6 +99,23 @@ export class UndoScope {
92
99
  * response) collapses into ONE Cmd+Z. `endGroup()` flushes it.
93
100
  */
94
101
  group = null;
102
+ /**
103
+ * ASYNC replay-echo suppression, keyed by `${modelKey}:${id}`.
104
+ *
105
+ * The synchronous {@link replaying} flag only catches echoes delivered INLINE
106
+ * during `applyOps`. The real engine doesn't emit `transaction:created`
107
+ * synchronously: `SyncClient` defers the commit behind `scheduleSync()` +
108
+ * `await persistMutationQueue()` (an IndexedDB write), so a replayed write's
109
+ * echo lands on the stream AFTER `undo()`/`redo()` has already reset
110
+ * `replaying` and pushed the entry. That late echo would be recorded as a
111
+ * NEW edit — and `record()` clears the redo stack, so every undo silently
112
+ * destroyed its own redo. We mark the (modelKey,id) of every op we're about
113
+ * to replay here (synchronously, before the write), and consume one mark when
114
+ * the matching mutation arrives — independent of WHEN it arrives. Entries
115
+ * carry a TTL so a never-arriving echo (offline: the commit is skipped) can't
116
+ * leak and wrongly suppress a much-later genuine edit to the same row.
117
+ */
118
+ pendingReplayEchoes = new Map();
95
119
  constructor(schema, store, organizationId, options = {}) {
96
120
  this.schema = schema;
97
121
  this.store = store;
@@ -150,6 +174,80 @@ export class UndoScope {
150
174
  return;
151
175
  this.record({ label: label ?? g.label, inverses, forwards });
152
176
  }
177
+ /** Every `${modelKey}:${id}` a set of ops will touch (all op kinds). */
178
+ *replayEchoKeys(ops) {
179
+ for (const op of ops) {
180
+ switch (op.kind) {
181
+ case 'create': {
182
+ const id = op.data.id;
183
+ if (typeof id === 'string')
184
+ yield `${op.modelKey}:${id}`;
185
+ break;
186
+ }
187
+ case 'update':
188
+ yield `${op.modelKey}:${op.patch.id}`;
189
+ break;
190
+ case 'delete':
191
+ yield `${op.modelKey}:${op.id}`;
192
+ break;
193
+ case 'createMany':
194
+ for (const d of op.data) {
195
+ const id = d.id;
196
+ if (typeof id === 'string')
197
+ yield `${op.modelKey}:${id}`;
198
+ }
199
+ break;
200
+ case 'updateMany':
201
+ for (const p of op.patches)
202
+ yield `${op.modelKey}:${p.id}`;
203
+ break;
204
+ case 'deleteMany':
205
+ for (const id of op.ids)
206
+ yield `${op.modelKey}:${id}`;
207
+ break;
208
+ }
209
+ }
210
+ }
211
+ /**
212
+ * Arm async-echo suppression for the rows a replay is about to write. Called
213
+ * synchronously, before `applyOps`, so the marks exist no matter how long the
214
+ * engine takes to surface the echo on the stream. See {@link pendingReplayEchoes}.
215
+ */
216
+ markReplayEchoes(ops) {
217
+ const expiresAt = Date.now() + REPLAY_ECHO_TTL_MS;
218
+ for (const key of this.replayEchoKeys(ops)) {
219
+ const existing = this.pendingReplayEchoes.get(key);
220
+ if (existing) {
221
+ existing.count += 1;
222
+ existing.expiresAt = expiresAt;
223
+ }
224
+ else {
225
+ this.pendingReplayEchoes.set(key, { count: 1, expiresAt });
226
+ }
227
+ }
228
+ }
229
+ /**
230
+ * If `${schemaKey}:${modelId}` has an armed echo mark, consume one and report
231
+ * that this mutation is our own replay echo (caller drops it). Prunes expired
232
+ * marks opportunistically so a skipped/never-arriving echo can't leak.
233
+ */
234
+ consumeReplayEcho(schemaKey, modelId) {
235
+ if (this.pendingReplayEchoes.size === 0)
236
+ return false;
237
+ const now = Date.now();
238
+ for (const [k, v] of this.pendingReplayEchoes) {
239
+ if (v.expiresAt <= now)
240
+ this.pendingReplayEchoes.delete(k);
241
+ }
242
+ const key = `${schemaKey}:${modelId}`;
243
+ const pending = this.pendingReplayEchoes.get(key);
244
+ if (!pending)
245
+ return false;
246
+ pending.count -= 1;
247
+ if (pending.count <= 0)
248
+ this.pendingReplayEchoes.delete(key);
249
+ return true;
250
+ }
153
251
  /** Resolve a stream mutation's registered name to its schema key, or null. */
154
252
  resolveSchemaKey(modelName) {
155
253
  return (this.schemaKeyByAlias.get(modelName) ??
@@ -169,6 +267,13 @@ export class UndoScope {
169
267
  const schemaKey = this.resolveSchemaKey(m.modelName);
170
268
  if (!schemaKey)
171
269
  return;
270
+ // Drop the ASYNC echo of our own replayed writes. The engine surfaces a
271
+ // replay's `transaction:created` only after an IndexedDB-gated commit, i.e.
272
+ // after `replaying` has already reset — so the synchronous flag above misses
273
+ // it. The (modelKey,id) marks armed in `markReplayEchoes` catch it whenever
274
+ // it lands, which is what stops every undo from wiping its own redo stack.
275
+ if (this.consumeReplayEcho(schemaKey, m.modelId))
276
+ return;
172
277
  if (this.tracksModel && !this.tracksModel(schemaKey))
173
278
  return;
174
279
  const ops = buildUndoOps(m, schemaKey);
@@ -331,7 +436,10 @@ export class UndoScope {
331
436
  const tx = createTransaction(this.schema, this.store, this.organizationId);
332
437
  const ops = resolveOps(entry.inverses, entry.forwards, this.store, this.conflictPolicy);
333
438
  // Suppress our own stream listener so replayed writes don't record as
334
- // new undo entries. Cleared in `finally` even if a replay op throws.
439
+ // new undo entries. `replaying` covers inline echoes; `markReplayEchoes`
440
+ // covers the engine's async (IDB-gated) echo that lands after this method
441
+ // returns. Cleared in `finally` even if a replay op throws.
442
+ this.markReplayEchoes(ops);
335
443
  this.replaying = true;
336
444
  try {
337
445
  await applyOps(tx, ops);
@@ -366,6 +474,8 @@ export class UndoScope {
366
474
  return;
367
475
  const tx = createTransaction(this.schema, this.store, this.organizationId);
368
476
  const ops = resolveOps(entry.forwards, entry.inverses, this.store, this.conflictPolicy);
477
+ // See undo(): arm async-echo suppression before the replayed writes.
478
+ this.markReplayEchoes(ops);
369
479
  this.replaying = true;
370
480
  try {
371
481
  await applyOps(tx, ops);
@@ -391,6 +501,7 @@ export class UndoScope {
391
501
  this.undoStack = [];
392
502
  this.redoStack = [];
393
503
  this.batch = [];
504
+ this.pendingReplayEchoes.clear();
394
505
  this.emitChange();
395
506
  }
396
507
  /** Introspection — for debug panels / e2e tests. */
@@ -407,6 +518,7 @@ export class UndoScope {
407
518
  this.recordListeners.clear();
408
519
  this.changeListeners.clear();
409
520
  this.batch = [];
521
+ this.pendingReplayEchoes.clear();
410
522
  }
411
523
  }
412
524
  /**
@@ -17,6 +17,7 @@
17
17
  * - `generateMigrationPlan` — the destructive-aware counterpart driven by the
18
18
  * {@link diffSchema} step list (drops, renames, type casts, backfills).
19
19
  */
20
+ import { AbloValidationError } from '../errors.js';
20
21
  import { resolveTenancy, tenancyColumn } from './tenancy.js';
21
22
  // ── Identifier safety ────────────────────────────────────────────────────────
22
23
  /** Postgres unquoted-identifier-safe slug: lowercase `[a-z0-9_]`, ≤50 chars. */
@@ -289,7 +290,7 @@ function sqlLiteral(value, fieldType) {
289
290
  switch (fieldType) {
290
291
  case 'number':
291
292
  if (typeof value !== 'number' || !Number.isFinite(value)) {
292
- throw new Error(`backfill for a number field must be a finite number, got ${JSON.stringify(value)}`);
293
+ throw new AbloValidationError(`backfill for a number field must be a finite number, got ${JSON.stringify(value)}`, { code: 'schema_definition_invalid' });
293
294
  }
294
295
  return String(value);
295
296
  case 'boolean':
@@ -314,6 +315,14 @@ export function generateMigrationPlan(steps, opts) {
314
315
  const qs = q(targetSchema);
315
316
  const statements = [];
316
317
  const concurrent = [];
318
+ // The app schema must exist before any statement targets it. On a fresh
319
+ // org's FIRST push (`prev = null`) the migration plan IS the provisioning —
320
+ // `app_<orgId>` has never been created, and skipping this line made every
321
+ // first push die with `3F000 invalid_schema_name` at statement 0. Idempotent
322
+ // (`IF NOT EXISTS`), so emitting it on every later migration is free.
323
+ if (steps.length > 0 && targetSchema !== 'public') {
324
+ statements.push(`CREATE SCHEMA IF NOT EXISTS ${qs};`);
325
+ }
317
326
  const qtFor = (table) => `${qs}.${q(table)}`;
318
327
  const tableOfModel = (schema, key) => {
319
328
  const m = schema?.models[key];
@@ -326,8 +335,8 @@ export function generateMigrationPlan(steps, opts) {
326
335
  switch (step.kind) {
327
336
  case 'create_model': {
328
337
  // Reuse the provisioner for the full table (base cols + fields + enum
329
- // checks + RLS), minus its `CREATE SCHEMA` (the schema already exists
330
- // mid-migration).
338
+ // checks + RLS), minus its `CREATE SCHEMA` (the plan header above
339
+ // already emitted it once — don't repeat it per model).
331
340
  const def = next.models[step.model];
332
341
  if (!def)
333
342
  break;
@@ -23,6 +23,7 @@
23
23
  * });
24
24
  */
25
25
  import { z } from 'zod';
26
+ import { AbloValidationError } from '../errors.js';
26
27
  // ── Helpers ───────────────────────────────────────────────────────────────
27
28
  /** Distinguish a Zod schema from a plain object shape (ZodRawShape). */
28
29
  function isZodSchema(value) {
@@ -179,7 +180,7 @@ export function resolveFieldMeta(schema) {
179
180
  }
180
181
  function assertColumnName(column) {
181
182
  if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(column)) {
182
- throw new Error(`field.from(): invalid column identifier ${JSON.stringify(column)}`);
183
+ throw new AbloValidationError(`field.from(): invalid column identifier ${JSON.stringify(column)}`, { code: 'schema_definition_invalid' });
183
184
  }
184
185
  }
185
186
  /** Add sync-engine chain methods to a Zod schema without disturbing its type. */
@@ -188,14 +188,16 @@ export interface ModelOptions {
188
188
  entityRoles?: EntityRole | readonly EntityRole[];
189
189
  /**
190
190
  * Whether clients may issue CREATE/UPDATE/DELETE mutations for this
191
- * model via the `commit` wire protocol. Default: false.
191
+ * model via the `commit` wire protocol. Default: **true** — declaring a
192
+ * model in the schema IS the opt-in; if you put an entity in your synced
193
+ * schema, you almost always want to write it (product decision
194
+ * 2026-06-10, reversing the earlier default-deny that made every fresh
195
+ * quickstart's first write die with `server_execute_unknown_model`).
192
196
  *
193
- * Safety-by-default: a newly-declared schema entity is read-only from
194
- * the client side until the author explicitly opts into wire mutability.
195
- * Prevents the class of bug where adding a new entity to the schema
196
- * silently exposes it as a write surface (the 2026-04-20 `AgentJob`
197
- * incident) OR where internal tables (`sync_deltas`, `presences`,
198
- * digest/ingestion tables) become writable by accident.
197
+ * Opt OUT for server-managed projections (stats, digests, audit views):
198
+ * `mutable: false`, or the `readOnly.*` sugar which sets it for you.
199
+ * That keeps the 2026-04-20 `AgentJob`-class protection available where
200
+ * it matters, as a deliberate marking instead of a silent default.
199
201
  *
200
202
  * The server's `buildModelMap` (src/server/commit.ts) derives
201
203
  * the mutation allowlist from this flag — no parallel hardcoded list.
@@ -99,7 +99,7 @@ export function model(shape, relations, options) {
99
99
  scope: options?.scope,
100
100
  grants: options?.grants,
101
101
  entityRoles: normalizeEntityRoles(options?.entityRoles),
102
- mutable: options?.mutable,
102
+ mutable: options?.mutable ?? true,
103
103
  lazyObservable: options?.lazyObservable,
104
104
  computed: options?.computed,
105
105
  autoFill: options?.autoFill,
@@ -167,7 +167,13 @@ export function defineSchema(models, options) {
167
167
  const persist = def.persist
168
168
  ? { ...def.persist, store: def.persist.store ?? typename }
169
169
  : undefined;
170
- resolvedModels[name] = { ...def, typename, persist };
170
+ // Physical table defaults to the schema key — the SAME rule the
171
+ // provisioner/planner use (`tableName ?? key`), resolved here once so the
172
+ // serialized artifact always carries it. Required now that models are
173
+ // mutable by default: the server's `buildModelMap` rejects a mutable
174
+ // model with no `tableName`, which would otherwise break every commit.
175
+ const tableName = def.tableName ?? name;
176
+ resolvedModels[name] = { ...def, typename, tableName, persist };
171
177
  }
172
178
  validateSyncGroupSchema(resolvedModels);
173
179
  return {
@@ -25,6 +25,7 @@
25
25
  * `FieldMeta` (the server does no field-shape validation anyway)
26
26
  */
27
27
  import { z } from 'zod';
28
+ import { AbloValidationError } from '../errors.js';
28
29
  import { baseFieldsSchema, } from './schema.js';
29
30
  /** Current schema-JSON envelope version. Bump on a breaking change to the
30
31
  * JSON shape itself (not the user's schema). v2 replaced the per-model
@@ -201,7 +202,7 @@ export function fromSchemaJSON(json) {
201
202
  export function parseSchema(json) {
202
203
  const parsed = JSON.parse(json);
203
204
  if (parsed.v !== SCHEMA_JSON_VERSION) {
204
- throw new Error(`parseSchema: unsupported schema-JSON version ${parsed.v} (expected ${SCHEMA_JSON_VERSION})`);
205
+ throw new AbloValidationError(`parseSchema: unsupported schema-JSON version ${parsed.v} (expected ${SCHEMA_JSON_VERSION})`, { code: 'schema_definition_invalid' });
205
206
  }
206
207
  return fromSchemaJSON(parsed);
207
208
  }
@@ -7,11 +7,18 @@
7
7
  * - `hosted` — Ablo's control-plane database.
8
8
  * - `selfHosted` — the customer's database, same execution path as hosted.
9
9
  * - `source` — a customer-owned endpoint (credentialless ingestion).
10
+ *
11
+ * @internal Deployment topology, not product vocabulary. Customers never see a
12
+ * "storage mode" — their story is `Ablo({ schema, apiKey, databaseUrl })` and
13
+ * one `datasource` resource (docs/plans/sync-engine-stripe-story-scope.md).
14
+ * This export exists for the sync-server host only.
10
15
  */
11
16
  import { z } from 'zod';
17
+ /** @internal See module note — host-deployment vocabulary, never customer-facing. */
12
18
  export declare const storageModeSchema: z.ZodEnum<{
13
19
  source: "source";
14
20
  hosted: "hosted";
15
21
  selfHosted: "selfHosted";
16
22
  }>;
23
+ /** @internal See module note — host-deployment vocabulary, never customer-facing. */
17
24
  export type StorageMode = z.infer<typeof storageModeSchema>;