@abloatai/humans 0.58.0 → 0.59.1
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/Model.js +10 -1
- package/dist/local/client/schemaConfig.js +1 -0
- package/dist/local/interfaces/index.d.ts +6 -1
- package/dist/local/sync/BootstrapFetcher.js +4 -4
- package/dist/local/sync/schemaDrift.d.ts +15 -1
- package/dist/local/sync/schemaDrift.js +30 -13
- package/dist/local/transactions/mutations/batchProcessing.js +5 -1
- package/dist/local/transactions/mutations/commitPayload.d.ts +2 -0
- package/dist/local/transactions/mutations/commitPayload.js +14 -0
- package/dist/local/transactions/mutations/pendingDrain.js +5 -1
- package/dist/local/transactions/mutations/replayValidation.d.ts +36 -0
- package/dist/local/transactions/mutations/replayValidation.js +2 -0
- package/package.json +2 -2
- package/src/local/Model.ts +12 -1
- package/src/local/client/schemaConfig.ts +3 -0
- package/src/local/interfaces/index.ts +3 -1
- package/src/local/sync/BootstrapFetcher.ts +5 -4
- package/src/local/sync/schemaDrift.ts +32 -16
- package/src/local/transactions/mutations/batchProcessing.ts +5 -1
- package/src/local/transactions/mutations/commitPayload.ts +17 -0
- package/src/local/transactions/mutations/pendingDrain.ts +5 -1
- package/src/local/transactions/mutations/replayValidation.ts +2 -0
package/dist/local/Model.js
CHANGED
|
@@ -189,7 +189,16 @@ export class Model {
|
|
|
189
189
|
* Track property changes
|
|
190
190
|
*/
|
|
191
191
|
propertyChanged(propertyName, oldValue, newValue) {
|
|
192
|
-
|
|
192
|
+
// `createdAt` and `updatedAt` are server-managed bookkeeping, not
|
|
193
|
+
// user-authored model changes. In particular, every real field change
|
|
194
|
+
// advances `updatedAt` below. When a schema explicitly declares that
|
|
195
|
+
// timestamp, MobX observes the assignment and calls this method again;
|
|
196
|
+
// treating that callback as another edit recursively stamps `updatedAt`
|
|
197
|
+
// until the stack overflows. Ignore both timestamps at this boundary so
|
|
198
|
+
// they remain observable without entering the mutation payload.
|
|
199
|
+
if (oldValue === newValue ||
|
|
200
|
+
propertyName === 'createdAt' ||
|
|
201
|
+
propertyName === 'updatedAt')
|
|
193
202
|
return;
|
|
194
203
|
runInAction(() => {
|
|
195
204
|
// Preserve the earliest captured `old` for this field until the entry
|
|
@@ -176,6 +176,7 @@ export function deriveConfigFromSchema(schema) {
|
|
|
176
176
|
// the client compares only the models it declares, so an additive server
|
|
177
177
|
// change stays silent and a real divergence names the exact models.
|
|
178
178
|
expectedModelHashes: Object.fromEntries(Object.entries(toSchemaJSON(schema).models).map(([key, model]) => [key, modelHash(model)])),
|
|
179
|
+
expectedModelShapes: Object.fromEntries(Object.entries(toSchemaJSON(schema).models).map(([key, model]) => [key, Object.fromEntries(Object.entries(model.fields).map(([field, meta]) => [field, { type: meta.type, isOptional: meta.isOptional }]))])),
|
|
179
180
|
// For a projection (`selectModels`/`omitModels`), also carry the full source
|
|
180
181
|
// schema's hash. The drift check accepts a server match on either hash, so a
|
|
181
182
|
// subset client stays quiet against a server running its full source schema.
|
|
@@ -141,7 +141,7 @@ import type { MutationOptions } from '@abloatai/transaction/client/resources/mut
|
|
|
141
141
|
* `claim` are deliberately absent: both are resolved on the client before a write
|
|
142
142
|
* is staged, so neither reaches this layer.
|
|
143
143
|
*/
|
|
144
|
-
export type WriteOptions = Pick<MutationOptions, 'readAt' | 'idempotencyKey' | 'label' | 'fenceToken' | 'claimRef'>;
|
|
144
|
+
export type WriteOptions = Pick<MutationOptions, 'readAt' | 'reads' | 'idempotencyKey' | 'label' | 'fenceToken' | 'claimRef'>;
|
|
145
145
|
/** A single mutation within a batch. Its `options` travel with it so the server
|
|
146
146
|
* can cache and replay the operation for idempotent retries. */
|
|
147
147
|
export interface MutationOperation {
|
|
@@ -289,6 +289,11 @@ export interface RuntimeConfig {
|
|
|
289
289
|
* Advisory, like the hashes above.
|
|
290
290
|
*/
|
|
291
291
|
expectedModelHashes?: Readonly<Record<string, string>>;
|
|
292
|
+
/** Field shapes paired with expectedModelHashes so drift can name direction, not just a model. */
|
|
293
|
+
expectedModelShapes?: Readonly<Record<string, Readonly<Record<string, {
|
|
294
|
+
readonly type: string;
|
|
295
|
+
readonly isOptional: boolean;
|
|
296
|
+
}>>>>;
|
|
292
297
|
}
|
|
293
298
|
/**
|
|
294
299
|
* Extends the WebSocket event map with your own collaboration events, such as
|
|
@@ -161,14 +161,14 @@ export class BootstrapFetcher {
|
|
|
161
161
|
// network hiccup). Fire-and-forget: never blocks or fails the bootstrap.
|
|
162
162
|
const clientModels = this.runtime.config.expectedModelHashes;
|
|
163
163
|
if (clientModels && Object.keys(clientModels).length > 0) {
|
|
164
|
-
void this.resolveSemanticDrift(clientModels, clientHash, serverHash, where);
|
|
164
|
+
void this.resolveSemanticDrift(clientModels, clientHash, serverHash, where, this.runtime.config.expectedModelShapes);
|
|
165
165
|
return;
|
|
166
166
|
}
|
|
167
167
|
this.warnWholeHashDrift(clientHash, serverHash, where);
|
|
168
168
|
}
|
|
169
169
|
/** Fetch the server's per-model schema surface and warn precisely — or stay
|
|
170
170
|
* silent when every model this client declares matches (additive lead). */
|
|
171
|
-
async resolveSemanticDrift(clientModels, clientHash, serverHash, where) {
|
|
171
|
+
async resolveSemanticDrift(clientModels, clientHash, serverHash, where, clientShapes) {
|
|
172
172
|
try {
|
|
173
173
|
const res = await fetch(`${this.options.baseUrl}/schema`, {
|
|
174
174
|
method: 'GET',
|
|
@@ -181,11 +181,11 @@ export class BootstrapFetcher {
|
|
|
181
181
|
? body.models.flatMap((m) => {
|
|
182
182
|
const entry = m;
|
|
183
183
|
return typeof entry.key === 'string'
|
|
184
|
-
? [{ key: entry.key, ...(typeof entry.hash === 'string' ? { hash: entry.hash } : {}) }]
|
|
184
|
+
? [{ key: entry.key, ...(typeof entry.hash === 'string' ? { hash: entry.hash } : {}), ...(entry.fields && typeof entry.fields === 'object' ? { fields: entry.fields } : {}) }]
|
|
185
185
|
: [];
|
|
186
186
|
})
|
|
187
187
|
: [];
|
|
188
|
-
const finding = classifySchemaDrift(clientModels, models);
|
|
188
|
+
const finding = classifySchemaDrift(clientModels, models, clientShapes);
|
|
189
189
|
if (finding.kind === 'aligned')
|
|
190
190
|
return; // additive server lead — not this client's concern
|
|
191
191
|
if (finding.kind !== 'unknown') {
|
|
@@ -19,6 +19,16 @@ export interface ServerSchemaModel {
|
|
|
19
19
|
readonly key: string;
|
|
20
20
|
/** Per-model content hash; absent on servers older than this check. */
|
|
21
21
|
readonly hash?: string;
|
|
22
|
+
readonly fields?: Readonly<Record<string, {
|
|
23
|
+
readonly type: string;
|
|
24
|
+
readonly isOptional: boolean;
|
|
25
|
+
}>>;
|
|
26
|
+
}
|
|
27
|
+
export interface SchemaFieldDrift {
|
|
28
|
+
readonly model: string;
|
|
29
|
+
readonly field: string;
|
|
30
|
+
readonly direction: 'client_only' | 'active_only' | 'changed';
|
|
31
|
+
readonly detail: string;
|
|
22
32
|
}
|
|
23
33
|
export type SchemaDriftFinding =
|
|
24
34
|
/** Every model this client declares exists server-side with matching content
|
|
@@ -38,13 +48,17 @@ export type SchemaDriftFinding =
|
|
|
38
48
|
readonly kind: 'changed';
|
|
39
49
|
readonly models: readonly string[];
|
|
40
50
|
readonly unpushed: readonly string[];
|
|
51
|
+
readonly fields?: readonly SchemaFieldDrift[];
|
|
41
52
|
}
|
|
42
53
|
/** The server surface carries no per-model hashes (older server) — the
|
|
43
54
|
* caller falls back to the whole-hash comparison. */
|
|
44
55
|
| {
|
|
45
56
|
readonly kind: 'unknown';
|
|
46
57
|
};
|
|
47
|
-
export declare function classifySchemaDrift(clientModels: Readonly<Record<string, string>>, serverModels: readonly ServerSchemaModel[]
|
|
58
|
+
export declare function classifySchemaDrift(clientModels: Readonly<Record<string, string>>, serverModels: readonly ServerSchemaModel[], clientShapes?: Readonly<Record<string, Readonly<Record<string, {
|
|
59
|
+
readonly type: string;
|
|
60
|
+
readonly isOptional: boolean;
|
|
61
|
+
}>>>>): SchemaDriftFinding;
|
|
48
62
|
/**
|
|
49
63
|
* The warning for a real, named divergence. Calm and specific: which models,
|
|
50
64
|
* what that means for this client, and the one next step. Never speaks about
|
|
@@ -14,22 +14,38 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Pure and transport-free; the BootstrapFetcher owns fetching the surface.
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
import { reconcileClientToActive } from '@abloatai/transaction/schema';
|
|
18
|
+
export function classifySchemaDrift(clientModels, serverModels, clientShapes = {}) {
|
|
18
19
|
if (serverModels.length > 0 && serverModels.every((m) => !m.hash)) {
|
|
19
20
|
return { kind: 'unknown' };
|
|
20
21
|
}
|
|
21
22
|
const server = new Map(serverModels.map((m) => [m.key, m.hash]));
|
|
22
23
|
const unpushed = [];
|
|
23
24
|
const changed = [];
|
|
25
|
+
const fields = [];
|
|
24
26
|
for (const [key, hash] of Object.entries(clientModels)) {
|
|
25
27
|
const serverHash = server.get(key);
|
|
26
28
|
if (serverHash === undefined)
|
|
27
29
|
unpushed.push(key);
|
|
28
|
-
else if (serverHash !== hash)
|
|
30
|
+
else if (serverHash !== hash) {
|
|
29
31
|
changed.push(key);
|
|
32
|
+
const clientFields = clientShapes[key];
|
|
33
|
+
const activeFields = serverModels.find((model) => model.key === key)?.fields;
|
|
34
|
+
if (clientFields && activeFields)
|
|
35
|
+
for (const field of new Set([...Object.keys(clientFields), ...Object.keys(activeFields)])) {
|
|
36
|
+
const client = clientFields[field];
|
|
37
|
+
const active = activeFields[field];
|
|
38
|
+
if (!active)
|
|
39
|
+
fields.push({ model: key, field, direction: 'client_only', detail: 'present in this build but absent from the active schema' });
|
|
40
|
+
else if (!client)
|
|
41
|
+
fields.push({ model: key, field, direction: 'active_only', detail: 'present in the active schema but absent from this build' });
|
|
42
|
+
else if (client.type !== active.type || client.isOptional !== active.isOptional)
|
|
43
|
+
fields.push({ model: key, field, direction: 'changed', detail: `${client.type}${client.isOptional ? ' optional' : ' required'} in this build and ${active.type}${active.isOptional ? ' optional' : ' required'} in the active schema` });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
30
46
|
}
|
|
31
47
|
if (changed.length > 0)
|
|
32
|
-
return { kind: 'changed', models: changed, unpushed };
|
|
48
|
+
return { kind: 'changed', models: changed, unpushed, ...(fields.length ? { fields } : {}) };
|
|
33
49
|
if (unpushed.length > 0)
|
|
34
50
|
return { kind: 'unpushed', models: unpushed };
|
|
35
51
|
return { kind: 'aligned' };
|
|
@@ -40,14 +56,15 @@ export function classifySchemaDrift(clientModels, serverModels) {
|
|
|
40
56
|
* hashes — the point of the semantic check is that nobody has to compare hex.
|
|
41
57
|
*/
|
|
42
58
|
export function describeSchemaDrift(finding, serverLabel) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
`
|
|
52
|
-
|
|
59
|
+
const findings = finding.kind === 'unpushed'
|
|
60
|
+
? reconcileClientToActive([], finding.models, serverLabel)
|
|
61
|
+
: reconcileClientToActive(finding.models, finding.unpushed, serverLabel, finding.fields ?? []);
|
|
62
|
+
const changed = findings.filter(({ code }) => code === 'model_changed').map(({ model }) => model).filter(Boolean);
|
|
63
|
+
const unpushed = findings.filter(({ code }) => code === 'model_unpushed').map(({ model }) => model).filter(Boolean);
|
|
64
|
+
const summary = [
|
|
65
|
+
...findings.filter(({ field }) => field !== undefined).map(({ message }) => message),
|
|
66
|
+
...(changed.length ? [`Models ${changed.join(', ')} differ between this build and the active schema at ${serverLabel}.`] : []),
|
|
67
|
+
...(unpushed.length ? [`Models ${unpushed.join(', ')} are declared by this build but are not active at ${serverLabel}.`] : []),
|
|
68
|
+
];
|
|
69
|
+
return `Ablo: ${summary.join(' ')} ${findings.map(({ action }) => action).filter((value, index, all) => all.indexOf(value) === index).join(' ')}`;
|
|
53
70
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AbloError, AbloNotFoundError } from '@abloatai/transaction/errors';
|
|
2
|
-
import { applyWriteOptions, normalizeModelKey, TX_TYPE_TO_MUTATION_OP } from './commitPayload.js';
|
|
2
|
+
import { applyWriteOptions, collectQueuedReads, normalizeModelKey, TX_TYPE_TO_MUTATION_OP } from './commitPayload.js';
|
|
3
3
|
export async function processBatch(ctx) {
|
|
4
4
|
if (ctx.durableReplayBlock)
|
|
5
5
|
return;
|
|
@@ -69,6 +69,7 @@ export async function processBatch(ctx) {
|
|
|
69
69
|
origin: 'model_batch',
|
|
70
70
|
operations: batchOps.map(({ op }) => op),
|
|
71
71
|
sourceMutationIds: ctx.sourceMutationIdsFor(batch),
|
|
72
|
+
commitOptions: { reads: collectQueuedReads(batch) },
|
|
72
73
|
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
73
74
|
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
74
75
|
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
@@ -84,6 +85,9 @@ export async function processBatch(ctx) {
|
|
|
84
85
|
dispatchStarted = true;
|
|
85
86
|
const result = ctx.parseMutationCommitResult(await ctx.dispatchCommitBounded(operations, {
|
|
86
87
|
idempotencyKey: commitIdempotencyKey,
|
|
88
|
+
...(durableEnvelope.commitOptions.reads !== undefined
|
|
89
|
+
? { reads: durableEnvelope.commitOptions.reads }
|
|
90
|
+
: {}),
|
|
87
91
|
}));
|
|
88
92
|
await ctx.persistDurableCommitAcceptance(durableEnvelope, result);
|
|
89
93
|
const lastSyncId = result.lastSyncId;
|
|
@@ -105,6 +105,8 @@ export interface QueuedMutation {
|
|
|
105
105
|
*/
|
|
106
106
|
confirmation?: Promise<void>;
|
|
107
107
|
}
|
|
108
|
+
/** Merge per-write premises into the one batch-level read set sent on wire. */
|
|
109
|
+
export declare function collectQueuedReads(transactions: readonly QueuedMutation[]): MutationOptions['reads'] | undefined;
|
|
108
110
|
export declare const normalizeModelKey: (modelName: string) => string;
|
|
109
111
|
export declare const stripModelSuffix: (modelName: string) => string;
|
|
110
112
|
/**
|
|
@@ -75,6 +75,20 @@ export function projectCommitPayload(modelName, source, opts, runtime = globalRu
|
|
|
75
75
|
}
|
|
76
76
|
return snapshotJsonValue(out, '$.input');
|
|
77
77
|
}
|
|
78
|
+
/** Merge per-write premises into the one batch-level read set sent on wire. */
|
|
79
|
+
export function collectQueuedReads(transactions) {
|
|
80
|
+
const declared = transactions
|
|
81
|
+
.map((transaction) => transaction.writeOptions?.reads)
|
|
82
|
+
.filter((reads) => reads !== undefined);
|
|
83
|
+
if (declared.length === 0)
|
|
84
|
+
return undefined;
|
|
85
|
+
const unique = new Map();
|
|
86
|
+
for (const dependency of declared.flatMap((reads) => reads ?? [])) {
|
|
87
|
+
unique.set(JSON.stringify(dependency), dependency);
|
|
88
|
+
}
|
|
89
|
+
const reads = [...unique.values()];
|
|
90
|
+
return reads.length > 0 ? reads : null;
|
|
91
|
+
}
|
|
78
92
|
export const normalizeModelKey = (modelName) => modelName.replace('Model', '').toLowerCase();
|
|
79
93
|
export const stripModelSuffix = (modelName) => modelName.replace('Model', '');
|
|
80
94
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applyWriteOptions, TX_TYPE_TO_MUTATION_OP } from './commitPayload.js';
|
|
1
|
+
import { applyWriteOptions, collectQueuedReads, TX_TYPE_TO_MUTATION_OP } from './commitPayload.js';
|
|
2
2
|
export async function drainPendingConfirmations(ctx) {
|
|
3
3
|
ctx.assertDurableReplayOpen();
|
|
4
4
|
// Kick the commit lane too: atomic envelopes from `commits.create()` may
|
|
@@ -39,6 +39,7 @@ export async function drainPendingConfirmations(ctx) {
|
|
|
39
39
|
origin: 'model_batch',
|
|
40
40
|
operations: projectedOperations,
|
|
41
41
|
sourceMutationIds: ctx.sourceMutationIdsFor(batch),
|
|
42
|
+
commitOptions: { reads: collectQueuedReads(batch) },
|
|
42
43
|
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
43
44
|
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
44
45
|
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
@@ -46,6 +47,9 @@ export async function drainPendingConfirmations(ctx) {
|
|
|
46
47
|
ctx.assertEnvelopeInsideReplayWindow(durableEnvelope);
|
|
47
48
|
const result = ctx.parseMutationCommitResult(await ctx.dispatchCommitBounded(durableEnvelope.operations, {
|
|
48
49
|
idempotencyKey,
|
|
50
|
+
...(durableEnvelope.commitOptions.reads !== undefined
|
|
51
|
+
? { reads: durableEnvelope.commitOptions.reads }
|
|
52
|
+
: {}),
|
|
49
53
|
}));
|
|
50
54
|
await ctx.persistDurableCommitAcceptance(durableEnvelope, result);
|
|
51
55
|
if (result.status === 'queued') {
|
|
@@ -56,6 +56,15 @@ export declare const persistedTransactionSchema: z.ZodObject<{
|
|
|
56
56
|
sourceMutationIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
57
57
|
writeOptions: z.ZodOptional<z.ZodObject<{
|
|
58
58
|
readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
59
|
+
reads: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
60
|
+
model: z.ZodString;
|
|
61
|
+
id: z.ZodString;
|
|
62
|
+
fields: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodString>>>;
|
|
63
|
+
readAt: z.ZodNumber;
|
|
64
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
65
|
+
group: z.ZodTemplateLiteral<`${string}:${string}`>;
|
|
66
|
+
readAt: z.ZodNumber;
|
|
67
|
+
}, z.core.$strip>]>>>>;
|
|
59
68
|
idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
60
69
|
label: z.ZodOptional<z.ZodString>;
|
|
61
70
|
fenceToken: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
@@ -97,6 +106,15 @@ export declare const persistedMutationSchema: z.ZodObject<{
|
|
|
97
106
|
capturedChanges: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
98
107
|
writeOptions: z.ZodOptional<z.ZodObject<{
|
|
99
108
|
readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
109
|
+
reads: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
110
|
+
model: z.ZodString;
|
|
111
|
+
id: z.ZodString;
|
|
112
|
+
fields: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodString>>>;
|
|
113
|
+
readAt: z.ZodNumber;
|
|
114
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
115
|
+
group: z.ZodTemplateLiteral<`${string}:${string}`>;
|
|
116
|
+
readAt: z.ZodNumber;
|
|
117
|
+
}, z.core.$strip>]>>>>;
|
|
100
118
|
idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
101
119
|
label: z.ZodOptional<z.ZodString>;
|
|
102
120
|
fenceToken: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
@@ -131,6 +149,15 @@ export declare const legacyPendingMutationRecordSchema: z.ZodObject<{
|
|
|
131
149
|
capturedChanges: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
132
150
|
writeOptions: z.ZodOptional<z.ZodObject<{
|
|
133
151
|
readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
152
|
+
reads: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
153
|
+
model: z.ZodString;
|
|
154
|
+
id: z.ZodString;
|
|
155
|
+
fields: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodString>>>;
|
|
156
|
+
readAt: z.ZodNumber;
|
|
157
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
158
|
+
group: z.ZodTemplateLiteral<`${string}:${string}`>;
|
|
159
|
+
readAt: z.ZodNumber;
|
|
160
|
+
}, z.core.$strip>]>>>>;
|
|
134
161
|
idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
135
162
|
label: z.ZodOptional<z.ZodString>;
|
|
136
163
|
fenceToken: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
@@ -164,6 +191,15 @@ export declare const pendingMutationRecordSchema: z.ZodObject<{
|
|
|
164
191
|
capturedChanges: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
165
192
|
writeOptions: z.ZodOptional<z.ZodObject<{
|
|
166
193
|
readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
194
|
+
reads: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
195
|
+
model: z.ZodString;
|
|
196
|
+
id: z.ZodString;
|
|
197
|
+
fields: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodString>>>;
|
|
198
|
+
readAt: z.ZodNumber;
|
|
199
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
200
|
+
group: z.ZodTemplateLiteral<`${string}:${string}`>;
|
|
201
|
+
readAt: z.ZodNumber;
|
|
202
|
+
}, z.core.$strip>]>>>>;
|
|
167
203
|
idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
168
204
|
label: z.ZodOptional<z.ZodString>;
|
|
169
205
|
fenceToken: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
@@ -18,10 +18,12 @@ import { z } from 'zod';
|
|
|
18
18
|
import { computePriorityScore, normalizeModelKey } from './commitPayload.js';
|
|
19
19
|
import { globalRuntime } from '../../context.js';
|
|
20
20
|
import { commitEnvelopeMemberSchema, commitOutboxScopeSchema, } from '@abloatai/transaction/commit';
|
|
21
|
+
import { readDependencySchema } from '@abloatai/transaction/coordination/schema';
|
|
21
22
|
/** The subset of a write's options that is stored with each transaction or queued mutation. */
|
|
22
23
|
const persistedWriteOptionsSchema = z
|
|
23
24
|
.object({
|
|
24
25
|
readAt: z.number().nullable().optional(),
|
|
26
|
+
reads: z.array(readDependencySchema).nullable().optional(),
|
|
25
27
|
idempotencyKey: z.string().optional(),
|
|
26
28
|
label: z.string().optional(),
|
|
27
29
|
// Aligned with the `WriteOptions` type: a claimed write persisted locally
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.1",
|
|
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.59.1",
|
|
88
88
|
"mobx": "^6.13.7",
|
|
89
89
|
"uuid": "^11.1.0",
|
|
90
90
|
"zod": "^4.4.3"
|
package/src/local/Model.ts
CHANGED
|
@@ -281,7 +281,18 @@ export abstract class Model {
|
|
|
281
281
|
* Track property changes
|
|
282
282
|
*/
|
|
283
283
|
propertyChanged(propertyName: string, oldValue: unknown, newValue: unknown): void {
|
|
284
|
-
|
|
284
|
+
// `createdAt` and `updatedAt` are server-managed bookkeeping, not
|
|
285
|
+
// user-authored model changes. In particular, every real field change
|
|
286
|
+
// advances `updatedAt` below. When a schema explicitly declares that
|
|
287
|
+
// timestamp, MobX observes the assignment and calls this method again;
|
|
288
|
+
// treating that callback as another edit recursively stamps `updatedAt`
|
|
289
|
+
// until the stack overflows. Ignore both timestamps at this boundary so
|
|
290
|
+
// they remain observable without entering the mutation payload.
|
|
291
|
+
if (
|
|
292
|
+
oldValue === newValue ||
|
|
293
|
+
propertyName === 'createdAt' ||
|
|
294
|
+
propertyName === 'updatedAt'
|
|
295
|
+
) return;
|
|
285
296
|
|
|
286
297
|
runInAction(() => {
|
|
287
298
|
// Preserve the earliest captured `old` for this field until the entry
|
|
@@ -185,6 +185,9 @@ export function deriveConfigFromSchema(schema: Schema): RuntimeConfig {
|
|
|
185
185
|
expectedModelHashes: Object.fromEntries(
|
|
186
186
|
Object.entries(toSchemaJSON(schema).models).map(([key, model]) => [key, modelHash(model)]),
|
|
187
187
|
),
|
|
188
|
+
expectedModelShapes: Object.fromEntries(
|
|
189
|
+
Object.entries(toSchemaJSON(schema).models).map(([key, model]) => [key, Object.fromEntries(Object.entries(model.fields).map(([field, meta]) => [field, { type: meta.type, isOptional: meta.isOptional }]))]),
|
|
190
|
+
),
|
|
188
191
|
// For a projection (`selectModels`/`omitModels`), also carry the full source
|
|
189
192
|
// schema's hash. The drift check accepts a server match on either hash, so a
|
|
190
193
|
// subset client stays quiet against a server running its full source schema.
|
|
@@ -242,7 +242,7 @@ import type { MutationOptions } from '@abloatai/transaction/client/resources/mut
|
|
|
242
242
|
*/
|
|
243
243
|
export type WriteOptions = Pick<
|
|
244
244
|
MutationOptions,
|
|
245
|
-
'readAt' | 'idempotencyKey' | 'label' | 'fenceToken' | 'claimRef'
|
|
245
|
+
'readAt' | 'reads' | 'idempotencyKey' | 'label' | 'fenceToken' | 'claimRef'
|
|
246
246
|
>;
|
|
247
247
|
|
|
248
248
|
/** A single mutation within a batch. Its `options` travel with it so the server
|
|
@@ -436,6 +436,8 @@ export interface RuntimeConfig {
|
|
|
436
436
|
* Advisory, like the hashes above.
|
|
437
437
|
*/
|
|
438
438
|
expectedModelHashes?: Readonly<Record<string, string>>;
|
|
439
|
+
/** Field shapes paired with expectedModelHashes so drift can name direction, not just a model. */
|
|
440
|
+
expectedModelShapes?: Readonly<Record<string, Readonly<Record<string, { readonly type: string; readonly isOptional: boolean }>>>>;
|
|
439
441
|
}
|
|
440
442
|
|
|
441
443
|
// ─────────────────────────────────────────────
|
|
@@ -293,7 +293,7 @@ export class BootstrapFetcher {
|
|
|
293
293
|
// network hiccup). Fire-and-forget: never blocks or fails the bootstrap.
|
|
294
294
|
const clientModels = this.runtime.config.expectedModelHashes;
|
|
295
295
|
if (clientModels && Object.keys(clientModels).length > 0) {
|
|
296
|
-
void this.resolveSemanticDrift(clientModels, clientHash, serverHash, where);
|
|
296
|
+
void this.resolveSemanticDrift(clientModels, clientHash, serverHash, where, this.runtime.config.expectedModelShapes);
|
|
297
297
|
return;
|
|
298
298
|
}
|
|
299
299
|
this.warnWholeHashDrift(clientHash, serverHash, where);
|
|
@@ -306,6 +306,7 @@ export class BootstrapFetcher {
|
|
|
306
306
|
clientHash: string,
|
|
307
307
|
serverHash: string,
|
|
308
308
|
where: string,
|
|
309
|
+
clientShapes: NonNullable<typeof this.runtime.config.expectedModelShapes> | undefined,
|
|
309
310
|
): Promise<void> {
|
|
310
311
|
try {
|
|
311
312
|
const res = await fetch(`${this.options.baseUrl}/schema`, {
|
|
@@ -316,13 +317,13 @@ export class BootstrapFetcher {
|
|
|
316
317
|
const body = (await res.json()) as { models?: unknown };
|
|
317
318
|
const models = Array.isArray(body.models)
|
|
318
319
|
? body.models.flatMap((m): ServerSchemaModel[] => {
|
|
319
|
-
const entry = m as { key?: unknown; hash?: unknown };
|
|
320
|
+
const entry = m as { key?: unknown; hash?: unknown; fields?: unknown };
|
|
320
321
|
return typeof entry.key === 'string'
|
|
321
|
-
? [{ key: entry.key, ...(typeof entry.hash === 'string' ? { hash: entry.hash } : {}) }]
|
|
322
|
+
? [{ key: entry.key, ...(typeof entry.hash === 'string' ? { hash: entry.hash } : {}), ...(entry.fields && typeof entry.fields === 'object' ? { fields: entry.fields as ServerSchemaModel['fields'] } : {}) }]
|
|
322
323
|
: [];
|
|
323
324
|
})
|
|
324
325
|
: [];
|
|
325
|
-
const finding = classifySchemaDrift(clientModels, models);
|
|
326
|
+
const finding = classifySchemaDrift(clientModels, models, clientShapes);
|
|
326
327
|
if (finding.kind === 'aligned') return; // additive server lead — not this client's concern
|
|
327
328
|
if (finding.kind !== 'unknown') {
|
|
328
329
|
this.runtime.logger.warn(describeSchemaDrift(finding, where), {
|
|
@@ -15,13 +15,18 @@
|
|
|
15
15
|
* Pure and transport-free; the BootstrapFetcher owns fetching the surface.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { reconcileClientToActive } from '@abloatai/transaction/schema';
|
|
19
|
+
|
|
18
20
|
/** One model as the server's schema read-back reports it. */
|
|
19
21
|
export interface ServerSchemaModel {
|
|
20
22
|
readonly key: string;
|
|
21
23
|
/** Per-model content hash; absent on servers older than this check. */
|
|
22
24
|
readonly hash?: string;
|
|
25
|
+
readonly fields?: Readonly<Record<string, { readonly type: string; readonly isOptional: boolean }>>;
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
export interface SchemaFieldDrift { readonly model: string; readonly field: string; readonly direction: 'client_only' | 'active_only' | 'changed'; readonly detail: string; }
|
|
29
|
+
|
|
25
30
|
export type SchemaDriftFinding =
|
|
26
31
|
/** Every model this client declares exists server-side with matching content
|
|
27
32
|
* (the server may know more — that's an additive lead, not drift). */
|
|
@@ -35,6 +40,7 @@ export type SchemaDriftFinding =
|
|
|
35
40
|
readonly kind: 'changed';
|
|
36
41
|
readonly models: readonly string[];
|
|
37
42
|
readonly unpushed: readonly string[];
|
|
43
|
+
readonly fields?: readonly SchemaFieldDrift[];
|
|
38
44
|
}
|
|
39
45
|
/** The server surface carries no per-model hashes (older server) — the
|
|
40
46
|
* caller falls back to the whole-hash comparison. */
|
|
@@ -43,6 +49,7 @@ export type SchemaDriftFinding =
|
|
|
43
49
|
export function classifySchemaDrift(
|
|
44
50
|
clientModels: Readonly<Record<string, string>>,
|
|
45
51
|
serverModels: readonly ServerSchemaModel[],
|
|
52
|
+
clientShapes: Readonly<Record<string, Readonly<Record<string, { readonly type: string; readonly isOptional: boolean }>>>> = {},
|
|
46
53
|
): SchemaDriftFinding {
|
|
47
54
|
if (serverModels.length > 0 && serverModels.every((m) => !m.hash)) {
|
|
48
55
|
return { kind: 'unknown' };
|
|
@@ -50,12 +57,24 @@ export function classifySchemaDrift(
|
|
|
50
57
|
const server = new Map(serverModels.map((m) => [m.key, m.hash]));
|
|
51
58
|
const unpushed: string[] = [];
|
|
52
59
|
const changed: string[] = [];
|
|
60
|
+
const fields: SchemaFieldDrift[] = [];
|
|
53
61
|
for (const [key, hash] of Object.entries(clientModels)) {
|
|
54
62
|
const serverHash = server.get(key);
|
|
55
63
|
if (serverHash === undefined) unpushed.push(key);
|
|
56
|
-
else if (serverHash !== hash)
|
|
64
|
+
else if (serverHash !== hash) {
|
|
65
|
+
changed.push(key);
|
|
66
|
+
const clientFields = clientShapes[key];
|
|
67
|
+
const activeFields = serverModels.find((model) => model.key === key)?.fields;
|
|
68
|
+
if (clientFields && activeFields) for (const field of new Set([...Object.keys(clientFields), ...Object.keys(activeFields)])) {
|
|
69
|
+
const client = clientFields[field];
|
|
70
|
+
const active = activeFields[field];
|
|
71
|
+
if (!active) fields.push({ model: key, field, direction: 'client_only', detail: 'present in this build but absent from the active schema' });
|
|
72
|
+
else if (!client) fields.push({ model: key, field, direction: 'active_only', detail: 'present in the active schema but absent from this build' });
|
|
73
|
+
else if (client.type !== active.type || client.isOptional !== active.isOptional) fields.push({ model: key, field, direction: 'changed', detail: `${client.type}${client.isOptional ? ' optional' : ' required'} in this build and ${active.type}${active.isOptional ? ' optional' : ' required'} in the active schema` });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
57
76
|
}
|
|
58
|
-
if (changed.length > 0) return { kind: 'changed', models: changed, unpushed };
|
|
77
|
+
if (changed.length > 0) return { kind: 'changed', models: changed, unpushed, ...(fields.length ? { fields } : {}) };
|
|
59
78
|
if (unpushed.length > 0) return { kind: 'unpushed', models: unpushed };
|
|
60
79
|
return { kind: 'aligned' };
|
|
61
80
|
}
|
|
@@ -69,18 +88,15 @@ export function describeSchemaDrift(
|
|
|
69
88
|
finding: Extract<SchemaDriftFinding, { kind: 'unpushed' | 'changed' }>,
|
|
70
89
|
serverLabel: string,
|
|
71
90
|
): string {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
`declined — \`ablo status\` shows the deployed shape; pushing your schema or deploying a ` +
|
|
84
|
-
`current build aligns them.`
|
|
85
|
-
);
|
|
91
|
+
const findings = finding.kind === 'unpushed'
|
|
92
|
+
? reconcileClientToActive([], finding.models, serverLabel)
|
|
93
|
+
: reconcileClientToActive(finding.models, finding.unpushed, serverLabel, finding.fields ?? []);
|
|
94
|
+
const changed = findings.filter(({ code }) => code === 'model_changed').map(({ model }) => model).filter(Boolean);
|
|
95
|
+
const unpushed = findings.filter(({ code }) => code === 'model_unpushed').map(({ model }) => model).filter(Boolean);
|
|
96
|
+
const summary = [
|
|
97
|
+
...findings.filter(({ field }) => field !== undefined).map(({ message }) => message),
|
|
98
|
+
...(changed.length ? [`Models ${changed.join(', ')} differ between this build and the active schema at ${serverLabel}.`] : []),
|
|
99
|
+
...(unpushed.length ? [`Models ${unpushed.join(', ')} are declared by this build but are not active at ${serverLabel}.`] : []),
|
|
100
|
+
];
|
|
101
|
+
return `Ablo: ${summary.join(' ')} ${findings.map(({ action }) => action).filter((value, index, all) => all.indexOf(value) === index).join(' ')}`;
|
|
86
102
|
}
|
|
@@ -7,7 +7,7 @@ import type { DeltaConfirmationTracker } from './deltaConfirmation.js';
|
|
|
7
7
|
import type { MutationCommitResult } from '@abloatai/transaction/commit';
|
|
8
8
|
import type { DurableCommitEnvelope } from '@abloatai/transaction/commit';
|
|
9
9
|
import { AbloError, AbloNotFoundError } from '@abloatai/transaction/errors';
|
|
10
|
-
import { applyWriteOptions, normalizeModelKey, TX_TYPE_TO_MUTATION_OP, type WriteOperationFields } from './commitPayload.js';
|
|
10
|
+
import { applyWriteOptions, collectQueuedReads, normalizeModelKey, TX_TYPE_TO_MUTATION_OP, type WriteOperationFields } from './commitPayload.js';
|
|
11
11
|
import type { MutationOperationType } from '@abloatai/transaction/types';
|
|
12
12
|
|
|
13
13
|
export interface BatchProcessingContext {
|
|
@@ -138,6 +138,7 @@ export async function processBatch(ctx: BatchProcessingContext): Promise<void> {
|
|
|
138
138
|
origin: 'model_batch',
|
|
139
139
|
operations: batchOps.map(({ op }) => op),
|
|
140
140
|
sourceMutationIds: ctx.sourceMutationIdsFor(batch),
|
|
141
|
+
commitOptions: { reads: collectQueuedReads(batch) },
|
|
141
142
|
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
142
143
|
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
143
144
|
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
@@ -155,6 +156,9 @@ export async function processBatch(ctx: BatchProcessingContext): Promise<void> {
|
|
|
155
156
|
const result = ctx.parseMutationCommitResult(
|
|
156
157
|
await ctx.dispatchCommitBounded(operations, {
|
|
157
158
|
idempotencyKey: commitIdempotencyKey,
|
|
159
|
+
...(durableEnvelope.commitOptions.reads !== undefined
|
|
160
|
+
? { reads: durableEnvelope.commitOptions.reads }
|
|
161
|
+
: {}),
|
|
158
162
|
}),
|
|
159
163
|
);
|
|
160
164
|
await ctx.persistDurableCommitAcceptance(
|
|
@@ -160,6 +160,23 @@ export interface QueuedMutation {
|
|
|
160
160
|
confirmation?: Promise<void>;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/** Merge per-write premises into the one batch-level read set sent on wire. */
|
|
164
|
+
export function collectQueuedReads(
|
|
165
|
+
transactions: readonly QueuedMutation[],
|
|
166
|
+
): MutationOptions['reads'] | undefined {
|
|
167
|
+
const declared = transactions
|
|
168
|
+
.map((transaction) => transaction.writeOptions?.reads)
|
|
169
|
+
.filter((reads) => reads !== undefined);
|
|
170
|
+
if (declared.length === 0) return undefined;
|
|
171
|
+
|
|
172
|
+
const unique = new Map<string, NonNullable<MutationOptions['reads']>[number]>();
|
|
173
|
+
for (const dependency of declared.flatMap((reads) => reads ?? [])) {
|
|
174
|
+
unique.set(JSON.stringify(dependency), dependency);
|
|
175
|
+
}
|
|
176
|
+
const reads = [...unique.values()];
|
|
177
|
+
return reads.length > 0 ? reads : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
163
180
|
export const normalizeModelKey = (modelName: string): string =>
|
|
164
181
|
modelName.replace('Model', '').toLowerCase();
|
|
165
182
|
export const stripModelSuffix = (modelName: string): string => modelName.replace('Model', '');
|
|
@@ -4,7 +4,7 @@ import type { MutationStore } from './MutationStore.js';
|
|
|
4
4
|
import type { OptimisticUpdateEntry } from './localMutation.js';
|
|
5
5
|
import type { MutationCommitResult } from '@abloatai/transaction/commit';
|
|
6
6
|
import type { DurableCommitEnvelope } from '@abloatai/transaction/commit';
|
|
7
|
-
import { applyWriteOptions, TX_TYPE_TO_MUTATION_OP } from './commitPayload.js';
|
|
7
|
+
import { applyWriteOptions, collectQueuedReads, TX_TYPE_TO_MUTATION_OP } from './commitPayload.js';
|
|
8
8
|
|
|
9
9
|
export interface PendingDrainContext {
|
|
10
10
|
readonly runtime: RuntimeContext;
|
|
@@ -81,6 +81,7 @@ export async function drainPendingConfirmations(ctx: PendingDrainContext): Promi
|
|
|
81
81
|
origin: 'model_batch',
|
|
82
82
|
operations: projectedOperations,
|
|
83
83
|
sourceMutationIds: ctx.sourceMutationIdsFor(batch),
|
|
84
|
+
commitOptions: { reads: collectQueuedReads(batch) },
|
|
84
85
|
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
85
86
|
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
86
87
|
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
@@ -89,6 +90,9 @@ export async function drainPendingConfirmations(ctx: PendingDrainContext): Promi
|
|
|
89
90
|
const result = ctx.parseMutationCommitResult(
|
|
90
91
|
await ctx.dispatchCommitBounded(durableEnvelope.operations, {
|
|
91
92
|
idempotencyKey,
|
|
93
|
+
...(durableEnvelope.commitOptions.reads !== undefined
|
|
94
|
+
? { reads: durableEnvelope.commitOptions.reads }
|
|
95
|
+
: {}),
|
|
92
96
|
}),
|
|
93
97
|
);
|
|
94
98
|
await ctx.persistDurableCommitAcceptance(durableEnvelope, result);
|
|
@@ -24,11 +24,13 @@ import {
|
|
|
24
24
|
commitEnvelopeMemberSchema,
|
|
25
25
|
commitOutboxScopeSchema,
|
|
26
26
|
} from '@abloatai/transaction/commit';
|
|
27
|
+
import { readDependencySchema } from '@abloatai/transaction/coordination/schema';
|
|
27
28
|
|
|
28
29
|
/** The subset of a write's options that is stored with each transaction or queued mutation. */
|
|
29
30
|
const persistedWriteOptionsSchema = z
|
|
30
31
|
.object({
|
|
31
32
|
readAt: z.number().nullable().optional(),
|
|
33
|
+
reads: z.array(readDependencySchema).nullable().optional(),
|
|
32
34
|
idempotencyKey: z.string().optional(),
|
|
33
35
|
label: z.string().optional(),
|
|
34
36
|
// Aligned with the `WriteOptions` type: a claimed write persisted locally
|