@abloatai/humans 0.55.0 → 0.56.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.
@@ -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
  //
@@ -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
  /**
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "directory": "packages/humans"
85
85
  },
86
86
  "dependencies": {
87
- "@abloatai/transaction": "^0.55.0",
87
+ "@abloatai/transaction": "^0.56.0",
88
88
  "mobx": "^6.13.7",
89
89
  "uuid": "^11.1.0",
90
90
  "zod": "^4.4.3"
@@ -21,7 +21,7 @@ import {
21
21
  } from './stores/persistenceCleanup.js';
22
22
  import type { BootstrapFetcher, BootstrapData } from './sync/BootstrapFetcher.js';
23
23
  import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
24
- import { logPositionSchema } from './logPosition.js';
24
+ import { logPositionSnapshotSchema } from './logPosition.js';
25
25
  import type { SyncDeltaAction } from '@abloatai/transaction/wire/delta';
26
26
  import type { BootstrapType } from '@abloatai/transaction/types';
27
27
  import { highestPersistedPrefixSyncId } from './sync/persistedPrefix.js';
@@ -448,7 +448,7 @@ export class Database {
448
448
  // which only catches falsy, and get sent to the server as the resume
449
449
  // point). Invalid → 0 → full bootstrap, the safe degradation.
450
450
  const metadataLastSyncId =
451
- logPositionSchema.shape.persisted.safeParse(metadata?.lastSyncId).data ?? 0;
451
+ logPositionSnapshotSchema.shape.persisted.safeParse(metadata?.lastSyncId).data ?? 0;
452
452
  const dataAge = metadata?.updatedAt ? Date.now() - metadata.updatedAt.getTime() : Infinity;
453
453
 
454
454
  // ── Cache-validity check ─────────────────────────────────────
@@ -90,15 +90,18 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
90
90
  /**
91
91
  * Pins this client to one Ablo project. During `ready()` the server resolves
92
92
  * the API key's actual project and the client refuses to start when it differs.
93
- * Defaults to `ABLO_PROJECT_ID`; `ablo dev` writes that value beside the key.
94
- * This is an assertion, never a routing selector the key remains authoritative.
93
+ * Defaults to `ABLO_PROJECT_ID`. This is an assertion, never a routing
94
+ * selector the key remains authoritative and already names its own project,
95
+ * so leave this unset unless one deployment can be handed keys for more than
96
+ * one project and you want the mismatch to fail loudly.
95
97
  */
96
98
  projectId?: string | null | undefined;
97
99
 
98
100
  /**
99
101
  * Pins this client to one immutable Ablo branch. Defaults to
100
- * `ABLO_BRANCH_ID`; `ablo dev` writes it beside the branch key. Like
101
- * `projectId`, this is a startup assertion and never selects a branch.
102
+ * `ABLO_BRANCH_ID`. Like `projectId`, this is a startup assertion that never
103
+ * selects a branch, and is worth setting only where a key for the wrong
104
+ * environment could reach this process.
102
105
  */
103
106
  branchId?: string | null | undefined;
104
107
 
@@ -1,12 +1,25 @@
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
+ */
12
+
1
13
  import { z } from 'zod';
14
+ import { logPositionSchema } from '@abloatai/transaction/syncLog/contract';
2
15
 
3
- export const logPositionSchema = z.object({
4
- persisted: z.number().int().nonnegative(),
5
- applied: z.number().int().nonnegative(),
6
- acked: z.number().int().nonnegative(),
16
+ export const logPositionSnapshotSchema = z.object({
17
+ persisted: logPositionSchema,
18
+ applied: logPositionSchema,
19
+ acked: logPositionSchema,
7
20
  });
8
21
 
9
- export type LogPositionSnapshot = z.infer<typeof logPositionSchema>;
22
+ export type LogPositionSnapshot = z.infer<typeof logPositionSnapshotSchema>;
10
23
 
11
24
  export interface LogPositionPort {
12
25
  readonly persisted: number;
@@ -21,7 +34,7 @@ export interface LogPositionPort {
21
34
  }
22
35
 
23
36
  export function parseLogPosition(value: unknown): LogPositionSnapshot | null {
24
- const result = logPositionSchema.safeParse(value);
37
+ const result = logPositionSnapshotSchema.safeParse(value);
25
38
  return result.success ? result.data : null;
26
39
  }
27
40
 
@@ -531,6 +531,21 @@ export class SyncWebSocket<
531
531
  }
532
532
  );
533
533
  });
534
+ } else if (serverHead > this.cursor.lastSyncId) {
535
+ // The other direction: we are behind the server head and the server
536
+ // sent nothing. That is not a stall, it is proof. An empty response
537
+ // means the server walked the log up to `currentSyncId` under this
538
+ // client's own project and capability scope and found nothing we are
539
+ // entitled to, and it measured that head through the settled barrier,
540
+ // so no lower id can still be in flight. Adopting it is therefore
541
+ // exact, not optimistic.
542
+ //
543
+ // Without this, a client on a plane whose head moves for reasons it
544
+ // cannot see — another project, another sync group, a model outside
545
+ // its allowlist — never converges. Its cursor sticks, every catch-up
546
+ // poll finds a gap, and each of those polls takes the plane's advisory
547
+ // lock to read the settled head. The cost lands on the write path.
548
+ this.cursor.lastSyncId = serverHead;
534
549
  }
535
550
  }
536
551