@abloatai/ablo 0.9.2 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +6 -0
- package/README.md +40 -22
- package/dist/BaseSyncedStore.d.ts +2 -36
- package/dist/BaseSyncedStore.js +5 -53
- package/dist/NetworkMonitor.js +4 -1
- package/dist/SyncClient.d.ts +10 -5
- package/dist/SyncClient.js +63 -1
- package/dist/SyncEngineContext.js +5 -1
- package/dist/auth/index.js +3 -1
- package/dist/cli.cjs +302645 -0
- package/dist/client/Ablo.d.ts +12 -3
- package/dist/client/Ablo.js +28 -2
- package/dist/client/ApiClient.js +39 -6
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/createModelProxy.d.ts +9 -0
- package/dist/client/createModelProxy.js +34 -10
- package/dist/client/persistence.d.ts +6 -1
- package/dist/client/persistence.js +1 -1
- package/dist/client/registerDataSource.d.ts +4 -4
- package/dist/client/registerDataSource.js +39 -31
- package/dist/client/writeOptionsSchema.d.ts +50 -0
- package/dist/client/writeOptionsSchema.js +57 -0
- package/dist/core/index.d.ts +18 -26
- package/dist/core/index.js +22 -46
- package/dist/errorCodes.d.ts +13 -0
- package/dist/errorCodes.js +16 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/interfaces/index.d.ts +10 -0
- package/dist/mutators/UndoManager.d.ts +31 -5
- package/dist/mutators/UndoManager.js +113 -1
- package/dist/schema/ddl.js +2 -1
- package/dist/schema/field.js +2 -1
- package/dist/schema/serialize.js +2 -1
- package/dist/server/storage-mode.d.ts +7 -0
- package/dist/server/storage-mode.js +6 -0
- package/dist/source/adapters/drizzle.js +3 -2
- package/dist/source/adapters/kysely.d.ts +68 -0
- package/dist/source/adapters/kysely.js +210 -0
- package/dist/source/adapters/memory.js +2 -1
- package/dist/source/adapters/prisma.js +3 -2
- package/dist/source/index.js +2 -1
- package/dist/transactions/TransactionQueue.d.ts +6 -7
- package/dist/transactions/TransactionQueue.js +33 -9
- package/dist/utils/duration.js +3 -2
- package/docs/client-behavior.md +1 -1
- package/docs/data-sources.md +61 -42
- package/docs/guarantees.md +2 -2
- package/docs/index.md +2 -2
- package/docs/integration-guide.md +4 -7
- package/docs/mcp.md +1 -1
- package/docs/quickstart.md +84 -37
- package/docs/schema-contract.md +2 -4
- package/llms-full.txt +360 -0
- package/llms.txt +14 -9
- package/package.json +22 -2
package/dist/core/index.js
CHANGED
|
@@ -1,52 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @abloatai/ablo/core — Framework extension
|
|
3
3
|
*
|
|
4
|
-
* Only imported by
|
|
5
|
-
* the
|
|
6
|
-
* Regular model files and components should NOT import from
|
|
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,
|
|
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
|
-
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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.
|
|
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
|
-
|
|
35
|
-
//
|
|
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
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
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';
|
package/dist/errorCodes.d.ts
CHANGED
|
@@ -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;
|
package/dist/errorCodes.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
/**
|
package/dist/schema/ddl.js
CHANGED
|
@@ -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
|
|
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':
|
package/dist/schema/field.js
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/schema/serialize.js
CHANGED
|
@@ -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
|
|
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>;
|
|
@@ -7,6 +7,12 @@
|
|
|
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 const storageModeSchema = z.enum(['hosted', 'source', 'selfHosted']);
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* We use `sql` + `db.execute` for ALL writes (not the fluent builder) so the
|
|
29
29
|
* adapter is one small, fully-typed unit with no per-driver builder generics.
|
|
30
30
|
*/
|
|
31
|
+
import { AbloValidationError } from '../../errors.js';
|
|
31
32
|
import { sql } from 'drizzle-orm';
|
|
32
33
|
import { outboxEventSchema } from '../contract.js';
|
|
33
34
|
import { adapterTableMigrations } from '../migrations.js';
|
|
@@ -40,7 +41,7 @@ function rowsOf(result) {
|
|
|
40
41
|
function rowId(op) {
|
|
41
42
|
const id = op.id ?? op.input?.id;
|
|
42
43
|
if (typeof id !== 'string' || id.length === 0) {
|
|
43
|
-
throw new
|
|
44
|
+
throw new AbloValidationError(`operation on "${op.model}" requires an id`, { code: 'source_operation_id_required' });
|
|
44
45
|
}
|
|
45
46
|
return id;
|
|
46
47
|
}
|
|
@@ -76,7 +77,7 @@ export function drizzleDataSource(db, schema) {
|
|
|
76
77
|
const modelColumns = (model) => {
|
|
77
78
|
const mc = maps.get(model);
|
|
78
79
|
if (!mc)
|
|
79
|
-
throw new
|
|
80
|
+
throw new AbloValidationError(`drizzleDataSource: no model "${model}" in schema`, { code: 'source_adapter_misconfigured' });
|
|
80
81
|
return mc;
|
|
81
82
|
};
|
|
82
83
|
const columnFor = (mc, field) => mc.fieldToColumn.get(field) ?? camelToSnake(field);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kysely Data Source adapter. Same adapter interface + conformance shape as
|
|
3
|
+
* `prismaDataSource` / `drizzleDataSource`, built against Kysely's REAL
|
|
4
|
+
* query-builder API:
|
|
5
|
+
* - `db.transaction().execute(async (trx) => …)` — interactive transaction.
|
|
6
|
+
* - `insertInto/updateTable/deleteFrom/selectFrom` + `returningAll()` —
|
|
7
|
+
* the fluent builder; table/column names are plain strings, so no raw
|
|
8
|
+
* SQL tag is needed and this module imports NOTHING from `kysely`
|
|
9
|
+
* (structural `KyselyLike`, mirroring the Prisma adapter's zero-dep
|
|
10
|
+
* `PrismaLike`).
|
|
11
|
+
*
|
|
12
|
+
* SCHEMA-DRIVEN COLUMNS. Kysely is SQL-near: it passes the column names you
|
|
13
|
+
* give it through verbatim (no Prisma-style `@map`). Like the Drizzle
|
|
14
|
+
* adapter, every table + column name is derived from the SAME rule the
|
|
15
|
+
* provisioner uses (`generateProvisionPlan`):
|
|
16
|
+
* table = `model.tableName ?? key`
|
|
17
|
+
* column = `fieldMeta.column ?? camelToSnake(field)` (+ the tenancy column)
|
|
18
|
+
* so `ablo migrate` (which emits `operator_id`) and this adapter COMPOSE.
|
|
19
|
+
* The adapter is the translation boundary: rows in/out are field-keyed (the
|
|
20
|
+
* SDK shape); the physical columns it reads/writes are snake_case.
|
|
21
|
+
*
|
|
22
|
+
* JSONB note: the outbox `data` / idempotency `response` values are passed
|
|
23
|
+
* as JSON strings — Postgres infers the parameter type from the target
|
|
24
|
+
* `jsonb` column, so the coercion is server-side and driver-agnostic (no
|
|
25
|
+
* `::jsonb` cast available without raw SQL).
|
|
26
|
+
*/
|
|
27
|
+
import type { DataSourceAdapter, Row } from '../adapter.js';
|
|
28
|
+
import type { Schema, SchemaRecord } from '../../schema/schema.js';
|
|
29
|
+
/**
|
|
30
|
+
* The subset of a Kysely instance (or transaction handle) the adapter calls.
|
|
31
|
+
* Structural on purpose — declared with method shorthand so a real
|
|
32
|
+
* `Kysely<DB>` (whose params are narrowed to `keyof DB`) stays assignable
|
|
33
|
+
* under TypeScript's method bivariance, exactly like `PrismaLike`.
|
|
34
|
+
*/
|
|
35
|
+
export interface KyselyLike {
|
|
36
|
+
selectFrom(table: string): KyselySelectBuilder;
|
|
37
|
+
insertInto(table: string): KyselyInsertBuilder;
|
|
38
|
+
updateTable(table: string): KyselyUpdateBuilder;
|
|
39
|
+
deleteFrom(table: string): KyselyDeleteBuilder;
|
|
40
|
+
transaction(): KyselyTransactionBuilder;
|
|
41
|
+
}
|
|
42
|
+
export interface KyselyTransactionBuilder {
|
|
43
|
+
execute<T>(fn: (trx: KyselyLike) => Promise<T>): Promise<T>;
|
|
44
|
+
}
|
|
45
|
+
export interface KyselySelectBuilder {
|
|
46
|
+
selectAll(): KyselySelectBuilder;
|
|
47
|
+
where(column: string, operator: string, value: unknown): KyselySelectBuilder;
|
|
48
|
+
orderBy(column: string, direction: 'asc' | 'desc'): KyselySelectBuilder;
|
|
49
|
+
limit(limit: number): KyselySelectBuilder;
|
|
50
|
+
execute(): Promise<readonly Row[]>;
|
|
51
|
+
}
|
|
52
|
+
export interface KyselyInsertBuilder {
|
|
53
|
+
values(row: Row): KyselyInsertBuilder;
|
|
54
|
+
returningAll(): KyselyInsertBuilder;
|
|
55
|
+
execute(): Promise<readonly Row[]>;
|
|
56
|
+
}
|
|
57
|
+
export interface KyselyUpdateBuilder {
|
|
58
|
+
set(patch: Row): KyselyUpdateBuilder;
|
|
59
|
+
where(column: string, operator: string, value: unknown): KyselyUpdateBuilder;
|
|
60
|
+
returningAll(): KyselyUpdateBuilder;
|
|
61
|
+
execute(): Promise<readonly Row[]>;
|
|
62
|
+
}
|
|
63
|
+
export interface KyselyDeleteBuilder {
|
|
64
|
+
where(column: string, operator: string, value: unknown): KyselyDeleteBuilder;
|
|
65
|
+
returningAll(): KyselyDeleteBuilder;
|
|
66
|
+
execute(): Promise<readonly Row[]>;
|
|
67
|
+
}
|
|
68
|
+
export declare function kyselyDataSource<S extends SchemaRecord>(db: KyselyLike, schema: Schema<S>): DataSourceAdapter;
|