@abloatai/ablo 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +20 -0
- package/README.md +3 -3
- package/dist/BaseSyncedStore.d.ts +72 -195
- package/dist/BaseSyncedStore.js +240 -625
- package/dist/Database.d.ts +8 -9
- package/dist/Database.js +9 -14
- package/dist/LazyReferenceCollection.js +5 -0
- package/dist/Model.js +6 -8
- package/dist/ModelRegistry.d.ts +25 -6
- package/dist/ModelRegistry.js +12 -6
- package/dist/NetworkMonitor.js +1 -1
- package/dist/ObjectPool.js +9 -3
- package/dist/SyncClient.d.ts +15 -5
- package/dist/SyncClient.js +152 -66
- package/dist/SyncEngineContext.d.ts +1 -3
- package/dist/SyncEngineContext.js +1 -2
- package/dist/agent/Agent.d.ts +21 -8
- package/dist/agent/Agent.js +26 -13
- package/dist/ai-sdk/coordination-context.js +9 -3
- package/dist/ai-sdk/wrap.d.ts +1 -1
- package/dist/ai-sdk/wrap.js +1 -1
- package/dist/auth/credentialPolicy.d.ts +10 -2
- package/dist/auth/credentialSource.d.ts +1 -13
- package/dist/auth/credentialSource.js +6 -13
- package/dist/auth/index.js +3 -3
- package/dist/cli.cjs +328 -262
- package/dist/client/Ablo.d.ts +35 -764
- package/dist/client/Ablo.js +64 -688
- package/dist/client/ApiClient.d.ts +14 -1
- package/dist/client/ApiClient.js +63 -9
- package/dist/client/auth.d.ts +8 -17
- package/dist/client/auth.js +42 -6
- package/dist/client/consoleLogger.d.ts +36 -0
- package/dist/client/consoleLogger.js +45 -0
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/createModelProxy.js +10 -6
- package/dist/client/credentialEndpoint.d.ts +63 -0
- package/dist/client/credentialEndpoint.js +87 -0
- package/dist/client/hostedEndpoints.d.ts +24 -0
- package/dist/client/hostedEndpoints.js +24 -0
- package/dist/client/httpClient.d.ts +1 -1
- package/dist/client/identity.js +2 -1
- package/dist/client/modelRegistration.d.ts +14 -0
- package/dist/client/modelRegistration.js +321 -0
- package/dist/client/options.d.ts +396 -0
- package/dist/client/options.js +10 -0
- package/dist/client/resourceTypes.d.ts +344 -0
- package/dist/client/resourceTypes.js +13 -0
- package/dist/client/schemaConfig.d.ts +56 -0
- package/dist/client/schemaConfig.js +188 -0
- package/dist/client/sessionMint.d.ts +1 -1
- package/dist/client/validateAbloOptions.js +1 -1
- package/dist/client/wsMutationExecutor.d.ts +33 -0
- package/dist/client/wsMutationExecutor.js +77 -0
- package/dist/context.js +0 -3
- package/dist/coordination/index.d.ts +8 -1
- package/dist/coordination/index.js +24 -1
- package/dist/coordination/trace.d.ts +0 -1
- package/dist/coordination/trace.js +0 -1
- package/dist/core/DatabaseManager.d.ts +0 -1
- package/dist/core/DatabaseManager.js +26 -24
- package/dist/core/QueryProcessor.js +7 -8
- package/dist/core/QueryView.d.ts +14 -2
- package/dist/core/QueryView.js +4 -5
- package/dist/core/StoreManager.d.ts +2 -2
- package/dist/core/StoreManager.js +5 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/openIDBWithTimeout.js +15 -11
- package/dist/core/query-utils.js +1 -1
- package/dist/core/storeContract.d.ts +143 -0
- package/dist/core/storeContract.js +16 -0
- package/dist/errorCodes.d.ts +11 -2
- package/dist/errorCodes.js +155 -137
- package/dist/errors.d.ts +18 -7
- package/dist/errors.js +14 -1
- package/dist/index.js +5 -0
- package/dist/interfaces/index.d.ts +6 -20
- package/dist/keys/index.js +18 -9
- package/dist/mutators/UndoManager.js +6 -1
- package/dist/mutators/defineMutators.d.ts +1 -3
- package/dist/policy/types.d.ts +1 -1
- package/dist/query/client.d.ts +11 -0
- package/dist/query/client.js +81 -52
- package/dist/react/AbloProvider.d.ts +6 -10
- package/dist/react/AbloProvider.js +22 -15
- package/dist/react/context.d.ts +2 -118
- package/dist/react/useAblo.js +1 -1
- package/dist/react/useErrorListener.js +1 -1
- package/dist/react/useMutationFailureListener.js +1 -1
- package/dist/react/useMutators.js +7 -1
- package/dist/react/useReactive.js +0 -1
- package/dist/react/useUndoScope.js +2 -2
- package/dist/schema/ddlLock.d.ts +39 -0
- package/dist/schema/ddlLock.js +51 -0
- package/dist/schema/field.js +1 -1
- package/dist/schema/index.d.ts +4 -3
- package/dist/schema/index.js +15 -5
- package/dist/schema/model.d.ts +3 -3
- package/dist/schema/model.js +2 -2
- package/dist/schema/openapi.js +5 -1
- package/dist/schema/queries.d.ts +1 -1
- package/dist/schema/queries.js +1 -0
- package/dist/schema/residency.d.ts +35 -0
- package/dist/schema/residency.js +27 -0
- package/dist/schema/roles.d.ts +25 -0
- package/dist/schema/roles.js +37 -0
- package/dist/schema/schema.d.ts +3 -3
- package/dist/schema/schema.js +11 -7
- package/dist/schema/serialize.d.ts +8 -8
- package/dist/schema/sync-delta-row.d.ts +2 -2
- package/dist/schema/sync-delta-row.js +2 -2
- package/dist/server/storage-mode.d.ts +8 -1
- package/dist/server/storage-mode.js +14 -4
- package/dist/source/adapter.d.ts +1 -1
- package/dist/source/adapters/drizzle.js +4 -3
- package/dist/source/adapters/kysely.js +4 -3
- package/dist/source/adapters/memory.js +1 -1
- package/dist/source/adapters/prisma.js +5 -4
- package/dist/source/conformance.js +10 -8
- package/dist/source/connector.js +4 -2
- package/dist/source/factory.d.ts +106 -0
- package/dist/source/factory.js +273 -0
- package/dist/source/index.d.ts +11 -461
- package/dist/source/index.js +14 -416
- package/dist/source/next.d.ts +1 -1
- package/dist/source/next.js +1 -1
- package/dist/source/pushQueue.d.ts +1 -1
- package/dist/source/pushQueue.js +6 -3
- package/dist/source/signing.d.ts +59 -0
- package/dist/source/signing.js +142 -0
- package/dist/source/types.d.ts +334 -0
- package/dist/source/types.js +44 -0
- package/dist/stores/ObjectStore.d.ts +1 -1
- package/dist/stores/ObjectStore.js +23 -23
- package/dist/stores/SyncActionStore.d.ts +1 -1
- package/dist/stores/SyncActionStore.js +70 -35
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +1 -0
- package/dist/sync/AreaOfInterestManager.d.ts +2 -2
- package/dist/sync/AreaOfInterestManager.js +1 -1
- package/dist/sync/BootstrapHelper.d.ts +1 -3
- package/dist/sync/BootstrapHelper.js +29 -8
- package/dist/sync/ConnectionManager.d.ts +8 -0
- package/dist/sync/ConnectionManager.js +19 -4
- package/dist/sync/HydrationCoordinator.d.ts +12 -0
- package/dist/sync/HydrationCoordinator.js +25 -5
- package/dist/sync/NetworkProbe.d.ts +3 -2
- package/dist/sync/NetworkProbe.js +7 -6
- package/dist/sync/SyncWebSocket.d.ts +71 -120
- package/dist/sync/SyncWebSocket.js +324 -630
- package/dist/sync/awaitClaimGrant.js +27 -19
- package/dist/sync/bootstrapApply.d.ts +60 -0
- package/dist/sync/bootstrapApply.js +65 -0
- package/dist/sync/commitFrames.d.ts +43 -0
- package/dist/sync/commitFrames.js +94 -0
- package/dist/sync/createClaimStream.js +3 -2
- package/dist/sync/createSnapshot.d.ts +1 -3
- package/dist/sync/credentialLifecycle.d.ts +175 -0
- package/dist/sync/credentialLifecycle.js +329 -0
- package/dist/sync/deltaPipeline.d.ts +111 -0
- package/dist/sync/deltaPipeline.js +262 -0
- package/dist/sync/groupChange.d.ts +111 -0
- package/dist/sync/groupChange.js +246 -0
- package/dist/sync/heartbeat.d.ts +62 -0
- package/dist/sync/heartbeat.js +91 -0
- package/dist/sync/participants.d.ts +8 -8
- package/dist/sync/syncCursor.d.ts +44 -0
- package/dist/sync/syncCursor.js +59 -0
- package/dist/sync/syncPlan.d.ts +62 -0
- package/dist/sync/syncPlan.js +51 -0
- package/dist/sync/wsFrameHandlers.d.ts +117 -0
- package/dist/sync/wsFrameHandlers.js +378 -0
- package/dist/testing/fixtures/bootstrap.d.ts +9 -11
- package/dist/testing/fixtures/deltas.d.ts +1 -1
- package/dist/testing/fixtures/models.js +1 -0
- package/dist/testing/helpers/sync-engine-harness.d.ts +0 -3
- package/dist/testing/helpers/sync-engine-harness.js +2 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/mocks/MockSyncContext.d.ts +1 -17
- package/dist/testing/mocks/MockSyncContext.js +1 -32
- package/dist/testing/mocks/MockSyncStore.d.ts +4 -4
- package/dist/testing/mocks/MockWebSocket.d.ts +2 -2
- package/dist/transactions/TransactionQueue.d.ts +20 -56
- package/dist/transactions/TransactionQueue.js +101 -487
- package/dist/transactions/TransactionStore.d.ts +18 -0
- package/dist/transactions/TransactionStore.js +51 -0
- package/dist/transactions/coalesceRules.d.ts +34 -0
- package/dist/transactions/coalesceRules.js +117 -0
- package/dist/transactions/commitPayload.d.ts +134 -0
- package/dist/transactions/commitPayload.js +152 -0
- package/dist/transactions/deltaConfirmation.d.ts +60 -0
- package/dist/transactions/deltaConfirmation.js +223 -0
- package/dist/transactions/optimistic.d.ts +24 -0
- package/dist/transactions/optimistic.js +45 -0
- package/dist/transactions/persistedReplay.d.ts +93 -0
- package/dist/transactions/persistedReplay.js +105 -0
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.js +0 -5
- package/dist/types/modelData.d.ts +12 -0
- package/dist/types/modelData.js +11 -0
- package/dist/types/participant.d.ts +21 -0
- package/dist/types/participant.js +12 -0
- package/dist/types/streams.d.ts +8 -14
- package/dist/utils/mobx-setup.d.ts +1 -1
- package/dist/utils/mobx-setup.js +2 -2
- package/dist/wire/errorEnvelope.d.ts +11 -2
- package/dist/wire/errorEnvelope.js +14 -2
- package/dist/wire/frames.d.ts +90 -0
- package/dist/wire/frames.js +55 -1
- package/dist/wire/index.d.ts +4 -1
- package/dist/wire/index.js +20 -1
- package/dist/wire/protocol.d.ts +45 -0
- package/dist/wire/protocol.js +45 -0
- package/dist/wire/protocolVersion.d.ts +56 -0
- package/dist/wire/protocolVersion.js +63 -0
- package/docs/api-keys.md +4 -3
- package/docs/examples/existing-python-backend.md +3 -3
- package/docs/identity.md +4 -4
- package/docs/integration-guide.md +1 -1
- package/docs/react.md +1 -1
- package/docs/sessions.md +5 -7
- package/package.json +15 -13
- package/dist/client/index.d.ts +0 -36
- package/dist/client/index.js +0 -33
- package/dist/config/index.d.ts +0 -10
- package/dist/config/index.js +0 -12
- package/dist/interfaces/headless.d.ts +0 -95
- package/dist/interfaces/headless.js +0 -41
- package/dist/query/index.d.ts +0 -6
- package/dist/query/index.js +0 -5
- package/dist/realtime/index.d.ts +0 -10
- package/dist/realtime/index.js +0 -9
- package/dist/schema/plane.d.ts +0 -23
- package/dist/schema/plane.js +0 -19
- package/dist/server/next.d.ts +0 -51
- package/dist/server/next.js +0 -47
- package/dist/sync/OfflineFlush.d.ts +0 -9
- package/dist/sync/OfflineFlush.js +0 -22
- package/dist/sync/OfflineTransactionStore.d.ts +0 -37
- package/dist/sync/OfflineTransactionStore.js +0 -263
- package/dist/transactions/index.d.ts +0 -16
- package/dist/transactions/index.js +0 -7
package/dist/client/Ablo.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* bootstrap, offline queue, DI adapters) behind a single function call.
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
|
-
* import { Ablo } from '@abloatai/ablo
|
|
8
|
+
* import { Ablo } from '@abloatai/ablo';
|
|
9
9
|
* import { schema } from './schema';
|
|
10
10
|
*
|
|
11
11
|
* const sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
@@ -18,21 +18,18 @@
|
|
|
18
18
|
* });
|
|
19
19
|
* await sync.reports.delete({ id: reportId });
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
22
|
-
import { baseFieldsSchema } from '../schema/schema.js';
|
|
23
|
-
import { schemaHash } from '../schema/serialize.js';
|
|
24
|
-
import { AbloError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, AbloNotFoundError, translateHttpError, toAbloError, claimedError } from '../errors.js';
|
|
21
|
+
import { AbloAuthenticationError, AbloConnectionError, AbloValidationError, AbloNotFoundError, translateHttpError, toAbloError, claimedError } from '../errors.js';
|
|
25
22
|
import { descriptionFromMeta } from '../coordination/schema.js';
|
|
26
|
-
import { LoadStrategy, PropertyType } from '../types/index.js';
|
|
27
23
|
import { initSyncEngine } from '../context.js';
|
|
28
24
|
import { noopObservability, browserOnlineStatus, defaultSessionErrorDetector, noopAnalytics, } from '../SyncEngineContext.js';
|
|
29
25
|
import { alwaysOnline } from '../adapters/alwaysOnline.js';
|
|
30
26
|
import { validateAbloOptions } from './validateAbloOptions.js';
|
|
27
|
+
import { ObjectPool } from '../ObjectPool.js';
|
|
28
|
+
import {} from '../auth/index.js';
|
|
31
29
|
import { mintSession } from './sessionMint.js';
|
|
32
30
|
import { createAuthCredentialSource } from '../auth/credentialSource.js';
|
|
33
31
|
import { createInternalComponents } from './createInternalComponents.js';
|
|
34
32
|
import { resolveParticipantIdentity } from './identity.js';
|
|
35
|
-
import { Model } from '../Model.js';
|
|
36
33
|
import { BaseSyncedStore } from '../BaseSyncedStore.js';
|
|
37
34
|
import { createPresenceStream } from '../sync/createPresenceStream.js';
|
|
38
35
|
import { createClaimStream } from '../sync/createClaimStream.js';
|
|
@@ -41,675 +38,21 @@ import { createSnapshot } from '../sync/createSnapshot.js';
|
|
|
41
38
|
import { reconcileFunctionalUpdate } from './functionalUpdate.js';
|
|
42
39
|
import { createParticipantManager } from '../sync/participants.js';
|
|
43
40
|
import { createProtocolClient, } from './ApiClient.js';
|
|
44
|
-
// Value import is cycle-safe: httpClient.js
|
|
45
|
-
//
|
|
41
|
+
// Value import is cycle-safe: httpClient.js and ApiClient.js take the client
|
|
42
|
+
// types from the `options`/`resourceTypes` leaves, never from this module.
|
|
46
43
|
import { createAbloHttpClient, } from './httpClient.js';
|
|
47
44
|
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfCliKeyMismatch, warnIfDatabaseUrlEnvIgnored, warnIfDatabaseUrlDeprecated, } from './auth.js';
|
|
48
45
|
import { registerDataSource } from './registerDataSource.js';
|
|
49
|
-
import { shouldUseInMemoryPersistence
|
|
46
|
+
import { shouldUseInMemoryPersistence } from './persistence.js';
|
|
47
|
+
import { deriveConfigFromSchema } from './schemaConfig.js';
|
|
48
|
+
import { registerModelsFromSchema } from './modelRegistration.js';
|
|
49
|
+
import { createConsoleLogger, resolveLogLevel } from './consoleLogger.js';
|
|
50
|
+
import { createDefaultMutationExecutor } from './wsMutationExecutor.js';
|
|
51
|
+
export { computeFKDepthPriority } from './schemaConfig.js';
|
|
50
52
|
import { createModelProxy } from './createModelProxy.js';
|
|
51
53
|
import { assertWriteOptions } from './writeOptionsSchema.js';
|
|
52
|
-
// ── Config derivation from schema ─────────────────────────────────────────
|
|
53
|
-
/**
|
|
54
|
-
* Compute a create-priority map from schema `belongsTo` relations using
|
|
55
|
-
* Tarjan's strongly-connected-components algorithm.
|
|
56
|
-
*
|
|
57
|
-
* The FK graph has an edge `child → parent` for every `belongsTo`. Tarjan
|
|
58
|
-
* runs a single linear DFS that simultaneously (a) detects cycles by
|
|
59
|
-
* grouping mutually-reachable nodes into SCCs and (b) emits those SCCs
|
|
60
|
-
* in reverse topological order of the condensation graph. In this edge
|
|
61
|
-
* convention a "sink" SCC has no outgoing edges — i.e. no parents — so
|
|
62
|
-
* it is an *FK root* (`organizations`, `themes`, etc.). Tarjan emits
|
|
63
|
-
* roots first and leaves last, exactly the order in which rows must be
|
|
64
|
-
* inserted to satisfy FK constraints.
|
|
65
|
-
*
|
|
66
|
-
* Priorities are assigned by emit order: SCC #0 → 10, SCC #1 → 20, …
|
|
67
|
-
* Members of the same SCC share a priority, so insertion order wins the
|
|
68
|
-
* tiebreak inside a cycle (this matters for cyclic schemas like
|
|
69
|
-
* `slideDecks ↔ layouts`, where one direction is the user's chosen
|
|
70
|
-
* "soft" edge — only the consumer's mutator sequence knows which one).
|
|
71
|
-
*
|
|
72
|
-
* This algorithm is iteration-order-independent: starting the DFS from
|
|
73
|
-
* any node yields the same SCC partitioning, and SCCs always come out
|
|
74
|
-
* in valid topological order. The previous DFS-with-memoization
|
|
75
|
-
* heuristic broke under cycles by treating the back-edge as depth 0,
|
|
76
|
-
* which made priorities depend on which node the walk happened to
|
|
77
|
-
* enter the cycle at.
|
|
78
|
-
*
|
|
79
|
-
* Schema authors can mark one side of a cycle with
|
|
80
|
-
* `belongsTo(target, fk, { defer: true })`. Those edges are excluded
|
|
81
|
-
* from the dependency graph entirely, which deterministically breaks
|
|
82
|
-
* the cycle and turns the SCC into a chain — the marked child gets a
|
|
83
|
-
* strictly higher priority than its parent instead of being tied with
|
|
84
|
-
* it. Pair with a Postgres `DEFERRABLE INITIALLY DEFERRED` constraint
|
|
85
|
-
* if you want the database side of the cycle to also relax. See
|
|
86
|
-
* {@link BelongsToOptions.defer}.
|
|
87
|
-
*
|
|
88
|
-
* The returned map is keyed by {@link ModelDef.typename} (falling back
|
|
89
|
-
* to the schema key), because that is what `Model.getModelName()`
|
|
90
|
-
* returns at transaction time — keying by schema key would silently
|
|
91
|
-
* miss the lookup and every model would fall through to
|
|
92
|
-
* `defaultCreatePriority`.
|
|
93
|
-
*
|
|
94
|
-
* Reference: Tarjan, R. (1972), "Depth-first search and linear graph
|
|
95
|
-
* algorithms." Linear in V + E.
|
|
96
|
-
*/
|
|
97
|
-
export function computeFKDepthPriority(schema) {
|
|
98
|
-
// schemaKey → typename (wire name used at transaction time)
|
|
99
|
-
const keyToTypename = new Map();
|
|
100
|
-
for (const [key, def] of Object.entries(schema.models)) {
|
|
101
|
-
keyToTypename.set(key, def.typename ?? key);
|
|
102
|
-
}
|
|
103
|
-
// Adjacency: schemaKey → parent schema keys pulled from `belongsTo`.
|
|
104
|
-
// Parents not in the schema (e.g. external types) are dropped so the
|
|
105
|
-
// graph stays closed. Edges marked `{ defer: true }` are also
|
|
106
|
-
// dropped — the schema author has declared this side of a cycle to
|
|
107
|
-
// be the "soft" one (insert with null FK, patch later), so the
|
|
108
|
-
// dependency-graph walker treats it as if the edge weren't there.
|
|
109
|
-
// That breaks the cycle deterministically and lets the other side
|
|
110
|
-
// become a strict topological predecessor.
|
|
111
|
-
const parentsOf = new Map();
|
|
112
|
-
for (const [key, def] of Object.entries(schema.models)) {
|
|
113
|
-
const out = [];
|
|
114
|
-
for (const rel of Object.values(def.relations)) {
|
|
115
|
-
if (rel.type !== 'belongsTo')
|
|
116
|
-
continue;
|
|
117
|
-
if (!keyToTypename.has(rel.target))
|
|
118
|
-
continue;
|
|
119
|
-
if (rel.options?.defer === true)
|
|
120
|
-
continue;
|
|
121
|
-
out.push(rel.target);
|
|
122
|
-
}
|
|
123
|
-
parentsOf.set(key, out);
|
|
124
|
-
}
|
|
125
|
-
// Tarjan SCC bookkeeping
|
|
126
|
-
const dfsIndex = new Map();
|
|
127
|
-
const lowlink = new Map();
|
|
128
|
-
const onStack = new Set();
|
|
129
|
-
const stack = [];
|
|
130
|
-
const sccs = [];
|
|
131
|
-
let counter = 0;
|
|
132
|
-
function strongconnect(v) {
|
|
133
|
-
dfsIndex.set(v, counter);
|
|
134
|
-
lowlink.set(v, counter);
|
|
135
|
-
counter++;
|
|
136
|
-
stack.push(v);
|
|
137
|
-
onStack.add(v);
|
|
138
|
-
for (const w of parentsOf.get(v) ?? []) {
|
|
139
|
-
if (!dfsIndex.has(w)) {
|
|
140
|
-
strongconnect(w);
|
|
141
|
-
lowlink.set(v, Math.min(lowlink.get(v), lowlink.get(w)));
|
|
142
|
-
}
|
|
143
|
-
else if (onStack.has(w)) {
|
|
144
|
-
// Back-edge into the active DFS path — w is in the same SCC as v.
|
|
145
|
-
lowlink.set(v, Math.min(lowlink.get(v), dfsIndex.get(w)));
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
// v is the root of an SCC: pop everything down to v inclusive.
|
|
149
|
-
if (lowlink.get(v) === dfsIndex.get(v)) {
|
|
150
|
-
const component = [];
|
|
151
|
-
let w;
|
|
152
|
-
do {
|
|
153
|
-
w = stack.pop();
|
|
154
|
-
onStack.delete(w);
|
|
155
|
-
component.push(w);
|
|
156
|
-
} while (w !== v);
|
|
157
|
-
sccs.push(component);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
for (const key of keyToTypename.keys()) {
|
|
161
|
-
if (!dfsIndex.has(key))
|
|
162
|
-
strongconnect(key);
|
|
163
|
-
}
|
|
164
|
-
// Tarjan emits SCCs in reverse topological order of the condensation.
|
|
165
|
-
// In our edge convention (child→parent), reverse-topo of the
|
|
166
|
-
// condensation means root-SCCs (no outgoing edges = no parents)
|
|
167
|
-
// first, leaf-SCCs (deepest descendants) last. We could just use
|
|
168
|
-
// emit-order as the priority — but that gives independent sibling
|
|
169
|
-
// SCCs different priorities, which is semantically wrong: siblings
|
|
170
|
-
// don't depend on each other and shouldn't be ordered relative to
|
|
171
|
-
// each other.
|
|
172
|
-
//
|
|
173
|
-
// Instead, do one more pass to compute *longest-path depth* on the
|
|
174
|
-
// condensation DAG: depth(SCC) = max(depth(parent SCC)) + 1, or 0
|
|
175
|
-
// for SCCs with no in-schema parents. SCCs at the same depth get
|
|
176
|
-
// the same priority — siblings stay tied, insertion order in the
|
|
177
|
-
// queue breaks the tie. Priority = (depth + 1) * 10.
|
|
178
|
-
//
|
|
179
|
-
// We can compute this in a single pass over the SCCs because
|
|
180
|
-
// Tarjan's emit-order *is* a valid topological order of the
|
|
181
|
-
// condensation: when we process sccs[i], every parent SCC has
|
|
182
|
-
// already been assigned a depth.
|
|
183
|
-
const nodeToSccIdx = new Map();
|
|
184
|
-
sccs.forEach((scc, i) => {
|
|
185
|
-
for (const node of scc)
|
|
186
|
-
nodeToSccIdx.set(node, i);
|
|
187
|
-
});
|
|
188
|
-
const sccDepth = new Map();
|
|
189
|
-
sccs.forEach((scc, i) => {
|
|
190
|
-
let maxParentDepth = -1;
|
|
191
|
-
for (const node of scc) {
|
|
192
|
-
for (const parent of parentsOf.get(node) ?? []) {
|
|
193
|
-
const parentSccIdx = nodeToSccIdx.get(parent);
|
|
194
|
-
if (parentSccIdx === undefined)
|
|
195
|
-
continue;
|
|
196
|
-
if (parentSccIdx === i)
|
|
197
|
-
continue; // intra-SCC edge — not a dep
|
|
198
|
-
const d = sccDepth.get(parentSccIdx);
|
|
199
|
-
if (d !== undefined && d > maxParentDepth)
|
|
200
|
-
maxParentDepth = d;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
sccDepth.set(i, maxParentDepth + 1);
|
|
204
|
-
});
|
|
205
|
-
const out = new Map();
|
|
206
|
-
sccs.forEach((scc, i) => {
|
|
207
|
-
const priority = (sccDepth.get(i) + 1) * 10;
|
|
208
|
-
for (const key of scc) {
|
|
209
|
-
out.set(keyToTypename.get(key), priority);
|
|
210
|
-
}
|
|
211
|
-
});
|
|
212
|
-
return out;
|
|
213
|
-
}
|
|
214
|
-
function deriveConfigFromSchema(schema) {
|
|
215
|
-
// Commit payload projection is done directly inside `TransactionQueue`
|
|
216
|
-
// — see `projectCommitPayload` there. Each model's field metadata
|
|
217
|
-
// rides on `ModelRegistry` (populated by `registerModelsFromSchema`),
|
|
218
|
-
// so there's no config-layer shim: the queue asks the registry for
|
|
219
|
-
// the declared fields and serializes accordingly.
|
|
220
|
-
return {
|
|
221
|
-
modelCreatePriority: computeFKDepthPriority(schema),
|
|
222
|
-
defaultCreatePriority: 40,
|
|
223
|
-
defaultNonCreatePriority: 50,
|
|
224
|
-
essentialFields: {},
|
|
225
|
-
classNameFallbackMap: {},
|
|
226
|
-
// Hash this client's schema once so bootstrap can detect drift against the
|
|
227
|
-
// server's active hash (same `schemaHash` the CLI push + server compute).
|
|
228
|
-
expectedSchemaHash: schemaHash(schema),
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
// ── Auto model registration from schema ───────────────────────────────────
|
|
232
|
-
function registerModelsFromSchema(schema, registry) {
|
|
233
|
-
registry.startBatch();
|
|
234
|
-
for (const [schemaKey, modelDef] of Object.entries(schema.models)) {
|
|
235
|
-
// Use typename as the model name — this is the wire-format name that
|
|
236
|
-
// the server sends in bootstrap responses and sync deltas. The pool's
|
|
237
|
-
// typeIndex, the ModelRegistry, and getModelName() all use this name.
|
|
238
|
-
// Schema key (camelCase plural) is only for the consumer-facing proxy API.
|
|
239
|
-
const modelName = modelDef.typename ?? schemaKey;
|
|
240
|
-
// Collect JSON sub-property fields to generate ${field}Json getters
|
|
241
|
-
const jsonSubFields = [];
|
|
242
|
-
for (const [fieldName, zodType] of Object.entries(modelDef.shape)) {
|
|
243
|
-
const inner = unwrapZodType(zodType);
|
|
244
|
-
if (isZodObject(inner)) {
|
|
245
|
-
jsonSubFields.push({ fieldName, subSchema: inner });
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
// Create a dynamic Model subclass with JSON sub-property getters.
|
|
249
|
-
//
|
|
250
|
-
// Field-level MobX observability is ON BY DEFAULT. A reactive read like
|
|
251
|
-
// `useAblo((a) => a.documents.get(id))` must re-render when a remote delta
|
|
252
|
-
// mutates the row IN PLACE (the common collaborative case); without
|
|
253
|
-
// per-field observability that update fires no reaction and the UI silently
|
|
254
|
-
// goes stale. Models opt OUT with an explicit `lazyObservable: false` —
|
|
255
|
-
// appropriate only for very large read-only list models where per-field
|
|
256
|
-
// atoms cost more than the QueryView's entry-replaced reactivity already
|
|
257
|
-
// provides. json fields register as `observable.ref` (see
|
|
258
|
-
// `registerModelsFromSchema`), so the default is ~one atom per scalar field
|
|
259
|
-
// per loaded row — cheap — not a deep atom tree per blob.
|
|
260
|
-
const isLazy = modelDef.lazyObservable !== false;
|
|
261
|
-
// Base provenance fields (`organizationId`, `createdBy`) live in
|
|
262
|
-
// `baseFieldsSchema`, not the per-model `shape`. The server stamps + emits
|
|
263
|
-
// them (camelCased on the wire), but hydration (`Model.assignFieldsFromData`)
|
|
264
|
-
// only assigns keys that already exist as an own/prototype property — so
|
|
265
|
-
// without a slot here, `deck.createdBy` / `deck.organizationId` silently read
|
|
266
|
-
// `undefined` (this is why the profile decks tab showed nothing: it filters
|
|
267
|
-
// `decks.filter(d => d.createdBy === userId)`). `id`/`createdAt`/`updatedAt`
|
|
268
|
-
// are already seeded by the base Model constructor, so they're excluded.
|
|
269
|
-
const fieldNames = [
|
|
270
|
-
...Object.keys(modelDef.shape),
|
|
271
|
-
...Object.keys(baseFieldsSchema.shape).filter((f) => f !== 'id' && f !== 'createdAt' && f !== 'updatedAt' && !(f in modelDef.shape)),
|
|
272
|
-
];
|
|
273
|
-
const computed = modelDef.computed;
|
|
274
|
-
const DynamicModel = createDynamicModelClass(modelName, jsonSubFields, fieldNames, computed, isLazy);
|
|
275
|
-
// Respect the schema's load strategy so lazy models skip IDB hydration + bootstrap
|
|
276
|
-
const loadStrategy = modelDef.load === 'lazy' || modelDef.load === 'manual'
|
|
277
|
-
? LoadStrategy.lazy
|
|
278
|
-
: LoadStrategy.instant;
|
|
279
|
-
registry.registerModel(modelName, DynamicModel, {
|
|
280
|
-
loadStrategy,
|
|
281
|
-
fields: modelDef.fields,
|
|
282
|
-
autoFill: modelDef.autoFill,
|
|
283
|
-
requiredFields: modelDef.requiredFields,
|
|
284
|
-
});
|
|
285
|
-
// Collect the set of fields that should get an IDB secondary index.
|
|
286
|
-
//
|
|
287
|
-
// Matches Linear's opt-in model (see wzhudev/reverse-linear-sync-engine):
|
|
288
|
-
// `@Reference(..., { indexed: true })`. Only `belongsTo` relations that
|
|
289
|
-
// explicitly set `{ index: true }` in their options get an IDB secondary
|
|
290
|
-
// index. Every other FK (and every scalar) is resolved via in-memory
|
|
291
|
-
// ObjectPool scans, which are fast enough at org-scope sizes (~10k rows)
|
|
292
|
-
// and reactive via MobX.
|
|
293
|
-
//
|
|
294
|
-
// Auto-indexing every belongsTo was wrong: it bloated write amplification
|
|
295
|
-
// for the vast majority of FKs that are never queried by fk. Indexing
|
|
296
|
-
// every scalar (like the legacy Go backend did) is even worse.
|
|
297
|
-
const indexedFields = new Set();
|
|
298
|
-
for (const relDef of Object.values(modelDef.relations)) {
|
|
299
|
-
if (relDef.type === 'belongsTo' && relDef.foreignKey && relDef.options?.index === true) {
|
|
300
|
-
indexedFields.add(relDef.foreignKey);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
// Register fields as properties (from Zod shape).
|
|
304
|
-
for (const [fieldName, rawZodType] of Object.entries(modelDef.shape)) {
|
|
305
|
-
const zodType = rawZodType;
|
|
306
|
-
const isOptional = zodType.isOptional?.() ?? false;
|
|
307
|
-
// A field is indexed if it's the FK of a `belongsTo({ index: true })`
|
|
308
|
-
// relation. Legacy `description === 'indexed'` still works for
|
|
309
|
-
// consumers using `field.*().indexed()`.
|
|
310
|
-
const isIndexed = indexedFields.has(fieldName) || zodType.description === 'indexed';
|
|
311
|
-
// JSON-typed fields (per the schema's wire-type tag) are opaque
|
|
312
|
-
// blobs from MobX's perspective — chart specs, ProseMirror docs,
|
|
313
|
-
// style maps. Deep observability on them recursively walks every
|
|
314
|
-
// nested property and creates an atom for each leaf, producing a
|
|
315
|
-
// microtask storm on every commit/streaming update. `ref` tracks
|
|
316
|
-
// only reassignment, which is how blob consumers actually use them.
|
|
317
|
-
const wireType = modelDef.fields?.[fieldName]?.type;
|
|
318
|
-
const observability = wireType === 'json' ? 'ref' : undefined;
|
|
319
|
-
registry.registerProperty(modelName, fieldName, {
|
|
320
|
-
type: PropertyType.property,
|
|
321
|
-
indexed: isIndexed,
|
|
322
|
-
optional: isOptional,
|
|
323
|
-
observability,
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
// Register relations
|
|
327
|
-
for (const [relName, relDef] of Object.entries(modelDef.relations)) {
|
|
328
|
-
if (relDef.type === 'belongsTo') {
|
|
329
|
-
registry.registerReference(modelName, relName, {
|
|
330
|
-
referencedModel: () => {
|
|
331
|
-
const targetModel = registry.getModelByName(relDef.target);
|
|
332
|
-
return targetModel ?? DynamicModel;
|
|
333
|
-
},
|
|
334
|
-
indexed: true,
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
else if (relDef.type === 'hasMany') {
|
|
338
|
-
// Generate a getter on the parent model that returns all children
|
|
339
|
-
// matching the FK via Model.getStore().getByForeignKey(). The FK
|
|
340
|
-
// index on the target model is registered by deriveSyncPlanFromSchema.
|
|
341
|
-
const targetName = relDef.target;
|
|
342
|
-
const foreignKey = relDef.foreignKey;
|
|
343
|
-
const orderByField = relDef._orderBy;
|
|
344
|
-
// Resolve the target typename from the schema (might differ from the key)
|
|
345
|
-
const targetDef = schema.models[targetName];
|
|
346
|
-
const targetTypename = targetDef?.typename ?? targetName;
|
|
347
|
-
Object.defineProperty(DynamicModel.prototype, relName, {
|
|
348
|
-
get() {
|
|
349
|
-
const store = Model.getStore();
|
|
350
|
-
if (!store)
|
|
351
|
-
return [];
|
|
352
|
-
const results = store.getByForeignKey(targetTypename, foreignKey, this.id);
|
|
353
|
-
if (orderByField && results.length > 1) {
|
|
354
|
-
return [...results].sort((a, b) => {
|
|
355
|
-
// `orderByField` is a runtime string from the schema's
|
|
356
|
-
// hasMany({ orderBy }) — Models have dynamic typed
|
|
357
|
-
// fields produced by createDynamicModelClass, so the
|
|
358
|
-
// static type doesn't carry an index signature for
|
|
359
|
-
// arbitrary field reads. `Reflect.get` is the typed
|
|
360
|
-
// bridge — returns `unknown`, narrowed below.
|
|
361
|
-
const va = Reflect.get(a, orderByField);
|
|
362
|
-
const vb = Reflect.get(b, orderByField);
|
|
363
|
-
if (typeof va === 'number' && typeof vb === 'number')
|
|
364
|
-
return va - vb;
|
|
365
|
-
if (typeof va === 'string' && typeof vb === 'string')
|
|
366
|
-
return va.localeCompare(vb);
|
|
367
|
-
return 0;
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
return results;
|
|
371
|
-
},
|
|
372
|
-
enumerable: true,
|
|
373
|
-
configurable: true,
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
registry.endBatch();
|
|
379
|
-
}
|
|
380
|
-
// ── JSON sub-property helpers ─────────────────────────────────────────────
|
|
381
|
-
/**
|
|
382
|
-
* Unwrap a Zod schema through .optional(), .nullable(), .default(),
|
|
383
|
-
* .readonly() to find the innermost type. Needed to detect whether a
|
|
384
|
-
* field.json() call wraps a ZodObject (has sub-properties) or a plain
|
|
385
|
-
* type (ZodUnknown, ZodArray, etc.).
|
|
386
|
-
*
|
|
387
|
-
* Uses Zod's public `.unwrap()` API per wrapper type — no `_def`
|
|
388
|
-
* digging. Bounded loop guards against pathological self-referential
|
|
389
|
-
* wrappers.
|
|
390
|
-
*/
|
|
391
|
-
function unwrapZodType(schema) {
|
|
392
|
-
let current = schema;
|
|
393
|
-
for (let i = 0; i < 10; i++) {
|
|
394
|
-
if (current instanceof z.ZodOptional) {
|
|
395
|
-
current = current.unwrap();
|
|
396
|
-
continue;
|
|
397
|
-
}
|
|
398
|
-
if (current instanceof z.ZodNullable) {
|
|
399
|
-
current = current.unwrap();
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
402
|
-
if (current instanceof z.ZodDefault) {
|
|
403
|
-
// v4 deprecates removeDefault in favor of unwrap, but the
|
|
404
|
-
// installed @types declarations only expose removeDefault on
|
|
405
|
-
// ZodDefault. Use it — it's the same runtime function.
|
|
406
|
-
current = current.unwrap();
|
|
407
|
-
continue;
|
|
408
|
-
}
|
|
409
|
-
if (current instanceof z.ZodReadonly) {
|
|
410
|
-
current = current.unwrap();
|
|
411
|
-
continue;
|
|
412
|
-
}
|
|
413
|
-
break;
|
|
414
|
-
}
|
|
415
|
-
return current;
|
|
416
|
-
}
|
|
417
|
-
/** Type guard: is this a ZodObject with a .shape property? */
|
|
418
|
-
function isZodObject(schema) {
|
|
419
|
-
return schema instanceof z.ZodObject;
|
|
420
|
-
}
|
|
421
|
-
/** Create a Model subclass for a schema-defined model */
|
|
422
|
-
function createDynamicModelClass(modelName, jsonSubFields, fieldNames, computed, lazyObservable = false) {
|
|
423
|
-
const ModelClass = class extends Model {
|
|
424
|
-
_modelName = modelName;
|
|
425
|
-
constructor(data) {
|
|
426
|
-
super(data);
|
|
427
|
-
// Gate `propertyChanged`-via-`observe` tracking during initial
|
|
428
|
-
// hydration. M1 installs a MobX `observe()` listener per schema
|
|
429
|
-
// property that forwards writes to `propertyChanged()` so direct
|
|
430
|
-
// assignments like `layer.position = newPos` still round-trip
|
|
431
|
-
// through the transaction queue. During construction we're writing
|
|
432
|
-
// wire data, NOT user edits — flagging this as "constructing" lets
|
|
433
|
-
// the listener early-return on those writes so `modifiedProperties`
|
|
434
|
-
// doesn't get polluted with every field of every hydrated model.
|
|
435
|
-
//
|
|
436
|
-
// The listener is installed by `makeObservable()` below (inside
|
|
437
|
-
// M1), so writes that happen BEFORE that line won't fire it; this
|
|
438
|
-
// flag is defensive in case a subclass or call path reorders the
|
|
439
|
-
// steps later.
|
|
440
|
-
this._isConstructing = true;
|
|
441
|
-
// MobX 6 requires fields to exist as own properties BEFORE makeObservable().
|
|
442
|
-
// Model base only sets id/createdAt/updatedAt. Schema fields (title, userId, etc.)
|
|
443
|
-
// must be initialized here so M1's annotations can find them.
|
|
444
|
-
for (const field of fieldNames) {
|
|
445
|
-
if (!(field in this)) {
|
|
446
|
-
this[field] = data?.[field] ?? undefined;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
// Per-field MobX observability opt-in via `lazyObservable: true` on
|
|
450
|
-
// the model definition. Defaults to plain objects — reactivity comes
|
|
451
|
-
// from the QueryView "entry replaced" pattern, which is cheap for
|
|
452
|
-
// read-only list UIs but invisible to in-place field mutations.
|
|
453
|
-
//
|
|
454
|
-
// Multiplayer editors need live field-level reactivity so remote
|
|
455
|
-
// deltas AND local drag/resize/rename mutations surface through
|
|
456
|
-
// `observer()` components without the whole pool entry being
|
|
457
|
-
// replaced. Without observability, `layer.position.x = 500` emits
|
|
458
|
-
// nothing and the UI lags until some unrelated state change triggers
|
|
459
|
-
// a pass (toolbar close, deselect).
|
|
460
|
-
//
|
|
461
|
-
// Delegates to `Model.makeObservable()` (the inherited method) so
|
|
462
|
-
// MobX annotations are derived from the same registry that M1 reads.
|
|
463
|
-
// That means computed getters, reference collections, custom
|
|
464
|
-
// getters/setters, and property-change tracking all integrate
|
|
465
|
-
// correctly — reimplementing `makeObservable` inline here would miss
|
|
466
|
-
// those seams.
|
|
467
|
-
if (lazyObservable) {
|
|
468
|
-
this.makeObservable();
|
|
469
|
-
}
|
|
470
|
-
this._isConstructing = false;
|
|
471
|
-
}
|
|
472
|
-
getModelName() {
|
|
473
|
-
return this._modelName;
|
|
474
|
-
}
|
|
475
|
-
};
|
|
476
|
-
// Generate ${field}Json getters for JSON fields with sub-properties.
|
|
477
|
-
//
|
|
478
|
-
// The getter reads the raw JSON string from the instance (set via
|
|
479
|
-
// updateFromData), parses it, applies Zod defaults, and caches by
|
|
480
|
-
// raw value. This replaces the hand-coded metadataObject + sub-property
|
|
481
|
-
// getter pattern that 11+ Ablo models currently repeat.
|
|
482
|
-
//
|
|
483
|
-
// Example: field named 'metadata' with sub-schema { icon: z.string().default('presentation') }
|
|
484
|
-
// → model.metadataJson returns { icon: 'presentation', ... } (typed, cached)
|
|
485
|
-
for (const { fieldName, subSchema } of jsonSubFields) {
|
|
486
|
-
const getterName = `${fieldName}Json`;
|
|
487
|
-
const cacheKey = `__${fieldName}JsonCache`;
|
|
488
|
-
Object.defineProperty(ModelClass.prototype, getterName, {
|
|
489
|
-
get() {
|
|
490
|
-
const raw = this[fieldName];
|
|
491
|
-
// Cache check: same raw value → same parsed result
|
|
492
|
-
const cache = this[cacheKey];
|
|
493
|
-
if (cache && cache.raw === raw)
|
|
494
|
-
return cache.parsed;
|
|
495
|
-
// Parse: handle string (from DB/wire), object (already parsed), null/undefined
|
|
496
|
-
let input;
|
|
497
|
-
try {
|
|
498
|
-
if (typeof raw === 'string') {
|
|
499
|
-
input = JSON.parse(raw);
|
|
500
|
-
}
|
|
501
|
-
else if (raw && typeof raw === 'object') {
|
|
502
|
-
input = raw;
|
|
503
|
-
}
|
|
504
|
-
else {
|
|
505
|
-
input = {};
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
catch {
|
|
509
|
-
input = {};
|
|
510
|
-
}
|
|
511
|
-
// Apply Zod parse for type coercion + defaults. safeParse so
|
|
512
|
-
// malformed metadata doesn't crash — falls back to all defaults.
|
|
513
|
-
const result = subSchema.safeParse(input);
|
|
514
|
-
const parsed = result.success ? result.data : subSchema.safeParse({}).data ?? {};
|
|
515
|
-
this[cacheKey] = { raw, parsed };
|
|
516
|
-
return parsed;
|
|
517
|
-
},
|
|
518
|
-
enumerable: true,
|
|
519
|
-
configurable: true,
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
|
-
// Install schema-declared computed getters on the prototype.
|
|
523
|
-
// Each getter receives `this` (the model instance) and returns the computed value.
|
|
524
|
-
if (computed) {
|
|
525
|
-
for (const [name, fn] of Object.entries(computed)) {
|
|
526
|
-
Object.defineProperty(ModelClass.prototype, name, {
|
|
527
|
-
get() {
|
|
528
|
-
return fn(this);
|
|
529
|
-
},
|
|
530
|
-
enumerable: true,
|
|
531
|
-
configurable: true,
|
|
532
|
-
});
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
return ModelClass;
|
|
536
|
-
}
|
|
537
|
-
const LOG_LEVEL_RANK = { debug: 10, info: 20, warn: 30, error: 40, silent: 99 };
|
|
538
|
-
/**
|
|
539
|
-
* Resolve the effective level. Precedence: explicit `logLevel` option →
|
|
540
|
-
* `debug: true` (⇒ debug) → `ABLO_LOG_LEVEL` env → default `warn`. `debug: false`
|
|
541
|
-
* / omitted just means "don't raise the level" — it falls through to env/default
|
|
542
|
-
* rather than force-silencing an ops-set env override.
|
|
543
|
-
*/
|
|
544
|
-
function resolveLogLevel(opts) {
|
|
545
|
-
if (opts?.logLevel && opts.logLevel in LOG_LEVEL_RANK)
|
|
546
|
-
return opts.logLevel;
|
|
547
|
-
if (opts?.debug === true)
|
|
548
|
-
return 'debug';
|
|
549
|
-
// `globalThis.process` guard keeps this safe in browser/edge runtimes that
|
|
550
|
-
// have no `process` binding — there we fall through to the default.
|
|
551
|
-
const raw = globalThis.process?.env?.ABLO_LOG_LEVEL;
|
|
552
|
-
const normalized = raw?.toLowerCase();
|
|
553
|
-
if (normalized && normalized in LOG_LEVEL_RANK)
|
|
554
|
-
return normalized;
|
|
555
|
-
return 'warn';
|
|
556
|
-
}
|
|
557
|
-
/**
|
|
558
|
-
* Build the default logger, gated at `level` and prefixed `[Ablo]` so a creator
|
|
559
|
-
* with a console full of other tools' logs can see at a glance what's ours.
|
|
560
|
-
*/
|
|
561
|
-
function createConsoleLogger(level) {
|
|
562
|
-
const threshold = LOG_LEVEL_RANK[level];
|
|
563
|
-
const emit = (lvl, fn, args) => {
|
|
564
|
-
if (typeof console === 'undefined' || LOG_LEVEL_RANK[lvl] < threshold)
|
|
565
|
-
return;
|
|
566
|
-
fn('[Ablo]', ...args);
|
|
567
|
-
};
|
|
568
|
-
return {
|
|
569
|
-
debug: (...args) => emit('debug', console.debug, args),
|
|
570
|
-
info: (...args) => emit('info', console.info, args),
|
|
571
|
-
warn: (...args) => emit('warn', console.warn, args),
|
|
572
|
-
error: (...args) => emit('error', console.error, args),
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
54
|
// `readProcessEnv` lives in `./auth` alongside the other resolvers
|
|
576
55
|
// that read it. Re-exported there for use elsewhere in the file.
|
|
577
|
-
// ── Default mutation executor (wire: `commit` frame over WebSocket) ──────
|
|
578
|
-
/**
|
|
579
|
-
* Derive a stable `Idempotency-Key` from the batch's operation set.
|
|
580
|
-
*
|
|
581
|
-
* Retries of the same batch compute the same key — a reconnecting
|
|
582
|
-
* client that rebuilds the identical mutations from its offline queue
|
|
583
|
-
* sends the identical key, so the server's `mutation_log` replay path
|
|
584
|
-
* returns the cached response instead of re-executing the mutators.
|
|
585
|
-
*
|
|
586
|
-
* Content-addressed: sort operations by (model, id, type) then sha256
|
|
587
|
-
* the serialized form. Separator-safe — adjacent fields are delimited
|
|
588
|
-
* by a character (`\x1e`, the ASCII record separator) that cannot
|
|
589
|
-
* appear in a JSON string literal. Output length is 70 chars — safely
|
|
590
|
-
* under Stripe's documented 255-char cap.
|
|
591
|
-
*
|
|
592
|
-
* Uses the Web Crypto API (cross-runtime: Node 20+ and browsers), same
|
|
593
|
-
* primitive as the offline queue's AES-GCM encryption.
|
|
594
|
-
*
|
|
595
|
-
* @internal — exported as unexported file-local; callers go through
|
|
596
|
-
* the executor's own `Idempotency-Key` plumbing.
|
|
597
|
-
*/
|
|
598
|
-
async function deriveOperationsIdempotencyKey(operations) {
|
|
599
|
-
const normalized = [...operations]
|
|
600
|
-
.map((op) => ({
|
|
601
|
-
type: op.type,
|
|
602
|
-
model: op.model,
|
|
603
|
-
id: op.id,
|
|
604
|
-
input: op.input ?? null,
|
|
605
|
-
}))
|
|
606
|
-
.sort((a, b) => {
|
|
607
|
-
if (a.model !== b.model)
|
|
608
|
-
return a.model < b.model ? -1 : 1;
|
|
609
|
-
if (a.id !== b.id)
|
|
610
|
-
return a.id < b.id ? -1 : 1;
|
|
611
|
-
return a.type < b.type ? -1 : a.type > b.type ? 1 : 0;
|
|
612
|
-
});
|
|
613
|
-
const encoded = new TextEncoder().encode(JSON.stringify(normalized));
|
|
614
|
-
const digest = await crypto.subtle.digest('SHA-256', encoded);
|
|
615
|
-
const bytes = new Uint8Array(digest);
|
|
616
|
-
let hex = '';
|
|
617
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
618
|
-
hex += bytes[i].toString(16).padStart(2, '0');
|
|
619
|
-
}
|
|
620
|
-
return `batch-${hex}`;
|
|
621
|
-
}
|
|
622
|
-
/**
|
|
623
|
-
* Default mutation executor: sends `{ type: 'commit', payload: ... }` over
|
|
624
|
-
* the sync engine's own WebSocket.
|
|
625
|
-
*
|
|
626
|
-
* Transport ownership follows the Zero / Liveblocks pattern — the engine
|
|
627
|
-
* owns its socket end-to-end and the executor is internal. Apps pass URLs
|
|
628
|
-
* and auth; they do NOT inject transport callbacks. That's why this
|
|
629
|
-
* factory takes a `getWs` closure instead of a full SyncWebSocket: the WS
|
|
630
|
-
* doesn't exist when the executor is constructed (it's created later in
|
|
631
|
-
* `Ablo` during `BaseSyncedStore` init), so we resolve it
|
|
632
|
-
* lazily at commit time. Same trick Zero uses internally — see
|
|
633
|
-
* `packages/zero-client/src/client/zero.ts` where `Pusher`/`Puller` are
|
|
634
|
-
* constructed before the socket then wired up at connect time.
|
|
635
|
-
*
|
|
636
|
-
* `options.idempotencyKey` becomes the wire-level `clientTxId` when set,
|
|
637
|
-
* matching Stripe-style retry semantics. Otherwise the SDK generates one.
|
|
638
|
-
*/
|
|
639
|
-
function createDefaultMutationExecutor(getWs) {
|
|
640
|
-
async function commit(operations, options) {
|
|
641
|
-
const ws = getWs();
|
|
642
|
-
if (!ws?.sendCommit) {
|
|
643
|
-
throw new AbloConnectionError('SyncWebSocket not ready for commit. The engine must finish bootstrap ' +
|
|
644
|
-
'before mutations can be sent.', { code: 'ws_not_ready' });
|
|
645
|
-
}
|
|
646
|
-
const clientTxId = options?.idempotencyKey ??
|
|
647
|
-
(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
648
|
-
? crypto.randomUUID()
|
|
649
|
-
: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`);
|
|
650
|
-
try {
|
|
651
|
-
return await ws.sendCommit(operations, clientTxId, undefined, // use sendCommit's built-in 15s default; no per-call override
|
|
652
|
-
options?.causedByTaskId, options?.reads);
|
|
653
|
-
}
|
|
654
|
-
catch (err) {
|
|
655
|
-
// Wrap transport-level failures as connection errors so the
|
|
656
|
-
// TransactionQueue's retry classifier treats them as transient
|
|
657
|
-
// (matches the old HTTP path's network-error handling).
|
|
658
|
-
if (err instanceof AbloError)
|
|
659
|
-
throw err;
|
|
660
|
-
if (err instanceof Error) {
|
|
661
|
-
if (/not connected|timed out|connection|ECONN/i.test(err.message)) {
|
|
662
|
-
const wrapped = new AbloConnectionError(err.message, { cause: err });
|
|
663
|
-
// Preserve any `diagnostics` snapshot the underlying SyncWebSocket
|
|
664
|
-
// attached to the rejection. Without this, the wrapped error
|
|
665
|
-
// bottoms out at "AbloConnectionError: not connected" with no
|
|
666
|
-
// attribution to which close code / heartbeat trip / session
|
|
667
|
-
// error caused it. See SyncWebSocket.notConnectedError().
|
|
668
|
-
if (err &&
|
|
669
|
-
typeof err === 'object' &&
|
|
670
|
-
'diagnostics' in err &&
|
|
671
|
-
err.diagnostics) {
|
|
672
|
-
wrapped.diagnostics = err.diagnostics;
|
|
673
|
-
}
|
|
674
|
-
throw wrapped;
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
throw err;
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
return {
|
|
681
|
-
commit,
|
|
682
|
-
executeCreate: (model, id, input, _txId, options) => commit([{ type: 'CREATE', model: model.toLowerCase(), id, input }], options).then(() => { }),
|
|
683
|
-
executeUpdate: (model, id, data, _txId, options) => commit([{ type: 'UPDATE', model: model.toLowerCase(), id, input: data }], options),
|
|
684
|
-
executeDelete: (model, id, _txId, options) => commit([{ type: 'DELETE', model: model.toLowerCase(), id }], options).then(() => { }),
|
|
685
|
-
executeArchive: (model, id, _txId, options) => commit([{ type: 'ARCHIVE', model: model.toLowerCase(), id }], options).then(() => { }),
|
|
686
|
-
executeUnarchive: (model, id, _txId, options) => commit([{ type: 'UNARCHIVE', model: model.toLowerCase(), id }], options).then(() => { }),
|
|
687
|
-
};
|
|
688
|
-
}
|
|
689
|
-
// ── Default mutation dispatcher (for offline flush) ───────────────────────
|
|
690
|
-
function createDefaultMutationDispatcher(executor) {
|
|
691
|
-
return {
|
|
692
|
-
async dispatch(opName, variables) {
|
|
693
|
-
const prefixes = ['Create', 'Update', 'Delete', 'Archive', 'Unarchive'];
|
|
694
|
-
for (const prefix of prefixes) {
|
|
695
|
-
if (opName.startsWith(prefix)) {
|
|
696
|
-
const model = opName.slice(prefix.length);
|
|
697
|
-
const v = variables;
|
|
698
|
-
const input = (prefix === 'Create' || prefix === 'Update')
|
|
699
|
-
? v.input
|
|
700
|
-
: undefined;
|
|
701
|
-
await executor.commit([{
|
|
702
|
-
type: prefix.toUpperCase(),
|
|
703
|
-
model: model.toLowerCase(),
|
|
704
|
-
id: v.id ?? '',
|
|
705
|
-
input,
|
|
706
|
-
}]);
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
},
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
56
|
// ── Auth normalization ─────────────────────────────────────────────────────
|
|
714
57
|
/**
|
|
715
58
|
* The one resolver the credential lifecycle needs: an async `() => token | null`,
|
|
@@ -748,7 +91,9 @@ export function Ablo(options) {
|
|
|
748
91
|
// refresh timer + wake/online/focus triggers. Null for the common static
|
|
749
92
|
// `apiKey` path — no refresh needed.
|
|
750
93
|
const credentialResolver = resolveCredentialResolver(configuredApiKey);
|
|
751
|
-
const authCredentials = createAuthCredentialSource(
|
|
94
|
+
const authCredentials = createAuthCredentialSource(
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- load-bearing on the self-hosted path; server-internal cap-mint (Phase 3) not shipped
|
|
96
|
+
internalOptions.capabilityToken ?? configuredAuthToken);
|
|
752
97
|
const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
|
|
753
98
|
assertBrowserSafety({
|
|
754
99
|
apiKey: configuredApiKey,
|
|
@@ -761,9 +106,9 @@ export function Ablo(options) {
|
|
|
761
106
|
createConsoleLogger(resolveLogLevel({ debug: options.debug, logLevel: options.logLevel }));
|
|
762
107
|
// Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
|
|
763
108
|
// passed — the env value is no longer auto-adopted (see resolveDatabaseUrl).
|
|
764
|
-
warnIfDatabaseUrlEnvIgnored(authInput, (m) => logger.warn(m));
|
|
765
|
-
warnIfDatabaseUrlDeprecated(authInput, (m) => logger.warn(m));
|
|
766
|
-
void warnIfCliKeyMismatch(authInput, (m) => logger.warn(m));
|
|
109
|
+
warnIfDatabaseUrlEnvIgnored(authInput, (m) => { logger.warn(m); });
|
|
110
|
+
warnIfDatabaseUrlDeprecated(authInput, (m) => { logger.warn(m); });
|
|
111
|
+
void warnIfCliKeyMismatch(authInput, (m) => { logger.warn(m); });
|
|
767
112
|
const schema = options.schema;
|
|
768
113
|
const url = resolveBaseURL(authInput);
|
|
769
114
|
// 1. Derive config from schema
|
|
@@ -802,7 +147,6 @@ export function Ablo(options) {
|
|
|
802
147
|
const ws = storeHolder.store?.getSyncWebSocket() ?? null;
|
|
803
148
|
return ws;
|
|
804
149
|
});
|
|
805
|
-
const dispatcher = internalOptions.mutationDispatcher ?? createDefaultMutationDispatcher(executor);
|
|
806
150
|
// 3. Initialize SDK context (one call — hides all DI wiring).
|
|
807
151
|
// Each provider can be overridden individually; the noop defaults
|
|
808
152
|
// are preserved for the zero-config consumer path.
|
|
@@ -817,15 +161,13 @@ export function Ablo(options) {
|
|
|
817
161
|
: browserOnlineStatus),
|
|
818
162
|
config,
|
|
819
163
|
mutationExecutor: executor,
|
|
820
|
-
mutationDispatcher: dispatcher,
|
|
821
164
|
});
|
|
822
165
|
// 4. Create internal components (user never sees these). See
|
|
823
166
|
// `./createInternalComponents.ts` for the construction order
|
|
824
167
|
// and what each component does. Model registration happens
|
|
825
|
-
// here
|
|
826
|
-
// the schema-to-Model-class translation
|
|
827
|
-
//
|
|
828
|
-
// that aren't worth pulling into the components module.
|
|
168
|
+
// here (via `registerModelsFromSchema`, in `./modelRegistration.ts`)
|
|
169
|
+
// because the schema-to-Model-class translation is client-construction
|
|
170
|
+
// wiring that isn't worth pulling into the components module.
|
|
829
171
|
const { modelRegistry, objectPool, bootstrapHelper, database, syncClient, hydration, } = createInternalComponents({
|
|
830
172
|
schema,
|
|
831
173
|
url,
|
|
@@ -855,9 +197,34 @@ export function Ablo(options) {
|
|
|
855
197
|
// timer + wake/online/focus re-mint). Installed once here so refresh works for
|
|
856
198
|
// ANY consumer of `Ablo({ auth })` — not only those who render `<AbloProvider>`.
|
|
857
199
|
// The first mint happens in `ready()` so the first connection carries a token.
|
|
200
|
+
//
|
|
201
|
+
// Long-lived server clients also get the pre-roll TIMER on windowless hosts
|
|
202
|
+
// (`proactiveInNode`): their socket must renew its `rk_`/`ek_` BEFORE the
|
|
203
|
+
// hub's keepalive reaper closes it (4001 `credential_expired`). Two signals
|
|
204
|
+
// qualify — agent/system participants (the axis `createConnectionManager`
|
|
205
|
+
// gates on; `kind` is deprecated but still what agent runtimes pass today),
|
|
206
|
+
// and an ABSOLUTE endpoint-string `apiKey` (a relative one can't fetch in
|
|
207
|
+
// Node at all, so an absolute URL is unambiguously a deliberate server
|
|
208
|
+
// client, kind or no kind). User-kind clients in Node (an SSR/RSC module
|
|
209
|
+
// eval of scaffolded browser code) stay reactive-only.
|
|
858
210
|
if (credentialResolver) {
|
|
859
|
-
|
|
211
|
+
const rawEndpoint = internalOptions.authEndpoint ?? internalOptions.apiKey;
|
|
212
|
+
const absoluteEndpoint = typeof rawEndpoint === 'string' && /^https?:\/\//i.test(rawEndpoint);
|
|
213
|
+
store.startCredentialLifecycle(credentialResolver, {
|
|
214
|
+
/* eslint-disable @typescript-eslint/no-deprecated -- `kind` gates the self-hosted proactive pre-roll; hosted path derives it from the apiKey scope */
|
|
215
|
+
proactiveInNode: internalOptions.kind === 'agent' ||
|
|
216
|
+
internalOptions.kind === 'system' ||
|
|
217
|
+
absoluteEndpoint,
|
|
218
|
+
/* eslint-enable @typescript-eslint/no-deprecated */
|
|
219
|
+
});
|
|
860
220
|
}
|
|
221
|
+
// Put the lazy-query lane on the SAME auth-recovery backbone as the WS probe
|
|
222
|
+
// and the proactive pre-roll: a 401 on `/sync/query` re-mints via the store's
|
|
223
|
+
// single-flight lifecycle and replays once, instead of silently returning
|
|
224
|
+
// empty rows against an expired `ek_` until the next proactive tick (the
|
|
225
|
+
// "Could not load documents — apikey_expired" wedge). Late-bound because the
|
|
226
|
+
// coordinator is constructed before the store exists.
|
|
227
|
+
hydration.setCredentialRecovery((recovery) => store.recoverFromAuthRejection(recovery));
|
|
861
228
|
// Wire the store back into the default executor's lazy getter (see
|
|
862
229
|
// `storeHolder` above). The executor was constructed before the store
|
|
863
230
|
// existed; this late binding closes the loop so commits dispatch over
|
|
@@ -883,10 +250,13 @@ export function Ablo(options) {
|
|
|
883
250
|
// The server stamps `isAgent` on outbound presence frames from the
|
|
884
251
|
// connection's authenticated identity prefix, but the local `self`
|
|
885
252
|
// entry uses the kind we know at construction.
|
|
886
|
-
const participantId =
|
|
253
|
+
const participantId =
|
|
254
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- self-hosted identity fallback; hosted path derives identity from the apiKey scope
|
|
255
|
+
(internalOptions.kind === 'agent' ? internalOptions.agentId : internalOptions.user?.id) ?? '';
|
|
887
256
|
const presenceStream = createPresenceStream({
|
|
888
257
|
participantId,
|
|
889
258
|
syncGroups: internalOptions.syncGroups ?? [],
|
|
259
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- local self-entry kind; server re-stamps isAgent from the authenticated identity
|
|
890
260
|
isAgent: internalOptions.kind === 'agent',
|
|
891
261
|
});
|
|
892
262
|
const claimStream = createClaimStream({ participantId });
|
|
@@ -900,6 +270,7 @@ export function Ablo(options) {
|
|
|
900
270
|
// 6. Validate options up front — fail loudly on obviously wrong inputs so
|
|
901
271
|
// strangers don't get silent empty results. Validation errors are written
|
|
902
272
|
// into `store.syncStatus` (the single source of truth).
|
|
273
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- self-hosted default; hosted path ignores it (server derives kind from the apiKey scope)
|
|
903
274
|
const kind = internalOptions.kind ?? 'user';
|
|
904
275
|
const _validationError = validateAbloOptions({
|
|
905
276
|
options: internalOptions,
|
|
@@ -918,6 +289,7 @@ export function Ablo(options) {
|
|
|
918
289
|
// trusting them is the trap (you think you're an agent; the key says user).
|
|
919
290
|
// Warn loudly rather than removing the fields — `agentId` is still load-bearing
|
|
920
291
|
// on the self-hosted path (no apiKey; paired with `capabilityToken`).
|
|
292
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- reads the deprecated fields precisely to warn callers off them under a configured apiKey
|
|
921
293
|
if (configuredApiKey && (internalOptions.kind || internalOptions.agentId)) {
|
|
922
294
|
logger.warn('Ablo: `kind` / `agentId` are ignored when an `apiKey` is configured — ' +
|
|
923
295
|
'the server derives participant identity from the key’s scope. Remove ' +
|
|
@@ -1274,10 +646,12 @@ export function Ablo(options) {
|
|
|
1274
646
|
}
|
|
1275
647
|
};
|
|
1276
648
|
const onAbort = () => {
|
|
1277
|
-
finish(() =>
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
649
|
+
finish(() => {
|
|
650
|
+
reject(new AbloConnectionError('Claim wait aborted.', {
|
|
651
|
+
code: 'claim_wait_aborted',
|
|
652
|
+
cause: options?.signal?.reason,
|
|
653
|
+
}));
|
|
654
|
+
});
|
|
1281
655
|
};
|
|
1282
656
|
if (options?.signal?.aborted) {
|
|
1283
657
|
onAbort();
|
|
@@ -1287,7 +661,9 @@ export function Ablo(options) {
|
|
|
1287
661
|
options?.signal?.addEventListener('abort', onAbort, { once: true });
|
|
1288
662
|
if (options?.timeout != null) {
|
|
1289
663
|
timeoutId = setTimeout(() => {
|
|
1290
|
-
finish(() =>
|
|
664
|
+
finish(() => {
|
|
665
|
+
reject(claimedError(target, listModelClaims(target), 'model_claimed_timeout'));
|
|
666
|
+
});
|
|
1291
667
|
}, options.timeout);
|
|
1292
668
|
}
|
|
1293
669
|
});
|
|
@@ -1387,7 +763,7 @@ export function Ablo(options) {
|
|
|
1387
763
|
entities: { [modelKey]: id },
|
|
1388
764
|
}),
|
|
1389
765
|
queue: (target) => publicClaims.queueFor({ type: target.model, id: target.id }),
|
|
1390
|
-
reorder: (target, order) => publicClaims.reorder({ type: target.model, id: target.id }, order),
|
|
766
|
+
reorder: (target, order) => { publicClaims.reorder({ type: target.model, id: target.id }, order); },
|
|
1391
767
|
state: (target) => {
|
|
1392
768
|
// The live claim stream only tracks *open* (active) claims;
|
|
1393
769
|
// terminal states (committed / expired / canceled) drop out of
|
|
@@ -1665,7 +1041,7 @@ export function Ablo(options) {
|
|
|
1665
1041
|
// the server then denies. See `MintSessionContext`.
|
|
1666
1042
|
modelTypenames: Object.fromEntries(Object.entries(schema.models).map(([key, def]) => [
|
|
1667
1043
|
key,
|
|
1668
|
-
def.typename ?? key,
|
|
1044
|
+
(def).typename ?? key,
|
|
1669
1045
|
])),
|
|
1670
1046
|
};
|
|
1671
1047
|
}
|