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,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryAdapter — a fully in-memory StorageAdapter for use in Node.js, agents,
|
|
3
|
+
* and any environment without IndexedDB.
|
|
4
|
+
*
|
|
5
|
+
* Data lives in Maps and arrays for the lifetime of the StoreManager.
|
|
6
|
+
* Nothing is persisted to disk — the engine always starts with a full
|
|
7
|
+
* bootstrap and loses pending transactions on restart. For durable off-heap
|
|
8
|
+
* storage in a server environment, implement StorageAdapter with your own
|
|
9
|
+
* SQLite / Redis / file-system backend.
|
|
10
|
+
*/
|
|
11
|
+
import { BootstrapType, type DatabaseMeta, type PartialIndexEntry, type StorageAdapter, type SyncActionHeader } from "./Database";
|
|
12
|
+
export declare class MemoryAdapter implements StorageAdapter {
|
|
13
|
+
private meta;
|
|
14
|
+
private models;
|
|
15
|
+
private txLog;
|
|
16
|
+
/** Persisted SSE sync action headers — keyed by syncId. */
|
|
17
|
+
private syncActions;
|
|
18
|
+
private nextKey;
|
|
19
|
+
private connected;
|
|
20
|
+
/** modelName → indexKey → value → firstSyncId at the time of fetch. */
|
|
21
|
+
private partialIndexes;
|
|
22
|
+
/** Set true when connect() cleared rows for a schemaVersion-bumped model. */
|
|
23
|
+
migrationClearedModels: boolean;
|
|
24
|
+
/** Names of models added since the last connect — StoreManager target-fetches
|
|
25
|
+
* these so adopters don't have to bump schemaVersion by hand. */
|
|
26
|
+
newlyAddedModels: string[];
|
|
27
|
+
private loadedTracker;
|
|
28
|
+
get loadedModels(): ReadonlySet<string>;
|
|
29
|
+
onLoadedModelsChange(cb: () => void): () => void;
|
|
30
|
+
markModelLoaded(modelName: string): void;
|
|
31
|
+
connect(): Promise<void>;
|
|
32
|
+
get isConnected(): boolean;
|
|
33
|
+
loadMeta(): Promise<DatabaseMeta | null>;
|
|
34
|
+
saveMeta(meta: DatabaseMeta): Promise<void>;
|
|
35
|
+
get currentMeta(): DatabaseMeta | null;
|
|
36
|
+
determineBootstrapType(): Promise<BootstrapType>;
|
|
37
|
+
writeModels(modelName: string, records: Record<string, unknown>[]): Promise<void>;
|
|
38
|
+
writeModelsIfAbsent(modelName: string, records: Record<string, unknown>[]): Promise<void>;
|
|
39
|
+
readAllModels(modelName: string): Promise<Record<string, unknown>[]>;
|
|
40
|
+
readModel(modelName: string, id: string): Promise<Record<string, unknown> | null>;
|
|
41
|
+
readModelsByIndex(modelName: string, indexName: string, value: string): Promise<Record<string, unknown>[]>;
|
|
42
|
+
deleteModel(modelName: string, id: string): Promise<void>;
|
|
43
|
+
deleteModels(modelName: string, ids: string[]): Promise<void>;
|
|
44
|
+
deleteModelsByIndex(modelName: string, indexName: string, value: string): Promise<void>;
|
|
45
|
+
clearModelStore(modelName: string): Promise<void>;
|
|
46
|
+
cacheTransaction(data: unknown): Promise<number | null>;
|
|
47
|
+
getCachedTransactions(): Promise<{
|
|
48
|
+
idbKey: number;
|
|
49
|
+
data: unknown;
|
|
50
|
+
}[]>;
|
|
51
|
+
deleteCachedTransactions(keys: number[]): Promise<void>;
|
|
52
|
+
clearCachedTransactions(): Promise<void>;
|
|
53
|
+
updateCachedTransaction(idbKey: number, data: unknown): Promise<void>;
|
|
54
|
+
recordSyncActions(actions: SyncActionHeader[]): Promise<void>;
|
|
55
|
+
hasSyncAction(syncId: number): Promise<boolean>;
|
|
56
|
+
findSyncActionsForModel(modelName: string, modelId: string): Promise<{
|
|
57
|
+
syncId: number;
|
|
58
|
+
action: string;
|
|
59
|
+
}[]>;
|
|
60
|
+
pruneSyncActionsBelow(belowSyncId: number): Promise<void>;
|
|
61
|
+
recordPartialIndex(modelName: string, indexKey: string, value: string, firstSyncId: number): Promise<void>;
|
|
62
|
+
clearPartialIndex(modelName: string, indexKey: string, value: string): Promise<void>;
|
|
63
|
+
clearPartialIndexesForModel(modelName: string): Promise<void>;
|
|
64
|
+
loadPartialIndexes(): Promise<PartialIndexEntry[]>;
|
|
65
|
+
close(): Promise<void>;
|
|
66
|
+
destroy(): Promise<void>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryAdapter — a fully in-memory StorageAdapter for use in Node.js, agents,
|
|
3
|
+
* and any environment without IndexedDB.
|
|
4
|
+
*
|
|
5
|
+
* Data lives in Maps and arrays for the lifetime of the StoreManager.
|
|
6
|
+
* Nothing is persisted to disk — the engine always starts with a full
|
|
7
|
+
* bootstrap and loses pending transactions on restart. For durable off-heap
|
|
8
|
+
* storage in a server environment, implement StorageAdapter with your own
|
|
9
|
+
* SQLite / Redis / file-system backend.
|
|
10
|
+
*/
|
|
11
|
+
import { BootstrapType, LoadedModelsTracker, diffModelVersions, currentModelVersions, } from "./Database";
|
|
12
|
+
export class MemoryAdapter {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.meta = null;
|
|
15
|
+
this.models = new Map();
|
|
16
|
+
this.txLog = [];
|
|
17
|
+
/** Persisted SSE sync action headers — keyed by syncId. */
|
|
18
|
+
this.syncActions = new Map();
|
|
19
|
+
this.nextKey = 1;
|
|
20
|
+
this.connected = false;
|
|
21
|
+
/** modelName → indexKey → value → firstSyncId at the time of fetch. */
|
|
22
|
+
this.partialIndexes = new Map();
|
|
23
|
+
/** Set true when connect() cleared rows for a schemaVersion-bumped model. */
|
|
24
|
+
this.migrationClearedModels = false;
|
|
25
|
+
/** Names of models added since the last connect — StoreManager target-fetches
|
|
26
|
+
* these so adopters don't have to bump schemaVersion by hand. */
|
|
27
|
+
this.newlyAddedModels = [];
|
|
28
|
+
this.loadedTracker = new LoadedModelsTracker();
|
|
29
|
+
}
|
|
30
|
+
get loadedModels() {
|
|
31
|
+
return this.loadedTracker.loadedModels;
|
|
32
|
+
}
|
|
33
|
+
onLoadedModelsChange(cb) {
|
|
34
|
+
return this.loadedTracker.onChange(cb);
|
|
35
|
+
}
|
|
36
|
+
markModelLoaded(modelName) {
|
|
37
|
+
this.loadedTracker.markLoaded(modelName);
|
|
38
|
+
}
|
|
39
|
+
async connect() {
|
|
40
|
+
this.connected = true;
|
|
41
|
+
this.migrationClearedModels = false;
|
|
42
|
+
this.newlyAddedModels = [];
|
|
43
|
+
this.loadedTracker.reset();
|
|
44
|
+
for (const [name, bucket] of this.models) {
|
|
45
|
+
if (bucket.size > 0) {
|
|
46
|
+
this.loadedTracker.seed(name);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (this.meta != null) {
|
|
50
|
+
const { cleared, newlyAdded } = await diffModelVersions(this, this.meta.modelSchemaVersions);
|
|
51
|
+
this.migrationClearedModels = cleared.length > 0;
|
|
52
|
+
this.newlyAddedModels = newlyAdded;
|
|
53
|
+
if (cleared.length > 0 || newlyAdded.length > 0) {
|
|
54
|
+
this.meta.modelSchemaVersions = currentModelVersions();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
get isConnected() {
|
|
59
|
+
return this.connected;
|
|
60
|
+
}
|
|
61
|
+
async loadMeta() {
|
|
62
|
+
return this.meta;
|
|
63
|
+
}
|
|
64
|
+
async saveMeta(meta) {
|
|
65
|
+
this.meta = {
|
|
66
|
+
...meta,
|
|
67
|
+
modelSchemaVersions: meta.modelSchemaVersions ?? currentModelVersions(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
get currentMeta() {
|
|
71
|
+
return this.meta;
|
|
72
|
+
}
|
|
73
|
+
async determineBootstrapType() {
|
|
74
|
+
if (this.migrationClearedModels) {
|
|
75
|
+
return BootstrapType.Full;
|
|
76
|
+
}
|
|
77
|
+
if (this.meta == null) {
|
|
78
|
+
return BootstrapType.Full;
|
|
79
|
+
}
|
|
80
|
+
if (this.meta.lastSyncId > 0) {
|
|
81
|
+
return BootstrapType.Partial;
|
|
82
|
+
}
|
|
83
|
+
return BootstrapType.Local;
|
|
84
|
+
}
|
|
85
|
+
async writeModels(modelName, records) {
|
|
86
|
+
if (records.length === 0) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
let bucket = this.models.get(modelName);
|
|
90
|
+
if (bucket == null) {
|
|
91
|
+
bucket = new Map();
|
|
92
|
+
this.models.set(modelName, bucket);
|
|
93
|
+
}
|
|
94
|
+
for (const record of records) {
|
|
95
|
+
bucket.set(record.id, record);
|
|
96
|
+
}
|
|
97
|
+
this.loadedTracker.markLoaded(modelName);
|
|
98
|
+
}
|
|
99
|
+
async writeModelsIfAbsent(modelName, records) {
|
|
100
|
+
if (records.length === 0) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
let bucket = this.models.get(modelName);
|
|
104
|
+
if (bucket == null) {
|
|
105
|
+
bucket = new Map();
|
|
106
|
+
this.models.set(modelName, bucket);
|
|
107
|
+
}
|
|
108
|
+
let inserted = false;
|
|
109
|
+
for (const record of records) {
|
|
110
|
+
const id = record.id;
|
|
111
|
+
if (!bucket.has(id)) {
|
|
112
|
+
bucket.set(id, record);
|
|
113
|
+
inserted = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (inserted) {
|
|
117
|
+
this.loadedTracker.markLoaded(modelName);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async readAllModels(modelName) {
|
|
121
|
+
return [...(this.models.get(modelName)?.values() ?? [])];
|
|
122
|
+
}
|
|
123
|
+
async readModel(modelName, id) {
|
|
124
|
+
return this.models.get(modelName)?.get(id) ?? null;
|
|
125
|
+
}
|
|
126
|
+
async readModelsByIndex(modelName, indexName, value) {
|
|
127
|
+
return [...(this.models.get(modelName)?.values() ?? [])].filter((r) => r[indexName] === value);
|
|
128
|
+
}
|
|
129
|
+
async deleteModel(modelName, id) {
|
|
130
|
+
this.models.get(modelName)?.delete(id);
|
|
131
|
+
}
|
|
132
|
+
async deleteModels(modelName, ids) {
|
|
133
|
+
const bucket = this.models.get(modelName);
|
|
134
|
+
if (bucket == null) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
for (const id of ids) {
|
|
138
|
+
bucket.delete(id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async deleteModelsByIndex(modelName, indexName, value) {
|
|
142
|
+
const bucket = this.models.get(modelName);
|
|
143
|
+
if (bucket == null) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const [id, record] of bucket.entries()) {
|
|
147
|
+
if (record[indexName] === value) {
|
|
148
|
+
bucket.delete(id);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async clearModelStore(modelName) {
|
|
153
|
+
this.models.get(modelName)?.clear();
|
|
154
|
+
this.loadedTracker.markUnloaded(modelName);
|
|
155
|
+
}
|
|
156
|
+
async cacheTransaction(data) {
|
|
157
|
+
const key = this.nextKey++;
|
|
158
|
+
this.txLog.push({ key, data });
|
|
159
|
+
return key;
|
|
160
|
+
}
|
|
161
|
+
async getCachedTransactions() {
|
|
162
|
+
return this.txLog.map((t) => ({ idbKey: t.key, data: t.data }));
|
|
163
|
+
}
|
|
164
|
+
async deleteCachedTransactions(keys) {
|
|
165
|
+
const keySet = new Set(keys);
|
|
166
|
+
this.txLog = this.txLog.filter((t) => !keySet.has(t.key));
|
|
167
|
+
}
|
|
168
|
+
async clearCachedTransactions() {
|
|
169
|
+
this.txLog = [];
|
|
170
|
+
}
|
|
171
|
+
async updateCachedTransaction(idbKey, data) {
|
|
172
|
+
const i = this.txLog.findIndex((t) => t.key === idbKey);
|
|
173
|
+
if (i !== -1) {
|
|
174
|
+
this.txLog[i] = { key: idbKey, data };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async recordSyncActions(actions) {
|
|
178
|
+
for (const a of actions) {
|
|
179
|
+
this.syncActions.set(a.syncId, a);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async hasSyncAction(syncId) {
|
|
183
|
+
return this.syncActions.has(syncId);
|
|
184
|
+
}
|
|
185
|
+
async findSyncActionsForModel(modelName, modelId) {
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const a of this.syncActions.values()) {
|
|
188
|
+
if (a.modelName === modelName && a.modelId === modelId) {
|
|
189
|
+
out.push({ syncId: a.syncId, action: a.action });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
async pruneSyncActionsBelow(belowSyncId) {
|
|
195
|
+
for (const id of this.syncActions.keys()) {
|
|
196
|
+
if (id < belowSyncId) {
|
|
197
|
+
this.syncActions.delete(id);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async recordPartialIndex(modelName, indexKey, value, firstSyncId) {
|
|
202
|
+
let byModel = this.partialIndexes.get(modelName);
|
|
203
|
+
if (byModel == null) {
|
|
204
|
+
byModel = new Map();
|
|
205
|
+
this.partialIndexes.set(modelName, byModel);
|
|
206
|
+
}
|
|
207
|
+
let byKey = byModel.get(indexKey);
|
|
208
|
+
if (byKey == null) {
|
|
209
|
+
byKey = new Map();
|
|
210
|
+
byModel.set(indexKey, byKey);
|
|
211
|
+
}
|
|
212
|
+
byKey.set(value, firstSyncId);
|
|
213
|
+
}
|
|
214
|
+
async clearPartialIndex(modelName, indexKey, value) {
|
|
215
|
+
this.partialIndexes.get(modelName)?.get(indexKey)?.delete(value);
|
|
216
|
+
}
|
|
217
|
+
async clearPartialIndexesForModel(modelName) {
|
|
218
|
+
this.partialIndexes.delete(modelName);
|
|
219
|
+
}
|
|
220
|
+
async loadPartialIndexes() {
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const [modelName, byKey] of this.partialIndexes) {
|
|
223
|
+
for (const [indexKey, values] of byKey) {
|
|
224
|
+
for (const [value, firstSyncId] of values) {
|
|
225
|
+
out.push({ modelName, indexKey, value, firstSyncId });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
async close() {
|
|
232
|
+
this.connected = false;
|
|
233
|
+
}
|
|
234
|
+
async destroy() {
|
|
235
|
+
this.models.clear();
|
|
236
|
+
this.txLog = [];
|
|
237
|
+
this.syncActions.clear();
|
|
238
|
+
this.meta = null;
|
|
239
|
+
this.nextKey = 1;
|
|
240
|
+
this.connected = false;
|
|
241
|
+
this.partialIndexes.clear();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelRegistry is a singleton that holds metadata for every model class.
|
|
3
|
+
*
|
|
4
|
+
* When decorators like @ClientModel and @Property run at class definition time,
|
|
5
|
+
* they register information here. The rest of the engine reads from this
|
|
6
|
+
* registry to know how to serialize, hydrate, observe, and sync each model.
|
|
7
|
+
*
|
|
8
|
+
* Also computes a schemaHash — a fingerprint of all models and their properties.
|
|
9
|
+
* If the hash changes between sessions, the local IndexedDB needs a migration.
|
|
10
|
+
*/
|
|
11
|
+
import { type ModelMeta, type PropertyMeta, type CoveringPath } from "./types";
|
|
12
|
+
declare class ModelRegistryImpl {
|
|
13
|
+
private models;
|
|
14
|
+
private cachedHash;
|
|
15
|
+
/** Register a model class. Returns existing metadata if already registered. */
|
|
16
|
+
registerModel(name: string, ctor: new (...args: unknown[]) => unknown): ModelMeta;
|
|
17
|
+
/** Register a property on a model. */
|
|
18
|
+
registerProperty(modelName: string, prop: PropertyMeta): void;
|
|
19
|
+
/**
|
|
20
|
+
* Merge partial metadata into an already-registered property.
|
|
21
|
+
* Used by @Reference to promote a user-declared @Property to PropertyType.Reference,
|
|
22
|
+
* adding referenceTo / onDelete / nullable without losing indexed / serializer etc.
|
|
23
|
+
*/
|
|
24
|
+
updateProperty(modelName: string, propertyName: string, updates: Partial<PropertyMeta>): void;
|
|
25
|
+
registerAction(modelName: string, name: string): void;
|
|
26
|
+
registerComputed(modelName: string, name: string): void;
|
|
27
|
+
/** Look up metadata by model name. */
|
|
28
|
+
getModelMeta(name: string): ModelMeta | undefined;
|
|
29
|
+
/** Look up metadata from a model instance (reads the class name). */
|
|
30
|
+
getMetaForInstance(instance: object): ModelMeta | undefined;
|
|
31
|
+
/** Get all registered model metadata. */
|
|
32
|
+
allModels(): ModelMeta[];
|
|
33
|
+
/** Names of every Eager-load-strategy model. Lazy / Partial /
|
|
34
|
+
* LocalOnly / Ephemeral models are loaded on demand or
|
|
35
|
+
* via SSE — never via a full-bootstrap payload. */
|
|
36
|
+
eagerModelNames(): string[];
|
|
37
|
+
/** Names of models that pre-subscribe to SSE deltas regardless of whether
|
|
38
|
+
* any rows have been loaded locally — Eager (always fully loaded) and
|
|
39
|
+
* Ephemeral (pool-only, fed by SSE). The catchup URL unions this with the
|
|
40
|
+
* adapter's `loadedModels` so an Eager model the server happens to have
|
|
41
|
+
* zero rows for in this workspace still receives future inserts. */
|
|
42
|
+
alwaysSubscribedModelNames(): string[];
|
|
43
|
+
private coveringPathsCache;
|
|
44
|
+
/**
|
|
45
|
+
* Auto-derive covering axes for a `RefCollection<child>` declared on
|
|
46
|
+
* `parentModel`. Walks `parentModel`'s outgoing FK chain up to `maxDepth`
|
|
47
|
+
* hops; at each level checks whether the child has the same FK name as an
|
|
48
|
+
* indexed property (denormalization). Each match becomes a `CoveringPath`
|
|
49
|
+
* resolved later at hydrate time.
|
|
50
|
+
*
|
|
51
|
+
* Cycle-detected (a model in the chain isn't traversed twice). Cached per
|
|
52
|
+
* `(parent, child, depth)` triple. Result is union'd with the manual
|
|
53
|
+
* `coveringIndexes` decorator option at the call site.
|
|
54
|
+
*/
|
|
55
|
+
getDerivedCoveringPaths(parentModelName: string, childModelName: string, maxDepth: number): CoveringPath[];
|
|
56
|
+
private walkCoveringPaths;
|
|
57
|
+
/**
|
|
58
|
+
* A hash of all model names, versions, load strategies, and property metadata.
|
|
59
|
+
* Used to detect when IndexedDB needs a migration.
|
|
60
|
+
*/
|
|
61
|
+
get schemaHash(): string;
|
|
62
|
+
}
|
|
63
|
+
export declare const ModelRegistry: ModelRegistryImpl;
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelRegistry is a singleton that holds metadata for every model class.
|
|
3
|
+
*
|
|
4
|
+
* When decorators like @ClientModel and @Property run at class definition time,
|
|
5
|
+
* they register information here. The rest of the engine reads from this
|
|
6
|
+
* registry to know how to serialize, hydrate, observe, and sync each model.
|
|
7
|
+
*
|
|
8
|
+
* Also computes a schemaHash — a fingerprint of all models and their properties.
|
|
9
|
+
* If the hash changes between sessions, the local IndexedDB needs a migration.
|
|
10
|
+
*/
|
|
11
|
+
import { hashString } from "./hash";
|
|
12
|
+
import { LoadStrategy, PropertyType, } from "./types";
|
|
13
|
+
class ModelRegistryImpl {
|
|
14
|
+
constructor() {
|
|
15
|
+
this.models = new Map();
|
|
16
|
+
this.cachedHash = null;
|
|
17
|
+
this.coveringPathsCache = new Map();
|
|
18
|
+
}
|
|
19
|
+
/** Register a model class. Returns existing metadata if already registered. */
|
|
20
|
+
registerModel(name, ctor) {
|
|
21
|
+
if (!this.models.has(name)) {
|
|
22
|
+
this.models.set(name, {
|
|
23
|
+
name,
|
|
24
|
+
loadStrategy: LoadStrategy.Eager,
|
|
25
|
+
usedForPartialIndexes: false,
|
|
26
|
+
properties: new Map(),
|
|
27
|
+
actions: new Set(),
|
|
28
|
+
computedProps: new Set(),
|
|
29
|
+
ctor: ctor,
|
|
30
|
+
schemaVersion: 1,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// Any registry change invalidates downstream caches.
|
|
34
|
+
this.cachedHash = null;
|
|
35
|
+
this.coveringPathsCache.clear();
|
|
36
|
+
return this.models.get(name);
|
|
37
|
+
}
|
|
38
|
+
/** Register a property on a model. */
|
|
39
|
+
registerProperty(modelName, prop) {
|
|
40
|
+
const meta = this.models.get(modelName);
|
|
41
|
+
if (meta == null) {
|
|
42
|
+
throw new Error(`Model "${modelName}" not registered`);
|
|
43
|
+
}
|
|
44
|
+
meta.properties.set(prop.name, prop);
|
|
45
|
+
this.cachedHash = null;
|
|
46
|
+
this.coveringPathsCache.clear();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Merge partial metadata into an already-registered property.
|
|
50
|
+
* Used by @Reference to promote a user-declared @Property to PropertyType.Reference,
|
|
51
|
+
* adding referenceTo / onDelete / nullable without losing indexed / serializer etc.
|
|
52
|
+
*/
|
|
53
|
+
updateProperty(modelName, propertyName, updates) {
|
|
54
|
+
const meta = this.models.get(modelName);
|
|
55
|
+
if (meta == null) {
|
|
56
|
+
throw new Error(`Model "${modelName}" not registered`);
|
|
57
|
+
}
|
|
58
|
+
const existing = meta.properties.get(propertyName);
|
|
59
|
+
if (existing == null) {
|
|
60
|
+
throw new Error(`Property "${propertyName}" not found on model "${modelName}". ` +
|
|
61
|
+
`Declare it with @Property() before applying @Reference.`);
|
|
62
|
+
}
|
|
63
|
+
meta.properties.set(propertyName, { ...existing, ...updates });
|
|
64
|
+
this.cachedHash = null;
|
|
65
|
+
this.coveringPathsCache.clear();
|
|
66
|
+
}
|
|
67
|
+
registerAction(modelName, name) {
|
|
68
|
+
this.models.get(modelName)?.actions.add(name);
|
|
69
|
+
}
|
|
70
|
+
registerComputed(modelName, name) {
|
|
71
|
+
this.models.get(modelName)?.computedProps.add(name);
|
|
72
|
+
}
|
|
73
|
+
/** Look up metadata by model name. */
|
|
74
|
+
getModelMeta(name) {
|
|
75
|
+
return this.models.get(name);
|
|
76
|
+
}
|
|
77
|
+
/** Look up metadata from a model instance (reads the class name). */
|
|
78
|
+
getMetaForInstance(instance) {
|
|
79
|
+
const name = instance.constructor._modelName;
|
|
80
|
+
return name != null ? this.models.get(name) : undefined;
|
|
81
|
+
}
|
|
82
|
+
/** Get all registered model metadata. */
|
|
83
|
+
allModels() {
|
|
84
|
+
return [...this.models.values()];
|
|
85
|
+
}
|
|
86
|
+
/** Names of every Eager-load-strategy model. Lazy / Partial /
|
|
87
|
+
* LocalOnly / Ephemeral models are loaded on demand or
|
|
88
|
+
* via SSE — never via a full-bootstrap payload. */
|
|
89
|
+
eagerModelNames() {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const meta of this.models.values()) {
|
|
92
|
+
if (meta.loadStrategy === LoadStrategy.Eager) {
|
|
93
|
+
out.push(meta.name);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/** Names of models that pre-subscribe to SSE deltas regardless of whether
|
|
99
|
+
* any rows have been loaded locally — Eager (always fully loaded) and
|
|
100
|
+
* Ephemeral (pool-only, fed by SSE). The catchup URL unions this with the
|
|
101
|
+
* adapter's `loadedModels` so an Eager model the server happens to have
|
|
102
|
+
* zero rows for in this workspace still receives future inserts. */
|
|
103
|
+
alwaysSubscribedModelNames() {
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const meta of this.models.values()) {
|
|
106
|
+
if (meta.loadStrategy === LoadStrategy.Eager ||
|
|
107
|
+
meta.loadStrategy === LoadStrategy.Ephemeral) {
|
|
108
|
+
out.push(meta.name);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Auto-derive covering axes for a `RefCollection<child>` declared on
|
|
115
|
+
* `parentModel`. Walks `parentModel`'s outgoing FK chain up to `maxDepth`
|
|
116
|
+
* hops; at each level checks whether the child has the same FK name as an
|
|
117
|
+
* indexed property (denormalization). Each match becomes a `CoveringPath`
|
|
118
|
+
* resolved later at hydrate time.
|
|
119
|
+
*
|
|
120
|
+
* Cycle-detected (a model in the chain isn't traversed twice). Cached per
|
|
121
|
+
* `(parent, child, depth)` triple. Result is union'd with the manual
|
|
122
|
+
* `coveringIndexes` decorator option at the call site.
|
|
123
|
+
*/
|
|
124
|
+
getDerivedCoveringPaths(parentModelName, childModelName, maxDepth) {
|
|
125
|
+
if (maxDepth < 1) {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
const key = `${parentModelName}|${childModelName}|${maxDepth}`;
|
|
129
|
+
const cached = this.coveringPathsCache.get(key);
|
|
130
|
+
if (cached != null) {
|
|
131
|
+
return cached;
|
|
132
|
+
}
|
|
133
|
+
const childMeta = this.models.get(childModelName);
|
|
134
|
+
if (childMeta == null) {
|
|
135
|
+
this.coveringPathsCache.set(key, []);
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
const childIndexed = new Set();
|
|
139
|
+
for (const prop of childMeta.properties.values()) {
|
|
140
|
+
if (prop.indexed === true) {
|
|
141
|
+
childIndexed.add(prop.name);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (childIndexed.size === 0) {
|
|
145
|
+
this.coveringPathsCache.set(key, []);
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
const out = [];
|
|
149
|
+
this.walkCoveringPaths(parentModelName, childIndexed, maxDepth, [], new Set([parentModelName]), out);
|
|
150
|
+
this.coveringPathsCache.set(key, out);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
walkCoveringPaths(currentModel, childIndexed, remainingDepth, soFar, visited, out) {
|
|
154
|
+
if (remainingDepth <= 0) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const meta = this.models.get(currentModel);
|
|
158
|
+
if (meta == null) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
for (const prop of meta.properties.values()) {
|
|
162
|
+
if (prop.type !== PropertyType.Reference || prop.referenceTo == null) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const hop = { fk: prop.name, throughModel: prop.referenceTo };
|
|
166
|
+
const nextHops = [...soFar, hop];
|
|
167
|
+
if (childIndexed.has(prop.name)) {
|
|
168
|
+
out.push({ axis: prop.name, hops: nextHops });
|
|
169
|
+
}
|
|
170
|
+
if (remainingDepth > 1 && !visited.has(prop.referenceTo)) {
|
|
171
|
+
visited.add(prop.referenceTo);
|
|
172
|
+
this.walkCoveringPaths(prop.referenceTo, childIndexed, remainingDepth - 1, nextHops, visited, out);
|
|
173
|
+
visited.delete(prop.referenceTo);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A hash of all model names, versions, load strategies, and property metadata.
|
|
179
|
+
* Used to detect when IndexedDB needs a migration.
|
|
180
|
+
*/
|
|
181
|
+
get schemaHash() {
|
|
182
|
+
if (this.cachedHash != null) {
|
|
183
|
+
return this.cachedHash;
|
|
184
|
+
}
|
|
185
|
+
const sorted = [...this.models.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
186
|
+
const parts = sorted.map(([name, meta]) => {
|
|
187
|
+
const props = [...meta.properties.values()]
|
|
188
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
189
|
+
.map((prop) => [
|
|
190
|
+
prop.name,
|
|
191
|
+
prop.type,
|
|
192
|
+
`lazy=${prop.lazy === true}`,
|
|
193
|
+
`nullable=${prop.nullable === true}`,
|
|
194
|
+
`indexed=${prop.indexed === true}`,
|
|
195
|
+
`serializer=${prop.serializer != null}`,
|
|
196
|
+
`deserializer=${prop.deserializer != null}`,
|
|
197
|
+
`referenceTo=${prop.referenceTo ?? ""}`,
|
|
198
|
+
`inverseOf=${prop.inverseOf ?? ""}`,
|
|
199
|
+
`idField=${prop.idField ?? ""}`,
|
|
200
|
+
`idsField=${prop.idsField ?? ""}`,
|
|
201
|
+
`onDelete=${prop.onDelete ?? ""}`,
|
|
202
|
+
`coveringIndexes=${(prop.coveringIndexes ?? []).join("|")}`,
|
|
203
|
+
].join(";"))
|
|
204
|
+
.join(",");
|
|
205
|
+
return [
|
|
206
|
+
name,
|
|
207
|
+
`version=${meta.schemaVersion}`,
|
|
208
|
+
`loadStrategy=${meta.loadStrategy}`,
|
|
209
|
+
`usedForPartialIndexes=${meta.usedForPartialIndexes}`,
|
|
210
|
+
`props=[${props}]`,
|
|
211
|
+
].join(":");
|
|
212
|
+
});
|
|
213
|
+
this.cachedHash = hashString(parts.join("|")).toString(36);
|
|
214
|
+
return this.cachedHash;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export const ModelRegistry = new ModelRegistryImpl();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight SSE connection for secondary services (e.g. a calculation engine).
|
|
3
|
+
* Writes to IDB and upserts into the ObjectPool — no sync state management.
|
|
4
|
+
* Ephemeral models skip IDB and are only held in the pool.
|
|
5
|
+
*/
|
|
6
|
+
import type { StorageAdapter } from "./Database";
|
|
7
|
+
import { ObjectPool } from "./ObjectPool";
|
|
8
|
+
import { BaseSSEConnection, type SSEClientFactory, type SSEErrorReporter } from "./BaseSSEConnection";
|
|
9
|
+
export interface ModelUpdate {
|
|
10
|
+
modelName: string;
|
|
11
|
+
modelId: string;
|
|
12
|
+
data: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Return null to drop the message. When not provided, raw payloads are
|
|
16
|
+
* assumed to already match `ModelUpdate`.
|
|
17
|
+
*/
|
|
18
|
+
export type ModelStreamMessageTransform = (raw: unknown) => ModelUpdate | ModelUpdate[] | null | undefined;
|
|
19
|
+
export declare class ModelStream extends BaseSSEConnection {
|
|
20
|
+
private database;
|
|
21
|
+
private pool;
|
|
22
|
+
private onStatusChange?;
|
|
23
|
+
private transform?;
|
|
24
|
+
private updateQueue;
|
|
25
|
+
private processing;
|
|
26
|
+
constructor(url: string, database: StorageAdapter, pool: ObjectPool, onStatusChange?: ((connected: boolean) => void) | undefined, sseClientFactory?: SSEClientFactory, transform?: ModelStreamMessageTransform | undefined, reportError?: SSEErrorReporter);
|
|
27
|
+
disconnect(): void;
|
|
28
|
+
protected onOpen(): void;
|
|
29
|
+
protected onClose(): void;
|
|
30
|
+
protected onMessage(data: string): void;
|
|
31
|
+
private enqueue;
|
|
32
|
+
private applyUpdate;
|
|
33
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight SSE connection for secondary services (e.g. a calculation engine).
|
|
3
|
+
* Writes to IDB and upserts into the ObjectPool — no sync state management.
|
|
4
|
+
* Ephemeral models skip IDB and are only held in the pool.
|
|
5
|
+
*/
|
|
6
|
+
import { ModelRegistry } from "./ModelRegistry";
|
|
7
|
+
import { BaseSSEConnection, } from "./BaseSSEConnection";
|
|
8
|
+
import { LoadStrategy } from "./types";
|
|
9
|
+
export class ModelStream extends BaseSSEConnection {
|
|
10
|
+
constructor(url, database, pool, onStatusChange, sseClientFactory, transform, reportError) {
|
|
11
|
+
super(url, sseClientFactory, reportError);
|
|
12
|
+
this.database = database;
|
|
13
|
+
this.pool = pool;
|
|
14
|
+
this.onStatusChange = onStatusChange;
|
|
15
|
+
this.transform = transform;
|
|
16
|
+
this.updateQueue = [];
|
|
17
|
+
this.processing = false;
|
|
18
|
+
}
|
|
19
|
+
disconnect() {
|
|
20
|
+
super.disconnect();
|
|
21
|
+
this.updateQueue = [];
|
|
22
|
+
this.processing = false;
|
|
23
|
+
}
|
|
24
|
+
onOpen() {
|
|
25
|
+
this.onStatusChange?.(true);
|
|
26
|
+
}
|
|
27
|
+
onClose() {
|
|
28
|
+
this.onStatusChange?.(false);
|
|
29
|
+
}
|
|
30
|
+
onMessage(data) {
|
|
31
|
+
const raw = JSON.parse(data);
|
|
32
|
+
const transformed = this.transform != null ? this.transform(raw) : raw;
|
|
33
|
+
if (transformed == null) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.enqueue(Array.isArray(transformed) ? transformed : [transformed]);
|
|
37
|
+
}
|
|
38
|
+
async enqueue(updates) {
|
|
39
|
+
this.updateQueue.push(...updates);
|
|
40
|
+
if (this.processing) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this.processing = true;
|
|
44
|
+
while (this.updateQueue.length > 0) {
|
|
45
|
+
await this.applyUpdate(this.updateQueue.shift());
|
|
46
|
+
}
|
|
47
|
+
this.processing = false;
|
|
48
|
+
}
|
|
49
|
+
async applyUpdate(update) {
|
|
50
|
+
const { modelName, modelId, data } = update;
|
|
51
|
+
if (data == null) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const modelMeta = ModelRegistry.getModelMeta(modelName);
|
|
55
|
+
if (modelMeta == null) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const record = { id: modelId, ...data };
|
|
59
|
+
if (modelMeta.loadStrategy !== LoadStrategy.Ephemeral) {
|
|
60
|
+
await this.database.writeModels(modelName, [record]);
|
|
61
|
+
}
|
|
62
|
+
const existing = this.pool.getById(modelName, modelId);
|
|
63
|
+
if (existing != null) {
|
|
64
|
+
existing.hydrate(data);
|
|
65
|
+
this.pool.put(modelName, existing);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|