@abloatai/humans 0.54.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.
- package/dist/local/Database.js +2 -2
- package/dist/local/client/options.d.ts +7 -4
- package/dist/local/logPosition.d.ts +13 -2
- package/dist/local/logPosition.js +17 -5
- package/dist/local/sync/SyncWebSocket.js +16 -0
- package/package.json +2 -2
- package/src/local/Database.ts +2 -2
- package/src/local/client/options.ts +7 -4
- package/src/local/logPosition.ts +19 -6
- package/src/local/sync/SyncWebSocket.ts +15 -0
package/dist/local/Database.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
|
75
|
-
*
|
|
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
|
|
81
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
87
|
+
"@abloatai/transaction": "^0.56.0",
|
|
88
88
|
"mobx": "^6.13.7",
|
|
89
89
|
"uuid": "^11.1.0",
|
|
90
90
|
"zod": "^4.4.3"
|
package/src/local/Database.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
94
|
-
*
|
|
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
|
|
101
|
-
*
|
|
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
|
|
package/src/local/logPosition.ts
CHANGED
|
@@ -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
|
|
4
|
-
persisted:
|
|
5
|
-
applied:
|
|
6
|
-
acked:
|
|
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
|
|
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 =
|
|
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
|
|