zerodrift 1.0.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/LICENSE +21 -0
- package/README.md +358 -0
- package/dist/core/BaseModel.d.ts +76 -0
- package/dist/core/BaseModel.js +505 -0
- package/dist/core/BaseSSEConnection.d.ts +31 -0
- package/dist/core/BaseSSEConnection.js +91 -0
- package/dist/core/BatchModelLoader.d.ts +27 -0
- package/dist/core/BatchModelLoader.js +70 -0
- package/dist/core/CompoundIndexFetcher.d.ts +46 -0
- package/dist/core/CompoundIndexFetcher.js +177 -0
- package/dist/core/Database.d.ts +303 -0
- package/dist/core/Database.js +837 -0
- package/dist/core/LazyCollection.d.ts +168 -0
- package/dist/core/LazyCollection.js +403 -0
- package/dist/core/LazyOwnedCollection.d.ts +35 -0
- package/dist/core/LazyOwnedCollection.js +66 -0
- package/dist/core/MemoryAdapter.d.ts +67 -0
- package/dist/core/MemoryAdapter.js +243 -0
- package/dist/core/ModelRegistry.d.ts +64 -0
- package/dist/core/ModelRegistry.js +217 -0
- package/dist/core/ModelStream.d.ts +33 -0
- package/dist/core/ModelStream.js +68 -0
- package/dist/core/ObjectPool.d.ts +113 -0
- package/dist/core/ObjectPool.js +339 -0
- package/dist/core/Store.d.ts +40 -0
- package/dist/core/Store.js +73 -0
- package/dist/core/StoreManager.d.ts +839 -0
- package/dist/core/StoreManager.js +2034 -0
- package/dist/core/SyncConnection.d.ts +105 -0
- package/dist/core/SyncConnection.js +348 -0
- package/dist/core/Transaction.d.ts +114 -0
- package/dist/core/Transaction.js +147 -0
- package/dist/core/TransactionQueue.d.ts +110 -0
- package/dist/core/TransactionQueue.js +601 -0
- package/dist/core/decorators.d.ts +66 -0
- package/dist/core/decorators.js +278 -0
- package/dist/core/hash.d.ts +6 -0
- package/dist/core/hash.js +12 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.js +18 -0
- package/dist/core/internal.d.ts +27 -0
- package/dist/core/internal.js +25 -0
- package/dist/core/observability.d.ts +21 -0
- package/dist/core/observability.js +66 -0
- package/dist/core/refAccessors.d.ts +43 -0
- package/dist/core/refAccessors.js +80 -0
- package/dist/core/serializers.d.ts +2 -0
- package/dist/core/serializers.js +2 -0
- package/dist/core/types.d.ts +320 -0
- package/dist/core/types.js +84 -0
- package/dist/react/index.d.ts +82 -0
- package/dist/react/index.js +373 -0
- package/dist/schema/builders.d.ts +29 -0
- package/dist/schema/builders.js +81 -0
- package/dist/schema/compile.d.ts +28 -0
- package/dist/schema/compile.js +334 -0
- package/dist/schema/createStore.d.ts +235 -0
- package/dist/schema/createStore.js +264 -0
- package/dist/schema/extend.d.ts +46 -0
- package/dist/schema/extend.js +6 -0
- package/dist/schema/index.d.ts +13 -0
- package/dist/schema/index.js +8 -0
- package/dist/schema/infer.d.ts +102 -0
- package/dist/schema/infer.js +1 -0
- package/dist/schema/types.d.ts +76 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema/zod.d.ts +90 -0
- package/dist/schema/zod.js +101 -0
- package/package.json +99 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { BaseModel } from "../core/BaseModel";
|
|
2
|
+
import { hashString } from "../core/hash";
|
|
3
|
+
import { ModelRegistry } from "../core/ModelRegistry";
|
|
4
|
+
import { defineObservableProperty } from "../core/observability";
|
|
5
|
+
import { installCollectionAccessor, installReferenceAccessor, } from "../core/refAccessors";
|
|
6
|
+
import { PropertyType } from "../core/types";
|
|
7
|
+
/**
|
|
8
|
+
* Compile a `SchemaDef` produced by `defineSchema(...)` into the existing
|
|
9
|
+
* `ModelRegistry`. Each schema entity becomes a synthetic `BaseModel`
|
|
10
|
+
* subclass, registered under its PascalCased key (or `entity({ name })`
|
|
11
|
+
* override). After this returns, `ModelRegistry`, `StoreManager`, and the
|
|
12
|
+
* sync runtime see schema-defined models exactly the way they see
|
|
13
|
+
* decorator-defined ones.
|
|
14
|
+
*
|
|
15
|
+
* The function is pure with respect to the input `schema` object, but
|
|
16
|
+
* registers globally as a side effect — same contract as `@ClientModel`.
|
|
17
|
+
* Validation runs before any registry mutation; on failure the registry
|
|
18
|
+
* is untouched.
|
|
19
|
+
*
|
|
20
|
+
* The four passes below have an ordering dependency: ctors must be created
|
|
21
|
+
* before their fields can carry resolved `referenceTo` registry names; fields
|
|
22
|
+
* must exist before `registerLink` can `updateProperty` the FK; and the
|
|
23
|
+
* per-entity hash must run last so it captures every link side-effect.
|
|
24
|
+
*/
|
|
25
|
+
export function compileSchema(schema) {
|
|
26
|
+
validateSchema(schema);
|
|
27
|
+
const nameByKey = resolveNames(schema);
|
|
28
|
+
const ctorByKey = new Map();
|
|
29
|
+
const externalKeys = collectExternalKeys(schema);
|
|
30
|
+
for (const [key, entityDef] of Object.entries(schema.entities)) {
|
|
31
|
+
if (externalKeys.has(key)) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const name = nameByKey.get(key);
|
|
35
|
+
const ctor = createSyntheticClass(name, entityDef);
|
|
36
|
+
ctorByKey.set(key, ctor);
|
|
37
|
+
const meta = ModelRegistry.registerModel(name, ctor);
|
|
38
|
+
meta.loadStrategy = entityDef.loadStrategy;
|
|
39
|
+
meta.usedForPartialIndexes = entityDef.usedForPartialIndexes ?? false;
|
|
40
|
+
if (entityDef.version != null) {
|
|
41
|
+
meta.schemaVersion = entityDef.version;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const [key, entityDef] of Object.entries(schema.entities)) {
|
|
45
|
+
if (externalKeys.has(key)) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const name = nameByKey.get(key);
|
|
49
|
+
const ctor = ctorByKey.get(key);
|
|
50
|
+
for (const [fieldName, builder] of Object.entries(entityDef.fields)) {
|
|
51
|
+
registerField(ctor, name, fieldName, builder, nameByKey);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const linkDef of Object.values(schema.links)) {
|
|
55
|
+
registerLink(linkDef, externalKeys, ctorByKey, nameByKey);
|
|
56
|
+
}
|
|
57
|
+
for (const [key, entityDef] of Object.entries(schema.entities)) {
|
|
58
|
+
if (externalKeys.has(key) || entityDef.version != null) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const name = nameByKey.get(key);
|
|
62
|
+
const meta = ModelRegistry.getModelMeta(name);
|
|
63
|
+
meta.schemaVersion = hashEntityMeta(meta);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
modelNames: [...nameByKey.values()],
|
|
67
|
+
nameByKey,
|
|
68
|
+
schemaHash: ModelRegistry.schemaHash,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Entity keys that would collide with `store.<top-level>` methods (`store.batch`,
|
|
73
|
+
* future additions). Validation rejects these up front so a schema can't
|
|
74
|
+
* silently shadow the typed surface.
|
|
75
|
+
*/
|
|
76
|
+
const RESERVED_DB_KEYS = new Set([
|
|
77
|
+
"batch",
|
|
78
|
+
"undo",
|
|
79
|
+
"redo",
|
|
80
|
+
"undoDepth",
|
|
81
|
+
"redoDepth",
|
|
82
|
+
"runUndoable",
|
|
83
|
+
]);
|
|
84
|
+
function validateSchema(schema) {
|
|
85
|
+
const errors = [];
|
|
86
|
+
const entityKeys = new Set(Object.keys(schema.entities));
|
|
87
|
+
const seenRegistryNames = new Set();
|
|
88
|
+
for (const [key, entityDef] of Object.entries(schema.entities)) {
|
|
89
|
+
if (RESERVED_DB_KEYS.has(key)) {
|
|
90
|
+
errors.push(`entity key "${key}" collides with the reserved top-level \`store.${key}\`. ` +
|
|
91
|
+
`Rename the entity (e.g. "${key}Entry") or override its registry name.`);
|
|
92
|
+
}
|
|
93
|
+
if (entityDef.external === true && entityDef.name == null) {
|
|
94
|
+
errors.push(`entity "${key}": external: true requires an explicit name so the ` +
|
|
95
|
+
`compiler can resolve cross-references against the existing registry entry.`);
|
|
96
|
+
}
|
|
97
|
+
const name = entityDef.name ?? pascalCase(key);
|
|
98
|
+
if (entityDef.external === true && ModelRegistry.getModelMeta(name) == null) {
|
|
99
|
+
errors.push(`entity "${key}": external model "${name}" is not registered in ` +
|
|
100
|
+
`ModelRegistry. Import/run its @ClientModel definition before compiling the schema.`);
|
|
101
|
+
}
|
|
102
|
+
if (seenRegistryNames.has(name)) {
|
|
103
|
+
errors.push(`Two entities compile to the same registry name "${name}". ` +
|
|
104
|
+
`Override one with \`entity({ name: "..." })\`.`);
|
|
105
|
+
}
|
|
106
|
+
seenRegistryNames.add(name);
|
|
107
|
+
let idCount = 0;
|
|
108
|
+
for (const builder of Object.values(entityDef.fields)) {
|
|
109
|
+
if (builder.meta.kind === "id") {
|
|
110
|
+
idCount++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (idCount > 1) {
|
|
114
|
+
errors.push(`Entity "${key}" declares more than one s.id() field.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const fkBacklinkCount = new Map();
|
|
118
|
+
const relationNamesByEntity = new Map();
|
|
119
|
+
for (const [linkKey, linkDef] of Object.entries(schema.links)) {
|
|
120
|
+
const fromKey = linkDef.from.entity;
|
|
121
|
+
const fieldKey = linkDef.from.field;
|
|
122
|
+
const toKey = linkDef.to.entity;
|
|
123
|
+
if (!entityKeys.has(fromKey)) {
|
|
124
|
+
errors.push(`link "${linkKey}": from.entity "${fromKey}" is not a declared entity. ` +
|
|
125
|
+
`Valid entities: ${[...entityKeys].join(", ")}.`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!entityKeys.has(toKey)) {
|
|
129
|
+
errors.push(`link "${linkKey}": to.entity "${toKey}" is not a declared entity. ` +
|
|
130
|
+
`Valid entities: ${[...entityKeys].join(", ")}.`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const fromEntity = schema.entities[fromKey];
|
|
134
|
+
const fkBuilder = fromEntity.fields[fieldKey];
|
|
135
|
+
if (fkBuilder == null) {
|
|
136
|
+
errors.push(`link "${linkKey}": field "${fieldKey}" does not exist on entity "${fromKey}".`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (fkBuilder.meta.kind !== "refId") {
|
|
140
|
+
errors.push(`link "${linkKey}": field "${fromKey}.${fieldKey}" is ${fkBuilder.meta.kind}; ` +
|
|
141
|
+
`link FKs must be declared with s.refId(...).`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (fkBuilder.meta.refTarget !== toKey) {
|
|
145
|
+
errors.push(`link "${linkKey}": s.refId target is "${fkBuilder.meta.refTarget}" ` +
|
|
146
|
+
`but link.to.entity is "${toKey}". They must match.`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const fkBacklinkKey = `${fromKey}.${fieldKey}`;
|
|
150
|
+
fkBacklinkCount.set(fkBacklinkKey, (fkBacklinkCount.get(fkBacklinkKey) ?? 0) + 1);
|
|
151
|
+
if (fromEntity.fields[linkDef.from.as] != null) {
|
|
152
|
+
errors.push(`link "${linkKey}": from.as "${linkDef.from.as}" collides with a ` +
|
|
153
|
+
`field already declared on entity "${fromKey}".`);
|
|
154
|
+
}
|
|
155
|
+
const toEntity = schema.entities[toKey];
|
|
156
|
+
if (toEntity.fields[linkDef.to.many] != null) {
|
|
157
|
+
errors.push(`link "${linkKey}": to.many "${linkDef.to.many}" collides with a ` +
|
|
158
|
+
`field already declared on entity "${toKey}".`);
|
|
159
|
+
}
|
|
160
|
+
addRelationName(relationNamesByEntity, fromKey, linkDef.from.as, errors);
|
|
161
|
+
addRelationName(relationNamesByEntity, toKey, linkDef.to.many, errors);
|
|
162
|
+
}
|
|
163
|
+
for (const [key, count] of fkBacklinkCount) {
|
|
164
|
+
if (count > 1) {
|
|
165
|
+
errors.push(`FK "${key}" is referenced by ${count} links — each refId field can ` +
|
|
166
|
+
`back at most one link.`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (errors.length > 0) {
|
|
170
|
+
throw new Error(`Schema validation failed:\n - ${errors.join("\n - ")}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function addRelationName(relationNamesByEntity, entityKey, name, errors) {
|
|
174
|
+
let names = relationNamesByEntity.get(entityKey);
|
|
175
|
+
if (names == null) {
|
|
176
|
+
names = new Set();
|
|
177
|
+
relationNamesByEntity.set(entityKey, names);
|
|
178
|
+
}
|
|
179
|
+
if (names.has(name)) {
|
|
180
|
+
errors.push(`entity "${entityKey}": relation property "${name}" is declared by ` +
|
|
181
|
+
`more than one link.`);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
names.add(name);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function collectExternalKeys(schema) {
|
|
188
|
+
const out = new Set();
|
|
189
|
+
for (const [key, entityDef] of Object.entries(schema.entities)) {
|
|
190
|
+
if (entityDef.external === true) {
|
|
191
|
+
out.add(key);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
function resolveNames(schema) {
|
|
197
|
+
const out = new Map();
|
|
198
|
+
for (const [key, entityDef] of Object.entries(schema.entities)) {
|
|
199
|
+
out.set(key, entityDef.name ?? pascalCase(key));
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
function pascalCase(input) {
|
|
204
|
+
if (input.length === 0) {
|
|
205
|
+
return input;
|
|
206
|
+
}
|
|
207
|
+
return input[0].toUpperCase() + input.slice(1);
|
|
208
|
+
}
|
|
209
|
+
function createSyntheticClass(name, entityDef) {
|
|
210
|
+
const defaults = collectDefaults(entityDef);
|
|
211
|
+
const ctor = defaults.length === 0
|
|
212
|
+
? class extends BaseModel {
|
|
213
|
+
}
|
|
214
|
+
: class extends BaseModel {
|
|
215
|
+
constructor() {
|
|
216
|
+
super();
|
|
217
|
+
for (const [key, value] of defaults) {
|
|
218
|
+
this[key] = value;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
Object.defineProperty(ctor, "name", { value: name });
|
|
223
|
+
ctor._modelName = name;
|
|
224
|
+
return ctor;
|
|
225
|
+
}
|
|
226
|
+
function collectDefaults(entityDef) {
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const [fieldName, builder] of Object.entries(entityDef.fields)) {
|
|
229
|
+
if (builder.meta.kind === "id") {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if ("default" in builder.meta && builder.meta.default !== undefined) {
|
|
233
|
+
out.push([fieldName, builder.meta.default]);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
function registerField(ctor, modelName, fieldName, builder, nameByKey) {
|
|
239
|
+
const meta = builder.meta;
|
|
240
|
+
if (meta.kind === "id") {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const propMeta = {
|
|
244
|
+
name: fieldName,
|
|
245
|
+
type: meta.kind === "refId"
|
|
246
|
+
? PropertyType.Reference
|
|
247
|
+
: meta.ephemeral
|
|
248
|
+
? PropertyType.EphemeralProperty
|
|
249
|
+
: PropertyType.Property,
|
|
250
|
+
};
|
|
251
|
+
if (meta.indexed) {
|
|
252
|
+
propMeta.indexed = true;
|
|
253
|
+
}
|
|
254
|
+
if (meta.nullable) {
|
|
255
|
+
propMeta.nullable = true;
|
|
256
|
+
}
|
|
257
|
+
if (meta.serializer != null) {
|
|
258
|
+
propMeta.serializer = meta.serializer;
|
|
259
|
+
}
|
|
260
|
+
if (meta.deserializer != null) {
|
|
261
|
+
propMeta.deserializer = meta.deserializer;
|
|
262
|
+
}
|
|
263
|
+
if (meta.kind === "refId" && meta.refTarget != null) {
|
|
264
|
+
propMeta.referenceTo =
|
|
265
|
+
nameByKey.get(meta.refTarget) ?? pascalCase(meta.refTarget);
|
|
266
|
+
}
|
|
267
|
+
ModelRegistry.registerProperty(modelName, propMeta);
|
|
268
|
+
defineObservableProperty(ctor.prototype, fieldName);
|
|
269
|
+
}
|
|
270
|
+
function registerLink(linkDef, externalKeys, ctorByKey, nameByKey) {
|
|
271
|
+
const fromExternal = externalKeys.has(linkDef.from.entity);
|
|
272
|
+
const toExternal = externalKeys.has(linkDef.to.entity);
|
|
273
|
+
const fromName = nameByKey.get(linkDef.from.entity);
|
|
274
|
+
const toName = nameByKey.get(linkDef.to.entity);
|
|
275
|
+
const fromCtor = ctorByKey.get(linkDef.from.entity);
|
|
276
|
+
const toCtor = ctorByKey.get(linkDef.to.entity);
|
|
277
|
+
const fkField = linkDef.from.field;
|
|
278
|
+
const asField = linkDef.from.as;
|
|
279
|
+
const manyField = linkDef.to.many;
|
|
280
|
+
// From-side updates only apply when the source entity is owned by this
|
|
281
|
+
// schema; for external sources we leave the FK property untouched on the
|
|
282
|
+
// foreign class to avoid clobbering decorator-defined metadata.
|
|
283
|
+
if (!fromExternal && fromCtor != null) {
|
|
284
|
+
const refUpdates = { lazy: true };
|
|
285
|
+
if (linkDef.onDelete != null) {
|
|
286
|
+
refUpdates.onDelete = linkDef.onDelete;
|
|
287
|
+
}
|
|
288
|
+
ModelRegistry.updateProperty(fromName, fkField, refUpdates);
|
|
289
|
+
ModelRegistry.registerProperty(fromName, {
|
|
290
|
+
name: asField,
|
|
291
|
+
type: PropertyType.ReferenceModel,
|
|
292
|
+
referenceTo: toName,
|
|
293
|
+
idField: fkField,
|
|
294
|
+
});
|
|
295
|
+
installReferenceAccessor(fromCtor.prototype, asField, fkField, toName);
|
|
296
|
+
}
|
|
297
|
+
// To-side reverse-collection only when the target entity is owned by this
|
|
298
|
+
// schema. Schema → decorator links don't pollute the decorator's prototype.
|
|
299
|
+
if (!toExternal && toCtor != null) {
|
|
300
|
+
ModelRegistry.registerProperty(toName, {
|
|
301
|
+
name: manyField,
|
|
302
|
+
type: PropertyType.ReferenceCollection,
|
|
303
|
+
referenceTo: fromName,
|
|
304
|
+
lazy: linkDef.to.lazy ?? true,
|
|
305
|
+
inverseOf: fkField,
|
|
306
|
+
});
|
|
307
|
+
installCollectionAccessor(toCtor.prototype, manyField);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function hashEntityMeta(meta) {
|
|
311
|
+
const props = [...meta.properties.values()]
|
|
312
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
313
|
+
.map((p) => [
|
|
314
|
+
p.name,
|
|
315
|
+
p.type,
|
|
316
|
+
`referenceTo=${p.referenceTo ?? ""}`,
|
|
317
|
+
`inverseOf=${p.inverseOf ?? ""}`,
|
|
318
|
+
`idField=${p.idField ?? ""}`,
|
|
319
|
+
`idsField=${p.idsField ?? ""}`,
|
|
320
|
+
`onDelete=${p.onDelete ?? ""}`,
|
|
321
|
+
`lazy=${p.lazy === true}`,
|
|
322
|
+
`nullable=${p.nullable === true}`,
|
|
323
|
+
`indexed=${p.indexed === true}`,
|
|
324
|
+
`serializer=${p.serializer != null}`,
|
|
325
|
+
`deserializer=${p.deserializer != null}`,
|
|
326
|
+
].join(";"))
|
|
327
|
+
.join(",");
|
|
328
|
+
return hashString([
|
|
329
|
+
meta.name,
|
|
330
|
+
`loadStrategy=${meta.loadStrategy}`,
|
|
331
|
+
`usedForPartialIndexes=${meta.usedForPartialIndexes}`,
|
|
332
|
+
`props=[${props}]`,
|
|
333
|
+
].join("|"));
|
|
334
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { StoreManager } from "../core/StoreManager";
|
|
2
|
+
import type { UndoResult } from "../core/TransactionQueue";
|
|
3
|
+
import type { ExtensionDescriptor, MergedExtensionMembers } from "./extend";
|
|
4
|
+
import type { EntityKey, IndexedFieldKeys, InferCreateInput, InferEntity, InferUpdateInput } from "./infer";
|
|
5
|
+
import type { SchemaDef } from "./types";
|
|
6
|
+
/**
|
|
7
|
+
* Curated subset of `BaseModel` lifecycle methods we expose on records so
|
|
8
|
+
* imperative "mutate fields then commit" workflows have a typed path. Keeps
|
|
9
|
+
* the rest of `BaseModel`'s internals (`hydrate`, `serialize`, `assign`,
|
|
10
|
+
* `__mobx`, …) hidden so the public surface stays schema-driven.
|
|
11
|
+
*/
|
|
12
|
+
export interface RecordCommitInterface {
|
|
13
|
+
/** Flush pending field changes to the transaction queue. */
|
|
14
|
+
save(): void;
|
|
15
|
+
/** True iff there is at least one pending change since the last save. */
|
|
16
|
+
readonly hasUnsavedChanges: boolean;
|
|
17
|
+
/** Drop pending changes and reset to the last-saved values. */
|
|
18
|
+
discardUnsavedChanges(): void;
|
|
19
|
+
/**
|
|
20
|
+
* MobX-tracked subscription. The selector reads any reactive field or
|
|
21
|
+
* derivation on this record; `cb` fires whenever its result changes.
|
|
22
|
+
* Returns an unsubscribe function.
|
|
23
|
+
*
|
|
24
|
+
* record.watch(r => r.title, (next, prev) => …)
|
|
25
|
+
*
|
|
26
|
+
* Inside React, prefer `useRecord` / `useRelation` — they wire the same
|
|
27
|
+
* thing through `useSyncExternalStore`. This is the imperative path.
|
|
28
|
+
*/
|
|
29
|
+
watch<T>(selector: (record: this) => T, cb: (next: T, prev: T) => void): () => void;
|
|
30
|
+
}
|
|
31
|
+
export type RecordWithExtensions<S extends SchemaDef, K extends EntityKey<S>, Exts extends readonly ExtensionDescriptor<S>[]> = InferEntity<S, K> & MergedExtensionMembers<S, K, Exts> & RecordCommitInterface;
|
|
32
|
+
export interface EntityNamespace<S extends SchemaDef, K extends EntityKey<S>, Exts extends readonly ExtensionDescriptor<S>[]> {
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a single record. Pool-first under the hood, so a hit costs only
|
|
35
|
+
* a microtask; a miss falls back to IDB and (if configured) the on-demand
|
|
36
|
+
* fetcher.
|
|
37
|
+
*/
|
|
38
|
+
get(id: string): Promise<RecordWithExtensions<S, K, Exts> | null>;
|
|
39
|
+
/** Resolve many records by id. Pool-first per id; missing ones are loaded together. */
|
|
40
|
+
getByIds(ids: readonly string[]): Promise<ReadonlyArray<RecordWithExtensions<S, K, Exts>>>;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve every record matching `value` on a declared `.indexed()` field.
|
|
43
|
+
* The `key` is constrained at the type level to fields actually marked
|
|
44
|
+
* indexed in the schema.
|
|
45
|
+
*
|
|
46
|
+
* `value` is `string` because IDB indexes are string-typed; values from
|
|
47
|
+
* non-string indexed fields (numbers, dates, refIds) need to be stringified
|
|
48
|
+
* the same way the runtime serializes them. Future versions may type the
|
|
49
|
+
* value against the field's TS type once StoreManager.getOrLoadCollection
|
|
50
|
+
* accepts non-string values.
|
|
51
|
+
*/
|
|
52
|
+
getByIndex(key: IndexedFieldKeys<S, K>, value: string): Promise<ReadonlyArray<RecordWithExtensions<S, K, Exts>>>;
|
|
53
|
+
/**
|
|
54
|
+
* Resolve every record matching any of `values` on a declared `.indexed()`
|
|
55
|
+
* field. Fans out one `getByIndex` call per value in parallel; the pool
|
|
56
|
+
* dedupes if records appear in multiple buckets. Records are returned in
|
|
57
|
+
* input-`values` order, with duplicates collapsed to first occurrence.
|
|
58
|
+
*
|
|
59
|
+
* If your backend supports compound index queries, opt in via
|
|
60
|
+
* `serverSupportsCompoundIndexKeys: true` + `onDemandIndexBatchFetcher` to
|
|
61
|
+
* collapse the fan-out into one server round-trip when the values share a
|
|
62
|
+
* parent FK. See `agent-docs/04-lazy-loading.md`.
|
|
63
|
+
*/
|
|
64
|
+
getByIndexValues(key: IndexedFieldKeys<S, K>, values: readonly string[]): Promise<ReadonlyArray<RecordWithExtensions<S, K, Exts>>>;
|
|
65
|
+
/**
|
|
66
|
+
* Resolve every record of this entity. Hydrates from IDB on first call,
|
|
67
|
+
* relies on partial-index coverage and SSE deltas on subsequent calls.
|
|
68
|
+
*/
|
|
69
|
+
getAll(): Promise<ReadonlyArray<RecordWithExtensions<S, K, Exts>>>;
|
|
70
|
+
/**
|
|
71
|
+
* Sync pool snapshot for a single record. Returns `undefined` if the
|
|
72
|
+
* record isn't currently hydrated — `undefined` means "not in this
|
|
73
|
+
* microtask's pool," **not** "doesn't exist." (Mirrors
|
|
74
|
+
* `objectPool.getById`; distinct from `get`, which resolves `null` only
|
|
75
|
+
* after a fetch confirms absence.) Use `get(id)` to fetch, or `has(id)`
|
|
76
|
+
* for a boolean membership check.
|
|
77
|
+
*/
|
|
78
|
+
peek(id: string): RecordWithExtensions<S, K, Exts> | undefined;
|
|
79
|
+
/** Sync: is this record currently hydrated in the pool? Pairs with `peek`
|
|
80
|
+
* for code that only needs presence, not the record. */
|
|
81
|
+
has(id: string): boolean;
|
|
82
|
+
/** Sync pool snapshot of every record currently hydrated for this entity. */
|
|
83
|
+
peekAll(): ReadonlyArray<RecordWithExtensions<S, K, Exts>>;
|
|
84
|
+
/**
|
|
85
|
+
* Sync pool filter: every pooled record where `record[key] === value`.
|
|
86
|
+
* `key` is constrained to fields actually marked `.indexed()` in the
|
|
87
|
+
* schema, mirroring `getByIndex` — querying non-indexed fields here is
|
|
88
|
+
* usually a sign you wanted `getByIndex` (which can fall back to IDB).
|
|
89
|
+
*/
|
|
90
|
+
peekByIndex(key: IndexedFieldKeys<S, K>, value: string): ReadonlyArray<RecordWithExtensions<S, K, Exts>>;
|
|
91
|
+
/**
|
|
92
|
+
* Create a record and commit it at the current boundary. Returns the live
|
|
93
|
+
* record (already in the pool, transaction enqueued). For a record you
|
|
94
|
+
* want to build up before committing — e.g. a "create on submit, abandon
|
|
95
|
+
* on cancel" form — use `draft(input)` instead.
|
|
96
|
+
*/
|
|
97
|
+
create(input: InferCreateInput<S, K>): RecordWithExtensions<S, K, Exts>;
|
|
98
|
+
/**
|
|
99
|
+
* Apply a partial field update and commit it at the current boundary.
|
|
100
|
+
* Returns the record so callers can chain. Throws if no record with `id`
|
|
101
|
+
* is in the pool — to patch a lazy-loaded record, `await get(id)` (or use
|
|
102
|
+
* `draft(id)`) first.
|
|
103
|
+
*/
|
|
104
|
+
patch(id: string, fields: InferUpdateInput<S, K>): RecordWithExtensions<S, K, Exts>;
|
|
105
|
+
/**
|
|
106
|
+
* Open a staged editing buffer. Nothing is committed until the returned
|
|
107
|
+
* record's `save()` runs (or an enclosing `batch`/`atomic` flushes it);
|
|
108
|
+
* `discardUnsavedChanges()` rolls back.
|
|
109
|
+
*
|
|
110
|
+
* - `draft(id)` — resolves the existing record (pool → IDB → on-demand,
|
|
111
|
+
* same as `get`) and hands it back in staging mode. Async. Rejects if
|
|
112
|
+
* no record with `id` exists.
|
|
113
|
+
* - `draft(input?)` — a brand-new uncommitted record with its `id` minted
|
|
114
|
+
* up front (so relations can point at it). Sync. Abandoning it without
|
|
115
|
+
* `save()` leaves nothing behind.
|
|
116
|
+
*/
|
|
117
|
+
draft(id: string): Promise<RecordWithExtensions<S, K, Exts>>;
|
|
118
|
+
draft(input?: Partial<InferCreateInput<S, K>>): RecordWithExtensions<S, K, Exts>;
|
|
119
|
+
/** Delete the record with full cascade / restrict semantics. Commits at
|
|
120
|
+
* the current boundary. */
|
|
121
|
+
delete(id: string): void;
|
|
122
|
+
/** Soft-delete (archive) the record with full cascade / restrict
|
|
123
|
+
* semantics. Commits at the current boundary. */
|
|
124
|
+
archive(id: string): void;
|
|
125
|
+
/**
|
|
126
|
+
* Hydrate records straight into the pool — no transactions enqueued, no
|
|
127
|
+
* IDB writes. Re-seeding an existing id refreshes that instance in place.
|
|
128
|
+
* For tests and stories, not production.
|
|
129
|
+
*/
|
|
130
|
+
seed(records: ReadonlyArray<Partial<InferCreateInput<S, K>>>): ReadonlyArray<RecordWithExtensions<S, K, Exts>>;
|
|
131
|
+
/** Force a network re-fetch of the listed ids. */
|
|
132
|
+
refresh(ids: readonly string[]): Promise<ReadonlyArray<RecordWithExtensions<S, K, Exts>>>;
|
|
133
|
+
/** Force a network re-fetch of every record of this entity. */
|
|
134
|
+
refreshAll(): Promise<void>;
|
|
135
|
+
/**
|
|
136
|
+
* Force a network re-fetch of every record matching `value` on a declared
|
|
137
|
+
* `.indexed()` field. Evicts the partial-index coverage cache first so the
|
|
138
|
+
* next load is guaranteed to hit the server.
|
|
139
|
+
*/
|
|
140
|
+
refreshByIndex(key: IndexedFieldKeys<S, K>, value: string): Promise<ReadonlyArray<RecordWithExtensions<S, K, Exts>>>;
|
|
141
|
+
/**
|
|
142
|
+
* Subscribe to pool-level changes for this entity. The callback fires
|
|
143
|
+
* payload-less — re-read with `peekAll` / `peekByIndex` / `peek` inside
|
|
144
|
+
* the handler. Returns an unsubscribe function.
|
|
145
|
+
*
|
|
146
|
+
* Inside React, prefer `useRecords` — it wires the same primitive through
|
|
147
|
+
* `useSyncExternalStore`. This is the imperative path for headless code.
|
|
148
|
+
*/
|
|
149
|
+
watchAll(cb: () => void): () => void;
|
|
150
|
+
/**
|
|
151
|
+
* Subscribe to pool-level changes filtered by `record[key] === value`.
|
|
152
|
+
* The pool runs the predicate at write-time and only invokes `cb` when a
|
|
153
|
+
* matching record was added or removed. Cheaper than `watchAll` followed
|
|
154
|
+
* by an in-handler `peekByIndex` filter.
|
|
155
|
+
*
|
|
156
|
+
* Caveat: this fires on **set-membership changes**, not field
|
|
157
|
+
* reassignments. A record moving between buckets via a setter
|
|
158
|
+
* (`record[key] = newValue`) goes through MobX boxes, not pool notify —
|
|
159
|
+
* pair with `record.watch(r => r[key], cb)` if you need that case too.
|
|
160
|
+
*/
|
|
161
|
+
watchByIndex(key: IndexedFieldKeys<S, K>, value: string, cb: () => void): () => void;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Top-level `store` methods that aren't entity namespaces. Kept on a sibling
|
|
165
|
+
* intersection so `EntityStore<S>` stays "one entry per entity key" — the schema
|
|
166
|
+
* compiler reserves these names so an entity can't shadow them.
|
|
167
|
+
*
|
|
168
|
+
* For React, prefer `useUndoRedo()` from `zerodrift/react` — it
|
|
169
|
+
* subscribes to the transaction queue so `canUndo` / `canRedo` are
|
|
170
|
+
* reactive. These methods are the imperative path for non-React
|
|
171
|
+
* consumers (CLI tools, headless agents, tests).
|
|
172
|
+
*/
|
|
173
|
+
export interface StoreApi {
|
|
174
|
+
/**
|
|
175
|
+
* Run `fn` inside a transaction batch. Every `store.<entity>.create / update /
|
|
176
|
+
* delete` call inside shares a single `batchId`, ships in one HTTP POST,
|
|
177
|
+
* and reverses as one unit on undo. Returns the `batchId`.
|
|
178
|
+
*
|
|
179
|
+
* Accepts both sync and async functions — `endBatch` always fires after
|
|
180
|
+
* the function (or its returned Promise) completes, even on throw.
|
|
181
|
+
*
|
|
182
|
+
* The async overload is declared first so an `async () => {}` literal
|
|
183
|
+
* picks it; a sync `() => {}` returns `void` which can't satisfy
|
|
184
|
+
* `Promise<void>`, so it falls through to the sync overload.
|
|
185
|
+
*/
|
|
186
|
+
batch(fn: () => Promise<void>): Promise<string>;
|
|
187
|
+
batch(fn: () => void): string;
|
|
188
|
+
/**
|
|
189
|
+
* Stage optimistic edits with all-or-nothing local commit semantics.
|
|
190
|
+
* Every model touched inside `fn` is `save()`d on success (in one batch
|
|
191
|
+
* → one undo entry) or `discardUnsavedChanges()`d on throw. See
|
|
192
|
+
* `StoreManager.atomic` for the full contract (SSE rebasing during
|
|
193
|
+
* await, runUndoable side effects, no nesting).
|
|
194
|
+
*/
|
|
195
|
+
atomic<T>(fn: () => Promise<T>): Promise<T>;
|
|
196
|
+
atomic<T>(fn: () => T): T;
|
|
197
|
+
/** Pop and revert the top of the undo stack. */
|
|
198
|
+
undo(): Promise<UndoResult | null>;
|
|
199
|
+
/** Re-apply the top of the redo stack. */
|
|
200
|
+
redo(): Promise<UndoResult | null>;
|
|
201
|
+
/** Number of entries currently on the undo stack. */
|
|
202
|
+
readonly undoDepth: number;
|
|
203
|
+
/** Number of entries currently on the redo stack. */
|
|
204
|
+
readonly redoDepth: number;
|
|
205
|
+
/**
|
|
206
|
+
* Run a remote side-effect that returns a `changeLogId`, recording it on
|
|
207
|
+
* the undo stack so the next `store.undo()` invokes the
|
|
208
|
+
* `undoableActions.undo` handler with that id. `fn` may return either the
|
|
209
|
+
* `changeLogId` directly or any object carrying one. Inside an open
|
|
210
|
+
* `store.batch(...)`, the action joins the batch.
|
|
211
|
+
*/
|
|
212
|
+
runUndoable<T extends string | {
|
|
213
|
+
changeLogId: string;
|
|
214
|
+
}>(fn: () => Promise<T> | T, opts?: {
|
|
215
|
+
actionType?: string;
|
|
216
|
+
metadata?: Record<string, unknown>;
|
|
217
|
+
}): Promise<T>;
|
|
218
|
+
}
|
|
219
|
+
export type EntityStore<S extends SchemaDef, Exts extends readonly ExtensionDescriptor<S>[] = readonly []> = {
|
|
220
|
+
[K in EntityKey<S>]: EntityNamespace<S, K, Exts>;
|
|
221
|
+
} & StoreApi;
|
|
222
|
+
/**
|
|
223
|
+
* Project a `SchemaDef` over a live `StoreManager`. The runtime values are
|
|
224
|
+
* `BaseModel` instances that structurally satisfy the inferred record type;
|
|
225
|
+
* the proxy-based public surface described in the RFC lands later.
|
|
226
|
+
*/
|
|
227
|
+
export declare function createStore<S extends SchemaDef, const Exts extends readonly ExtensionDescriptor<S>[] = readonly []>(opts: {
|
|
228
|
+
schema: S;
|
|
229
|
+
storeManager: StoreManager<any>;
|
|
230
|
+
extensions?: Exts;
|
|
231
|
+
}): EntityStore<S, Exts>;
|
|
232
|
+
/** @internal Symbol carrying the ModelRegistry name on each namespace. */
|
|
233
|
+
export declare const REGISTRY_NAME: unique symbol;
|
|
234
|
+
/** @internal Read the registry name a namespace was built for. */
|
|
235
|
+
export declare function entityNamespaceRegistryName(ns: EntityNamespace<SchemaDef, string, readonly ExtensionDescriptor<SchemaDef>[]>): string;
|