@abloatai/humans 0.55.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/local/Database.js +2 -2
  2. package/dist/local/Model.js +1 -1
  3. package/dist/local/SyncClient.d.ts +3 -34
  4. package/dist/local/SyncClient.js +1 -16
  5. package/dist/local/client/createModelProxy.d.ts +13 -1
  6. package/dist/local/client/createModelProxy.js +159 -80
  7. package/dist/local/client/options.d.ts +7 -4
  8. package/dist/local/client/reactiveEngine.js +5 -1
  9. package/dist/local/client/wsMutationExecutor.js +1 -0
  10. package/dist/local/logPosition.d.ts +13 -2
  11. package/dist/local/logPosition.js +17 -5
  12. package/dist/local/sync/SyncWebSocket.js +16 -0
  13. package/dist/local/syncClientTypes.d.ts +41 -0
  14. package/dist/local/syncClientTypes.js +11 -0
  15. package/dist/local/transactions/mutations/MutationQueue.d.ts +2 -0
  16. package/dist/local/transactions/mutations/MutationQueue.js +54 -69
  17. package/dist/local/transactions/mutations/commitLane.d.ts +4 -1
  18. package/dist/local/transactions/mutations/commitLane.js +12 -3
  19. package/dist/local/transactions/mutations/mutationInput.d.ts +40 -0
  20. package/dist/local/transactions/mutations/mutationInput.js +53 -0
  21. package/dist/surface.d.ts +1 -1
  22. package/dist/surface.js +1 -0
  23. package/package.json +2 -2
  24. package/src/local/Database.ts +2 -3
  25. package/src/local/Model.ts +1 -1
  26. package/src/local/SyncClient.ts +12 -72
  27. package/src/local/client/createModelProxy.ts +164 -13
  28. package/src/local/client/options.ts +7 -4
  29. package/src/local/client/reactiveEngine.ts +5 -1
  30. package/src/local/client/wsMutationExecutor.ts +1 -0
  31. package/src/local/logPosition.ts +19 -6
  32. package/src/local/sync/SyncWebSocket.ts +15 -0
  33. package/src/local/syncClientTypes.ts +59 -0
  34. package/src/local/transactions/mutations/MutationQueue.ts +61 -88
  35. package/src/local/transactions/mutations/commitLane.ts +23 -5
  36. package/src/local/transactions/mutations/mutationInput.ts +69 -0
  37. package/src/surface.ts +1 -0
@@ -13,7 +13,7 @@ import { globalRuntime } from './context.js';
13
13
  import { AbloConnectionError, AbloValidationError } from '@abloatai/transaction/errors';
14
14
  import { persistenceDatabaseNamesForDeletion, purgeIndexedDbPersistence, } from './stores/persistenceCleanup.js';
15
15
  import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
16
- import { logPositionSchema } from './logPosition.js';
16
+ import { logPositionSnapshotSchema } from './logPosition.js';
17
17
  import { highestPersistedPrefixSyncId } from './sync/persistedPrefix.js';
18
18
  import { isAcceptedOutboxPromotion, isSameOutboxRecord, } from './transactions/persistedTransaction.js';
19
19
  /**
@@ -320,7 +320,7 @@ export class Database {
320
320
  // (a corrupted negative/float cursor would previously pass `|| 0`,
321
321
  // which only catches falsy, and get sent to the server as the resume
322
322
  // point). Invalid → 0 → full bootstrap, the safe degradation.
323
- const metadataLastSyncId = logPositionSchema.shape.persisted.safeParse(metadata?.lastSyncId).data ?? 0;
323
+ const metadataLastSyncId = logPositionSnapshotSchema.shape.persisted.safeParse(metadata?.lastSyncId).data ?? 0;
324
324
  const dataAge = metadata?.updatedAt ? Date.now() - metadata.updatedAt.getTime() : Infinity;
325
325
  // ── Cache-validity check ─────────────────────────────────────
326
326
  //
@@ -936,7 +936,7 @@ export class Model {
936
936
  throw new AbloValidationError('Model identifier (__typename, __class, or modelName) not found in data', { code: 'model_identifier_missing' });
937
937
  }
938
938
  // Try to get model class by identifier
939
- let ModelClass = getActiveRegistry().getModelByName(modelIdentifier);
939
+ const ModelClass = getActiveRegistry().getModelByName(modelIdentifier);
940
940
  if (!ModelClass) {
941
941
  throw new AbloValidationError(`Model class not found for: ${modelIdentifier}`, { code: 'model_class_not_registered' });
942
942
  }
@@ -17,41 +17,11 @@ import { type CommitLatencySample } from './transactions/mutations/commitLatency
17
17
  import { type UnconfirmedWritesMetrics } from './transactions/mutations/UnconfirmedWrites.js';
18
18
  import type { DurableWriteStore } from './transactions/mutations/durableWriteStore.js';
19
19
  import type { Database } from './Database.js';
20
- import type { BootstrapData } from './sync/BootstrapFetcher.js';
21
20
  import type { WriteOptions } from './interfaces/index.js';
22
21
  import { LogPosition } from './logPosition.js';
23
- interface SyncObserver {
24
- onSync?: (event: SyncEvent) => void;
25
- }
26
- interface SyncEvent {
27
- type: 'create' | 'update' | 'delete' | 'archive' | 'rollback';
28
- modelType: string;
29
- model?: Model;
30
- modelId?: string;
31
- transactionType?: string;
32
- }
33
- interface SyncState {
34
- connectionState: 'connected' | 'disconnected' | 'connecting';
35
- pendingMutations: number;
36
- lastSyncAt?: Date;
37
- error?: Error;
38
- }
39
- export interface RehydrationStats {
40
- added: number;
41
- updated: number;
42
- removed: number;
43
- skipped: number;
44
- healed: number;
45
- elapsedMs: number;
46
- }
47
- /**
48
- * The slice of a bootstrap answer the pool applies: its rows, the models whose
49
- * server query failed, and the log position the snapshot was taken at — the
50
- * position every row in it reflects. `lastSyncId` is optional only for callers
51
- * applying rows with no snapshot position to speak of; the fetcher always
52
- * names one.
53
- */
54
- export type BootstrapSnapshot = Pick<BootstrapData, 'models' | 'failedModels'> & Partial<Pick<BootstrapData, 'lastSyncId'>>;
22
+ import { type RehydrationStats, type SyncObserver, type SyncState } from './syncClientTypes.js';
23
+ import type { BootstrapSnapshot } from './syncClientTypes.js';
24
+ export type { BootstrapSnapshot, RehydrationStats } from './syncClientTypes.js';
55
25
  export declare class SyncClient extends EventEmitter {
56
26
  private readonly runtime;
57
27
  private objectPool;
@@ -533,4 +503,3 @@ export declare class SyncClient extends EventEmitter {
533
503
  healed: number;
534
504
  };
535
505
  }
536
- export {};
@@ -24,22 +24,7 @@ import { LogPosition } from './logPosition.js';
24
24
  import { createLocalMutationPort } from './transactions/localMutation.js';
25
25
  import { createReconnectDrain } from './transactions/reconnectDrain.js';
26
26
  import { DatabaseCommitOutboxStore } from './transactions/databaseCommitOutbox.js';
27
- /**
28
- * Converts an untyped server `updatedAt` value — an ISO string, epoch number,
29
- * or Date read off an untyped row — into epoch milliseconds for
30
- * last-write-wins comparison. Falsy or non-date values become 0, matching the
31
- * conflict resolver's rule that a missing timestamp sorts as the epoch.
32
- */
33
- function toEpochMs(value) {
34
- if (!value)
35
- return 0;
36
- if (value instanceof Date)
37
- return value.getTime();
38
- if (typeof value === 'string' || typeof value === 'number') {
39
- return new Date(value).getTime();
40
- }
41
- return 0;
42
- }
27
+ import { toEpochMs, } from './syncClientTypes.js';
43
28
  export class SyncClient extends EventEmitter {
44
29
  runtime;
45
30
  objectPool;
@@ -10,6 +10,7 @@
10
10
  * The factory returns a plain object; the client assembles the `ablo.<model>`
11
11
  * lookup table from one of these per model.
12
12
  */
13
+ import type { CommitCreateOptions, CommitReceipt } from '@abloatai/transaction/resources/httpResources';
13
14
  import type { ModelTarget } from '@abloatai/transaction/coordination/schema';
14
15
  import type { ModelRegistry } from '../ModelRegistry.js';
15
16
  import type { InstanceCache } from '../InstanceCache.js';
@@ -17,7 +18,7 @@ import type { SyncClient } from '../SyncClient.js';
17
18
  import type { OnDemandLoader } from '../sync/OnDemandLoader.js';
18
19
  import type { JoinedParticipant } from '../sync/participants.js';
19
20
  import type { Duration, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease, ClaimWaitOptions, Snapshot } from '@abloatai/transaction/types/streams';
20
- export type { ModelListScope, ModelTrackParams, ModelTrackResult, LocalReadOptions, LocalCountOptions, ServerReadOptions, ServerGetOptions, ServerRetrieveOptions, ClaimTargetOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimSkipOptions, ClaimSkipParams, ClaimLookupParams, ClaimReorderParams, ClaimOptions, ClaimReadApi, AwaitedClaimMethod, ClaimApi, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, JoinOptions, } from '@abloatai/transaction/resources/modelOperations';
21
+ export type { ModelListScope, ModelTrackParams, ModelTrackResult, LocalReadOptions, LocalCountOptions, ServerReadOptions, ListAllOptions, ServerGetOptions, ServerRetrieveOptions, ClaimTargetOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimSkipOptions, ClaimSkipParams, ClaimLookupParams, ClaimReorderParams, ClaimOptions, ClaimReadApi, AwaitedClaimMethod, ClaimApi, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, JoinOptions, } from '@abloatai/transaction/resources/modelOperations';
21
22
  export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease };
22
23
  import type { ClaimApi, ClaimAttemptEvent, JoinOptions, LocalCountOptions, LocalReadOptions } from '@abloatai/transaction/resources/modelOperations';
23
24
  import type { HttpModelClient } from '@abloatai/transaction/transport/httpClient';
@@ -39,6 +40,17 @@ export interface ModelCollaboration {
39
40
  data: unknown;
40
41
  stamp: number;
41
42
  }>;
43
+ /**
44
+ * The batch commit lane, for a create handed a list of rows.
45
+ *
46
+ * A reactive client's single writes go through the mutation queue and land
47
+ * optimistically, because a rejected write rolls one row back. A batch
48
+ * cannot: it is atomic, so applying the rows one at a time would paint a
49
+ * half-written state the server may then decline whole. It goes down the
50
+ * same commit door the stateless client uses and the rows arrive on the
51
+ * ordinary stream, the way a teammate's would.
52
+ */
53
+ commitBatch(options: CommitCreateOptions): Promise<CommitReceipt>;
42
54
  createClaim(options: {
43
55
  /**
44
56
  * The locator, in the spelling the SDK surface and the HTTP routes use.
@@ -18,8 +18,9 @@ import { Model, modelAsRow } from '../Model.js';
18
18
  import { toMs } from '@abloatai/transaction/utils/duration';
19
19
  import { LEASE_TTL_MS } from '@abloatai/transaction/wire/protocol';
20
20
  import { heartbeatCadenceMs, resolveHeartbeatOptions, resolveHeartbeatPlan, startClaimHeartbeatLoop, } from '@abloatai/transaction/coordination/claimHeartbeatLoop';
21
- import { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
22
- import { modelList } from '@abloatai/transaction/resources/httpResources';
21
+ import { assertWriteOptions, assertWriteTarget, } from '@abloatai/transaction/resources/writeOptionsSchema';
22
+ import { createModelId, resolveCreatedRows, resolveCreateId, } from '@abloatai/transaction/resources/modelCreate';
23
+ import { collectModelList, modelList, } from '@abloatai/transaction/resources/httpResources';
23
24
  import { subTarget } from '@abloatai/transaction/coordination';
24
25
  // A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
25
26
  // .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
@@ -685,6 +686,150 @@ hydration, collaboration, readSetContext) {
685
686
  }
686
687
  return page;
687
688
  });
689
+ /**
690
+ * Creates many rows as one atomic commit, and returns them in caller order.
691
+ */
692
+ const createManyRows = async (params) => {
693
+ if (params.data.length === 0)
694
+ return [];
695
+ if (!collaboration) {
696
+ throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so a batch ` +
697
+ `create is unavailable here. Use the standard Ablo({ schema, apiKey }) client.`, { code: 'model_claim_not_configured' });
698
+ }
699
+ const prepared = prepareReadSet(readSetContext, readSetClientIdentity, undefined, undefined, params.idempotencyKey, params.reads);
700
+ try {
701
+ const organizationId = syncClient.getOrganizationId() ?? undefined;
702
+ const ids = [];
703
+ const operations = params.data.map((row) => {
704
+ const fields = row;
705
+ const id = resolveCreateId(undefined, fields) ??
706
+ createModelId(registeredModelName, params.idempotencyKey ? `${params.idempotencyKey}:${ids.length}` : null);
707
+ ids.push(id);
708
+ return {
709
+ action: 'create',
710
+ model: registeredModelName,
711
+ data: { organizationId: fields.organizationId ?? organizationId, ...fields, id },
712
+ id,
713
+ };
714
+ });
715
+ const receipt = await collaboration.commitBatch({
716
+ operations,
717
+ wait: 'confirmed',
718
+ ...(prepared.idempotencyKey
719
+ ? { idempotencyKey: prepared.idempotencyKey }
720
+ : params.idempotencyKey
721
+ ? { idempotencyKey: params.idempotencyKey }
722
+ : {}),
723
+ ...(prepared.reads
724
+ ? { reads: [...prepared.reads] }
725
+ : {}),
726
+ ...(params.track ? { track: [...params.track] } : {}),
727
+ });
728
+ const rows = await resolveCreatedRows({
729
+ modelName: registeredModelName,
730
+ ids,
731
+ operationResults: receipt.operationResults,
732
+ readRow: async (id) => {
733
+ const read = await collaboration.readPoint(registeredModelName, id);
734
+ return read.data;
735
+ },
736
+ });
737
+ consumeReadSet(readSetContext, readSetClientIdentity, prepared.consumed, prepared.automaticCommit);
738
+ return rows;
739
+ }
740
+ catch (error) {
741
+ abortReadSetCommit(readSetContext, prepared.automaticCommit);
742
+ throw error;
743
+ }
744
+ };
745
+ // `create` takes one row or a list of them. The list form is atomic and
746
+ // is therefore NOT applied optimistically: see `createManyRows`.
747
+ const createImpl = guardWrite(async (params) => {
748
+ if (Array.isArray(params.data)) {
749
+ return createManyRows(params);
750
+ }
751
+ const single = params;
752
+ const id = resolveCreateId(single.id, single.data) ?? Model.generateId();
753
+ const claim = single.claim;
754
+ let autoLease;
755
+ if (claim && !isClaimHandle(claim)) {
756
+ if (!collaboration) {
757
+ throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
758
+ }
759
+ // Write intent: enter the new row's entity scope before acquiring the
760
+ // create-claim so the holder's claim presence broadcasts to everyone
761
+ // already in this entity group (closing the subscribe-versus-broadcast
762
+ // race — see `takeClaim`). Released with the lease in the `finally`
763
+ // below. Awaited for broadcast ordering; still best-effort.
764
+ await collaboration.pinScope?.({ [schemaKey]: id });
765
+ const contention = resolveClaimContentionOptions(claim);
766
+ autoLease = await collaboration.createClaim({
767
+ target: {
768
+ model: wireModel,
769
+ id,
770
+ ...subTarget(claim, schemaKey),
771
+ },
772
+ description: claimDescription(claim, 'creating'),
773
+ ttl: claim.ttl,
774
+ queue: contention.wait,
775
+ maxQueueDepth: contention.maxDepth,
776
+ waitTimeoutMs: contention.timeoutMs,
777
+ signal: contention.signal,
778
+ onStatus: contention.onStatus,
779
+ });
780
+ }
781
+ // Default `organizationId` from the client's identity, matching the other
782
+ // write path — without this, a caller that omits it would create an
783
+ // org-unscoped row on one write path but not the other. An explicit value
784
+ // in `data` still wins via the spread.
785
+ const orgDefault = params.data.organizationId ??
786
+ syncClient.getOrganizationId();
787
+ const model = new ModelClass({
788
+ id,
789
+ ...(orgDefault != null ? { organizationId: orgDefault } : {}),
790
+ ...params.data,
791
+ createdAt: new Date(),
792
+ updatedAt: new Date(),
793
+ });
794
+ let prepared;
795
+ try {
796
+ const resolved = preparedMutation(single);
797
+ prepared = resolved.prepared;
798
+ const effective = {
799
+ ...resolved.options,
800
+ ...(autoLease
801
+ ? {
802
+ claimRef: { id: autoLease.id },
803
+ ...(autoLease.fenceToken !== undefined
804
+ ? { fenceToken: autoLease.fenceToken }
805
+ : {}),
806
+ }
807
+ : {}),
808
+ ...(isClaimHandle(claim)
809
+ ? {
810
+ claimRef: { id: claim.id },
811
+ ...(claim.fenceToken !== undefined
812
+ ? { fenceToken: claim.fenceToken }
813
+ : {}),
814
+ }
815
+ : {}),
816
+ };
817
+ syncClient.add(model, effective);
818
+ await waitForMutation(model);
819
+ consumeReadSet(readSetContext, readSetClientIdentity, prepared.consumed, prepared.automaticCommit);
820
+ return modelAsRow(model);
821
+ }
822
+ catch (error) {
823
+ abortReadSetCommit(readSetContext, prepared?.automaticCommit ?? false);
824
+ throw error;
825
+ }
826
+ finally {
827
+ await autoLease?.release?.().catch(() => { });
828
+ }
829
+ });
830
+ function createRows(params) {
831
+ return createImpl(params);
832
+ }
688
833
  const operations = {
689
834
  local,
690
835
  get,
@@ -692,85 +837,12 @@ hydration, collaboration, readSetContext) {
692
837
  // No automatic scope enrolment on bulk `list`: that would subscribe to an
693
838
  // unbounded set of rows' entity groups.
694
839
  list,
695
- create: guardWrite(async (params) => {
696
- const id = params.id ?? Model.generateId();
697
- const claim = params.claim;
698
- let autoLease;
699
- if (claim && !isClaimHandle(claim)) {
700
- if (!collaboration) {
701
- throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
702
- }
703
- // Write intent: enter the new row's entity scope before acquiring the
704
- // create-claim so the holder's claim presence broadcasts to everyone
705
- // already in this entity group (closing the subscribe-versus-broadcast
706
- // race — see `takeClaim`). Released with the lease in the `finally`
707
- // below. Awaited for broadcast ordering; still best-effort.
708
- await collaboration.pinScope?.({ [schemaKey]: id });
709
- const contention = resolveClaimContentionOptions(claim);
710
- autoLease = await collaboration.createClaim({
711
- target: {
712
- model: wireModel,
713
- id,
714
- ...subTarget(claim, schemaKey),
715
- },
716
- description: claimDescription(claim, 'creating'),
717
- ttl: claim.ttl,
718
- queue: contention.wait,
719
- maxQueueDepth: contention.maxDepth,
720
- waitTimeoutMs: contention.timeoutMs,
721
- signal: contention.signal,
722
- onStatus: contention.onStatus,
723
- });
724
- }
725
- // Default `organizationId` from the client's identity, matching the other
726
- // write path — without this, a caller that omits it would create an
727
- // org-unscoped row on one write path but not the other. An explicit value
728
- // in `data` still wins via the spread.
729
- const orgDefault = params.data.organizationId ??
730
- syncClient.getOrganizationId();
731
- const model = new ModelClass({
732
- id,
733
- ...(orgDefault != null ? { organizationId: orgDefault } : {}),
734
- ...params.data,
735
- createdAt: new Date(),
736
- updatedAt: new Date(),
737
- });
738
- let prepared;
739
- try {
740
- const resolved = preparedMutation(params);
741
- prepared = resolved.prepared;
742
- const effective = {
743
- ...resolved.options,
744
- ...(autoLease
745
- ? {
746
- claimRef: { id: autoLease.id },
747
- ...(autoLease.fenceToken !== undefined
748
- ? { fenceToken: autoLease.fenceToken }
749
- : {}),
750
- }
751
- : {}),
752
- ...(isClaimHandle(claim)
753
- ? {
754
- claimRef: { id: claim.id },
755
- ...(claim.fenceToken !== undefined
756
- ? { fenceToken: claim.fenceToken }
757
- : {}),
758
- }
759
- : {}),
760
- };
761
- syncClient.add(model, effective);
762
- await waitForMutation(model);
763
- consumeReadSet(readSetContext, readSetClientIdentity, prepared.consumed, prepared.automaticCommit);
764
- return modelAsRow(model);
765
- }
766
- catch (error) {
767
- abortReadSetCommit(readSetContext, prepared?.automaticCommit ?? false);
768
- throw error;
769
- }
770
- finally {
771
- await autoLease?.release?.().catch(() => { });
772
- }
840
+ listAll: guard(async (options = {}) => {
841
+ const { maxPages, signal, ...readOptions } = options;
842
+ signal?.throwIfAborted();
843
+ return collectModelList(await list(readOptions), { maxPages, signal });
773
844
  }),
845
+ create: createRows,
774
846
  // `update` is overloaded — classic `update({ id, data })` + functional
775
847
  // `update(id, current => next)`. The IIFE keeps the shared error-guard
776
848
  // wrapping while exposing the two public signatures (a plain `guard(...)`
@@ -839,6 +911,10 @@ hydration, collaboration, readSetContext) {
839
911
  });
840
912
  }
841
913
  const params = arg;
914
+ // Named before anything reads it. Without this the row lookup below
915
+ // reports `Entity not found: Model/undefined`, which sends the reader
916
+ // looking for a missing row rather than at the unaddressed write.
917
+ assertWriteTarget('update', registeredModelName, params.id);
842
918
  const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
843
919
  if (autoClaim) {
844
920
  const handle = await takeClaim({ ...autoClaim, id: params.id });
@@ -912,6 +988,9 @@ hydration, collaboration, readSetContext) {
912
988
  return update;
913
989
  })(),
914
990
  delete: guardWrite(async (params) => {
991
+ // Before the idempotent "ensure absent" below can read this as a row that
992
+ // is simply not here. An unaddressed delete is a mistake, not an absence.
993
+ assertWriteTarget('delete', registeredModelName, params.id);
915
994
  const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
916
995
  if (autoClaim) {
917
996
  const handle = await takeClaim({ ...autoClaim, id: params.id });
@@ -71,14 +71,17 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
71
71
  /**
72
72
  * Pins this client to one Ablo project. During `ready()` the server resolves
73
73
  * the API key's actual project and the client refuses to start when it differs.
74
- * Defaults to `ABLO_PROJECT_ID`; `ablo dev` writes that value beside the key.
75
- * This is an assertion, never a routing selector the key remains authoritative.
74
+ * Defaults to `ABLO_PROJECT_ID`. This is an assertion, never a routing
75
+ * selector the key remains authoritative and already names its own project,
76
+ * so leave this unset unless one deployment can be handed keys for more than
77
+ * one project and you want the mismatch to fail loudly.
76
78
  */
77
79
  projectId?: string | null | undefined;
78
80
  /**
79
81
  * Pins this client to one immutable Ablo branch. Defaults to
80
- * `ABLO_BRANCH_ID`; `ablo dev` writes it beside the branch key. Like
81
- * `projectId`, this is a startup assertion and never selects a branch.
82
+ * `ABLO_BRANCH_ID`. Like `projectId`, this is a startup assertion that never
83
+ * selects a branch, and is worth setting only where a key for the wrong
84
+ * environment could reach this process.
82
85
  */
83
86
  branchId?: string | null | undefined;
84
87
  /**
@@ -408,6 +408,9 @@ export function buildReactiveEngine(inputs) {
408
408
  const registeredModelName = modelDef.typename ?? schemaKey;
409
409
  modelProxies[schemaKey] = createModelProxy(schemaKey, registeredModelName, objectPool, syncClient, modelRegistry, hydration, {
410
410
  createClaim: (claimOptions) => publicClaims.create(claimOptions),
411
+ // Lazily referenced: `commits` is declared below this loop, and this
412
+ // only runs when someone actually writes a batch.
413
+ commitBatch: (commitOptions) => commits.create(commitOptions),
411
414
  readPoint,
412
415
  createSnapshot: (modelKey, id) => createSnapshot({
413
416
  pool: objectPool,
@@ -502,13 +505,14 @@ export function buildReactiveEngine(inputs) {
502
505
  if (wait === 'queued') {
503
506
  return { id: clientTxId, status: 'queued' };
504
507
  }
505
- const { lastSyncId, notifications, missingIds } = await queue.waitForCommitReceipt(clientTxId);
508
+ const { lastSyncId, notifications, missingIds, operationResults } = await queue.waitForCommitReceipt(clientTxId);
506
509
  return {
507
510
  id: clientTxId,
508
511
  status: 'confirmed',
509
512
  lastSyncId,
510
513
  ...(notifications && notifications.length > 0 ? { notifications } : {}),
511
514
  ...(missingIds && missingIds.length > 0 ? { missingIds } : {}),
515
+ ...(operationResults && operationResults.length > 0 ? { operationResults } : {}),
512
516
  };
513
517
  },
514
518
  async get({ id }) {
@@ -50,6 +50,7 @@ export function createDefaultMutationExecutor(getWs, readSetContext) {
50
50
  ...(receipt.correlationId ? { correlationId: receipt.correlationId } : {}),
51
51
  ...(receipt.notifications ? { notifications: receipt.notifications } : {}),
52
52
  ...(receipt.missingIds ? { missingIds: receipt.missingIds } : {}),
53
+ ...(receipt.operationResults ? { operationResults: receipt.operationResults } : {}),
53
54
  });
54
55
  }
55
56
  if (!ws.sendCommit) {
@@ -1,10 +1,21 @@
1
+ /**
2
+ * The three log positions a connected client owns.
3
+ *
4
+ * Each field is a {@link logPositionSchema}, the one position type, and the
5
+ * field name says who is claiming what: `applied` is what arrival processed,
6
+ * `persisted` is what local storage durably holds IN DELIVERED ORDER, and
7
+ * `acked` is what the server has been told. They are the same kind of number
8
+ * as the server's heads and cursors, and deliberately not comparable to them
9
+ * without saying which owner you mean. See the owner table on
10
+ * `@abloatai/transaction/syncLog/contract`.
11
+ */
1
12
  import { z } from 'zod';
2
- export declare const logPositionSchema: z.ZodObject<{
13
+ export declare const logPositionSnapshotSchema: z.ZodObject<{
3
14
  persisted: z.ZodNumber;
4
15
  applied: z.ZodNumber;
5
16
  acked: z.ZodNumber;
6
17
  }, z.core.$strip>;
7
- export type LogPositionSnapshot = z.infer<typeof logPositionSchema>;
18
+ export type LogPositionSnapshot = z.infer<typeof logPositionSnapshotSchema>;
8
19
  export interface LogPositionPort {
9
20
  readonly persisted: number;
10
21
  readonly applied: number;
@@ -1,11 +1,23 @@
1
+ /**
2
+ * The three log positions a connected client owns.
3
+ *
4
+ * Each field is a {@link logPositionSchema}, the one position type, and the
5
+ * field name says who is claiming what: `applied` is what arrival processed,
6
+ * `persisted` is what local storage durably holds IN DELIVERED ORDER, and
7
+ * `acked` is what the server has been told. They are the same kind of number
8
+ * as the server's heads and cursors, and deliberately not comparable to them
9
+ * without saying which owner you mean. See the owner table on
10
+ * `@abloatai/transaction/syncLog/contract`.
11
+ */
1
12
  import { z } from 'zod';
2
- export const logPositionSchema = z.object({
3
- persisted: z.number().int().nonnegative(),
4
- applied: z.number().int().nonnegative(),
5
- acked: z.number().int().nonnegative(),
13
+ import { logPositionSchema } from '@abloatai/transaction/syncLog/contract';
14
+ export const logPositionSnapshotSchema = z.object({
15
+ persisted: logPositionSchema,
16
+ applied: logPositionSchema,
17
+ acked: logPositionSchema,
6
18
  });
7
19
  export function parseLogPosition(value) {
8
- const result = logPositionSchema.safeParse(value);
20
+ const result = logPositionSnapshotSchema.safeParse(value);
9
21
  return result.success ? result.data : null;
10
22
  }
11
23
  const ZERO = { persisted: 0, applied: 0, acked: 0 };
@@ -396,6 +396,22 @@ export class SyncWebSocket extends WsTransport {
396
396
  });
397
397
  });
398
398
  }
399
+ else if (serverHead > this.cursor.lastSyncId) {
400
+ // The other direction: we are behind the server head and the server
401
+ // sent nothing. That is not a stall, it is proof. An empty response
402
+ // means the server walked the log up to `currentSyncId` under this
403
+ // client's own project and capability scope and found nothing we are
404
+ // entitled to, and it measured that head through the settled barrier,
405
+ // so no lower id can still be in flight. Adopting it is therefore
406
+ // exact, not optimistic.
407
+ //
408
+ // Without this, a client on a plane whose head moves for reasons it
409
+ // cannot see — another project, another sync group, a model outside
410
+ // its allowlist — never converges. Its cursor sticks, every catch-up
411
+ // poll finds a gap, and each of those polls takes the plane's advisory
412
+ // lock to read the settled head. The cost lands on the write path.
413
+ this.cursor.lastSyncId = serverHead;
414
+ }
399
415
  }
400
416
  if (payload.requiresBootstrap) {
401
417
  this.emit('bootstrap_required', payload.bootstrapHint);
@@ -0,0 +1,41 @@
1
+ import type { Model } from './Model.js';
2
+ import type { BootstrapData } from './sync/BootstrapFetcher.js';
3
+ import type { QueuedMutation } from './transactions/mutations/MutationQueue.js';
4
+ import type { CommitTransaction } from './transactions/mutations/commitLane.js';
5
+ export interface SyncObserver {
6
+ onSync?: (event: SyncEvent) => void;
7
+ }
8
+ export interface SyncEvent {
9
+ type: 'create' | 'update' | 'delete' | 'archive' | 'rollback';
10
+ modelType: string;
11
+ model?: Model;
12
+ modelId?: string;
13
+ transactionType?: string;
14
+ }
15
+ export interface SyncState {
16
+ connectionState: 'connected' | 'disconnected' | 'connecting';
17
+ pendingMutations: number;
18
+ lastSyncAt?: Date;
19
+ error?: Error;
20
+ }
21
+ export interface RehydrationStats {
22
+ added: number;
23
+ updated: number;
24
+ removed: number;
25
+ skipped: number;
26
+ healed: number;
27
+ elapsedMs: number;
28
+ }
29
+ export type EventHandler = () => void;
30
+ /** The bootstrap fields applied to the local object pool. */
31
+ export type BootstrapSnapshot = Pick<BootstrapData, 'models' | 'failedModels'> & Partial<Pick<BootstrapData, 'lastSyncId'>>;
32
+ /** A completed queued mutation or explicit commit. */
33
+ export type CompletedTransaction = (Pick<QueuedMutation, 'id' | 'modelId' | 'syncIdNeededForCompletion'> & {
34
+ lastSyncId?: undefined;
35
+ operations?: undefined;
36
+ }) | (Pick<CommitTransaction, 'id' | 'lastSyncId' | 'operations'> & {
37
+ modelId?: undefined;
38
+ syncIdNeededForCompletion?: undefined;
39
+ });
40
+ /** Normalize an untyped server timestamp for last-write-wins comparison. */
41
+ export declare function toEpochMs(value: unknown): number;
@@ -0,0 +1,11 @@
1
+ /** Normalize an untyped server timestamp for last-write-wins comparison. */
2
+ export function toEpochMs(value) {
3
+ if (!value)
4
+ return 0;
5
+ if (value instanceof Date)
6
+ return value.getTime();
7
+ if (typeof value === 'string' || typeof value === 'number') {
8
+ return new Date(value).getTime();
9
+ }
10
+ return 0;
11
+ }
@@ -18,6 +18,7 @@ import type { RuntimeContext } from '../../RuntimeContext.js';
18
18
  import { type LogPositionPort } from '../../logPosition.js';
19
19
  import type { WriteOptions } from '../../interfaces/index.js';
20
20
  import type { StaleNotification, ReadDependency, TrackDependency } from '@abloatai/transaction/coordination/schema';
21
+ import { type CommitOperationResult } from '@abloatai/transaction/wire/commit';
21
22
  import { type MutationInput, type QueuedMutation, type UserContext } from './commitPayload.js';
22
23
  import { type CommitOutboxScope } from '@abloatai/transaction/transactions/confirmation/commitEnvelope';
23
24
  import type { DurableWriteStore } from './durableWriteStore.js';
@@ -373,6 +374,7 @@ export declare class MutationQueue extends EventEmitter {
373
374
  lastSyncId: number;
374
375
  notifications?: StaleNotification[];
375
376
  missingIds?: string[];
377
+ operationResults?: CommitOperationResult[];
376
378
  }>;
377
379
  private isReorderPayload;
378
380
  private isPermanentError;