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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 selasijean
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# zerodrift
|
|
2
|
+
|
|
3
|
+
A TypeScript local-first sync engine. Reads are synchronous from an in-memory pool, writes are optimistic, state stays current across tabs and clients via SSE, and everything persists locally so the app survives reloads and works offline. The same engine runs in Node so agents and background workers can hold a live model just like a browser tab.
|
|
4
|
+
|
|
5
|
+
You bring the backend. The client speaks a small three-endpoint protocol that can be implemented in any language. A reference Go backend and Next.js demo live in this repo so you can run the whole loop locally.
|
|
6
|
+
|
|
7
|
+
## What you get
|
|
8
|
+
|
|
9
|
+
- **Local-first reads**: every read hits an in-memory `ObjectPool` first.
|
|
10
|
+
- **Optimistic writes**: model changes update the UI immediately, then reconcile with server deltas.
|
|
11
|
+
- **Realtime sync**: tabs and clients stay current over SSE, without polling.
|
|
12
|
+
- **Offline persistence**: IndexedDB stores models and queued transactions in the browser.
|
|
13
|
+
- **Two authoring paths**: decorator classes or schema-as-data via `defineSchema(...)`.
|
|
14
|
+
- **React and headless runtimes**: use hooks in React, or run `StoreManager` directly in Node, CLIs, and agents.
|
|
15
|
+
- **Bring your own backend**: implement bootstrap, transaction, and SSE endpoints in the stack you already use.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install zerodrift
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Optional packages depend on the surface you use:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install zod # for entityFromZod(...) schema authoring
|
|
27
|
+
npm install eventsource # for Node/headless SSE clients
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If you use the decorator authoring path, enable `experimentalDecorators` in your `tsconfig.json` (or the SWC/Babel equivalent). `reflect-metadata` is **not** required — the engine never reads `design:type` metadata.
|
|
31
|
+
|
|
32
|
+
## Import paths
|
|
33
|
+
|
|
34
|
+
| Import | Use it for |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `zerodrift` | `StoreManager`, `BaseModel`, decorators, `MemoryAdapter`, relation field types (`RefCollection`/`BackRef`/`OwnedRefs`), and the config / error / sync types. The curated, stable surface. |
|
|
37
|
+
| `zerodrift/schema` | `defineSchema`, `entityFromZod`, field builders, links, extensions, and typed `store.<entity>.*` APIs. |
|
|
38
|
+
| `zerodrift/react` | `<SyncProvider>` and React hooks: `useRecord`, `useRecords`, `useRecordsByIndex`, `useRelation`, `useBatch`, `useUndoRedo`. |
|
|
39
|
+
| `zerodrift/internal` | Engine machinery (`ObjectPool`, `TransactionQueue`, `SyncConnection`, `ModelRegistry`, …) for tooling/tests. **No stability promise** — may change between releases. |
|
|
40
|
+
|
|
41
|
+
## Define your models
|
|
42
|
+
|
|
43
|
+
Decorator models extend `BaseModel` and use decorators to declare fields and relationships.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import {
|
|
47
|
+
BaseModel,
|
|
48
|
+
ClientModel,
|
|
49
|
+
Property,
|
|
50
|
+
Reference,
|
|
51
|
+
LazyReferenceCollection,
|
|
52
|
+
LoadStrategy,
|
|
53
|
+
} from "zerodrift";
|
|
54
|
+
import type { RefCollection } from "zerodrift";
|
|
55
|
+
|
|
56
|
+
@ClientModel({ name: "Team", loadStrategy: LoadStrategy.Eager })
|
|
57
|
+
export class Team extends BaseModel {
|
|
58
|
+
@Property() public name = "";
|
|
59
|
+
|
|
60
|
+
@LazyReferenceCollection("Issue", { inverseOf: "teamId" })
|
|
61
|
+
public issues: RefCollection<Issue>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@ClientModel({ name: "Issue", loadStrategy: LoadStrategy.Eager })
|
|
65
|
+
export class Issue extends BaseModel {
|
|
66
|
+
@Property() public title = "";
|
|
67
|
+
@Property() public priority = 0;
|
|
68
|
+
|
|
69
|
+
@Property({ indexed: true })
|
|
70
|
+
public teamId: string | null = null;
|
|
71
|
+
|
|
72
|
+
@Reference("Team", { onDelete: "cascade" })
|
|
73
|
+
public team: Team;
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`@Property` fields are persisted and observable. `@Reference`, `@ReferenceCollection`, `@OwnedCollection`, and `@BackReference` describe relationships; `Lazy*` variants load on demand. `loadStrategy` controls whether a model loads during bootstrap or only when requested. Pass an explicit `@ClientModel({ name })` — it's the registry key and the `useRecord(Model, …)` handle; without it the class name is used, which minifiers mangle in production.
|
|
78
|
+
|
|
79
|
+
See [agent-docs/01-models-and-decorators.md](agent-docs/01-models-and-decorators.md) for the full decorator reference.
|
|
80
|
+
|
|
81
|
+
## Schema-first with Zod
|
|
82
|
+
|
|
83
|
+
If your record shapes already live in Zod, use `entityFromZod(...)` as the schema authoring path. Zod owns the field types; `fields` overrides add zerodrift metadata such as foreign keys and indexes.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { z } from "zod";
|
|
87
|
+
import {
|
|
88
|
+
createStore,
|
|
89
|
+
defineSchema,
|
|
90
|
+
entityFromZod,
|
|
91
|
+
fields as s,
|
|
92
|
+
link,
|
|
93
|
+
LoadStrategy,
|
|
94
|
+
} from "zerodrift/schema";
|
|
95
|
+
|
|
96
|
+
const TeamRecord = z.object({
|
|
97
|
+
id: z.string(),
|
|
98
|
+
name: z.string(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const IssueRecord = z.object({
|
|
102
|
+
id: z.string(),
|
|
103
|
+
title: z.string().default(""),
|
|
104
|
+
priority: z.number().default(0),
|
|
105
|
+
teamId: z.string().nullable(),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export const schema = defineSchema({
|
|
109
|
+
entities: {
|
|
110
|
+
team: entityFromZod(TeamRecord, {
|
|
111
|
+
name: "Team",
|
|
112
|
+
loadStrategy: LoadStrategy.Eager,
|
|
113
|
+
}),
|
|
114
|
+
issue: entityFromZod(IssueRecord, {
|
|
115
|
+
name: "Issue",
|
|
116
|
+
loadStrategy: LoadStrategy.Eager,
|
|
117
|
+
fields: {
|
|
118
|
+
teamId: s.refId("team").nullable().indexed(),
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
links: {
|
|
123
|
+
issueTeam: link({
|
|
124
|
+
from: { entity: "issue", field: "teamId", as: "team" },
|
|
125
|
+
to: { entity: "team", many: "issues", lazy: true },
|
|
126
|
+
onDelete: "cascade",
|
|
127
|
+
}),
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const store = createStore({ schema, storeManager: sm });
|
|
132
|
+
|
|
133
|
+
const issue = await store.issue.get(issueId);
|
|
134
|
+
const teamIssues = await store.issue.getByIndex("teamId", teamId);
|
|
135
|
+
|
|
136
|
+
// create / patch commit at the current boundary — no separate save():
|
|
137
|
+
const newIssue = store.issue.create({ title: "Fix hydration", teamId });
|
|
138
|
+
store.issue.patch(issue.id, { priority: 1 });
|
|
139
|
+
|
|
140
|
+
// draft() is the staged path — mutate, then save() or discardUnsavedChanges():
|
|
141
|
+
const d = store.issue.draft({ title: "" });
|
|
142
|
+
d.title = "Write tests";
|
|
143
|
+
d.save();
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Both authoring paths compile to the same registry, so schema entities and decorator classes can coexist. See [agent-docs/11-schema-first-authoring.md](agent-docs/11-schema-first-authoring.md) for extensions, typed subscriptions, Zod override forms, and coexistence details.
|
|
147
|
+
|
|
148
|
+
## React quick start
|
|
149
|
+
|
|
150
|
+
Wrap your app in `<SyncProvider>` once. Import your model file as a side effect so decorators run before bootstrap.
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
import { SyncProvider } from "zerodrift/react";
|
|
154
|
+
import "./models";
|
|
155
|
+
|
|
156
|
+
export default function Providers({ children }) {
|
|
157
|
+
return (
|
|
158
|
+
<SyncProvider
|
|
159
|
+
config={{
|
|
160
|
+
workspaceId: "workspace-123",
|
|
161
|
+
transport: {
|
|
162
|
+
bootstrapFetcher: async (type, options) => {
|
|
163
|
+
const since = options?.sinceSyncId ?? 0;
|
|
164
|
+
const res = await fetch(`/api/bootstrap?type=${type}&since=${since}`);
|
|
165
|
+
return res.json();
|
|
166
|
+
},
|
|
167
|
+
transactionSender: async (batch) => {
|
|
168
|
+
const res = await fetch("/api/transactions", {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: { "Content-Type": "application/json" },
|
|
171
|
+
body: JSON.stringify(batch),
|
|
172
|
+
});
|
|
173
|
+
return res.json();
|
|
174
|
+
},
|
|
175
|
+
syncUrl: "/api/events",
|
|
176
|
+
},
|
|
177
|
+
}}
|
|
178
|
+
fallback={<div>Loading...</div>}
|
|
179
|
+
>
|
|
180
|
+
{children}
|
|
181
|
+
</SyncProvider>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Common reads and writes. The read hooks take a **handle** — a model class
|
|
187
|
+
(decorator path) or a `store.<entity>` namespace (schema path) — and infer
|
|
188
|
+
the record type from it. Every result has the same shape:
|
|
189
|
+
`{ data, isLoading, isLoaded, error, reload }`.
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
const { data: issues } = useRecords(Issue); // T[]
|
|
193
|
+
const { data: issue } = useRecord(Issue, issueId); // T | null
|
|
194
|
+
const { data: teamIssues } = useRecordsByIndex(Issue, "teamId", teamId);
|
|
195
|
+
const { data: comments } = useRelation(issue?.comments); // a relation
|
|
196
|
+
const { phase } = useBootstrapStatus();
|
|
197
|
+
|
|
198
|
+
issue.title = "New title";
|
|
199
|
+
issue.save();
|
|
200
|
+
|
|
201
|
+
const batch = useBatch();
|
|
202
|
+
batch(() => {
|
|
203
|
+
issue.priority = 1;
|
|
204
|
+
issue.save();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const { undo, redo, canUndo, canRedo } = useUndoRedo();
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Schema-authored stores pass the namespace as the handle — same hooks, typed
|
|
211
|
+
record + `.indexed()`-constrained index keys:
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
const { data: issue } = useRecord(store.issue, issueId);
|
|
215
|
+
const { data: teams } = useRecords(store.team);
|
|
216
|
+
const { data: teamIssues } = useRecordsByIndex(store.issue, "teamId", teamId);
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
See [agent-docs/08-react-integration.md](agent-docs/08-react-integration.md) for hook return shapes, context-driven id generation, Storybook seeding, and testing patterns.
|
|
220
|
+
|
|
221
|
+
## Headless usage
|
|
222
|
+
|
|
223
|
+
The same `StoreManager` runs without React or a browser. Use `MemoryAdapter` for in-process agents and tests, or implement `StorageAdapter` for durable storage.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import EventSource from "eventsource";
|
|
227
|
+
import { MemoryAdapter, StoreManager } from "zerodrift";
|
|
228
|
+
import "./models";
|
|
229
|
+
|
|
230
|
+
const sm = new StoreManager({
|
|
231
|
+
workspaceId: "agent-1",
|
|
232
|
+
transport: {
|
|
233
|
+
bootstrapFetcher,
|
|
234
|
+
transactionSender,
|
|
235
|
+
syncUrl: "http://localhost:8081/api/events",
|
|
236
|
+
sseClientFactory: (url) => new EventSource(url),
|
|
237
|
+
},
|
|
238
|
+
persistence: { storageAdapter: new MemoryAdapter() },
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await sm.bootstrap();
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
See [agent-docs/09-headless-and-agents.md](agent-docs/09-headless-and-agents.md) for reactivity outside React, shared vs isolated agent state, refresh APIs, and observability.
|
|
245
|
+
|
|
246
|
+
## Backend protocol
|
|
247
|
+
|
|
248
|
+
The client needs three endpoints:
|
|
249
|
+
|
|
250
|
+
| Endpoint | Purpose |
|
|
251
|
+
|---|---|
|
|
252
|
+
| `GET /api/bootstrap` | Fetch initial or partial model data. |
|
|
253
|
+
| `POST /api/transactions` | Accept queued client mutations. |
|
|
254
|
+
| `GET /api/events` | Stream delta packets over SSE. |
|
|
255
|
+
|
|
256
|
+
Bootstrap returns records grouped by model name:
|
|
257
|
+
|
|
258
|
+
```json
|
|
259
|
+
{
|
|
260
|
+
"lastSyncId": 5205,
|
|
261
|
+
"subscribedSyncGroups": ["workspace-abc"],
|
|
262
|
+
"models": {
|
|
263
|
+
"Issue": [{ "id": "...", "title": "...", "teamId": "..." }],
|
|
264
|
+
"Team": [{ "id": "...", "name": "..." }]
|
|
265
|
+
},
|
|
266
|
+
"backendDatabaseVersion": 1
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Transactions send inserts, updates, deletes, and archives:
|
|
271
|
+
|
|
272
|
+
```json
|
|
273
|
+
{
|
|
274
|
+
"transactions": [
|
|
275
|
+
{
|
|
276
|
+
"id": "uuid",
|
|
277
|
+
"action": "U",
|
|
278
|
+
"modelName": "Issue",
|
|
279
|
+
"modelId": "uuid",
|
|
280
|
+
"changes": {
|
|
281
|
+
"title": { "oldValue": "Old", "newValue": "New" }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
]
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
The response should include the latest committed sync id:
|
|
289
|
+
|
|
290
|
+
```json
|
|
291
|
+
{ "success": true, "lastSyncId": 5206 }
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
SSE messages are delta packets:
|
|
295
|
+
|
|
296
|
+
```json
|
|
297
|
+
{
|
|
298
|
+
"syncActions": [
|
|
299
|
+
{
|
|
300
|
+
"id": 5206,
|
|
301
|
+
"modelName": "Issue",
|
|
302
|
+
"modelId": "uuid",
|
|
303
|
+
"action": "U",
|
|
304
|
+
"data": { "title": "New title", "priority": 1 }
|
|
305
|
+
}
|
|
306
|
+
],
|
|
307
|
+
"addedSyncGroups": [],
|
|
308
|
+
"removedSyncGroups": []
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
The client reconnects with `?since=<lastSyncId>` so the server can replay missed events. See [agent-docs/07-realtime-sync.md](agent-docs/07-realtime-sync.md) for SSE details and [agent-docs/05-sync-groups.md](agent-docs/05-sync-groups.md) for scoped event delivery.
|
|
313
|
+
|
|
314
|
+
## Run the demo
|
|
315
|
+
|
|
316
|
+
A reference Go backend + Next.js app that exercises the full sync loop
|
|
317
|
+
locally live in [`examples/`](examples/). See [examples/README.md](examples/README.md)
|
|
318
|
+
for the one-command-each setup:
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
cd examples && make start-backend && make run-webapp
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Documentation
|
|
325
|
+
|
|
326
|
+
Deeper material lives in [agent-docs/](agent-docs/):
|
|
327
|
+
|
|
328
|
+
- [00 - Architecture overview](agent-docs/00-overview.md)
|
|
329
|
+
- [01 - Models and decorators](agent-docs/01-models-and-decorators.md)
|
|
330
|
+
- [02 - ObjectPool](agent-docs/02-object-pool.md)
|
|
331
|
+
- [03 - IndexedDB and persistence](agent-docs/03-indexeddb-and-persistence.md)
|
|
332
|
+
- [04 - Lazy loading](agent-docs/04-lazy-loading.md)
|
|
333
|
+
- [05 - Sync groups](agent-docs/05-sync-groups.md)
|
|
334
|
+
- [06 - Transactions and undo](agent-docs/06-transactions-and-undo.md)
|
|
335
|
+
- [07 - Realtime sync](agent-docs/07-realtime-sync.md)
|
|
336
|
+
- [08 - React integration](agent-docs/08-react-integration.md)
|
|
337
|
+
- [09 - Headless and agents](agent-docs/09-headless-and-agents.md)
|
|
338
|
+
- [10 - Inverse links and reactivity](agent-docs/10-inverse-links-and-reactivity.md)
|
|
339
|
+
- [11 - Schema-first authoring](agent-docs/11-schema-first-authoring.md)
|
|
340
|
+
|
|
341
|
+
## Project structure
|
|
342
|
+
|
|
343
|
+
```text
|
|
344
|
+
. # the publishable zerodrift package (src/, __tests__/)
|
|
345
|
+
|-- src/
|
|
346
|
+
|-- agent-docs/ # architecture and API notes
|
|
347
|
+
`-- examples/ # self-contained runnable demo (own Makefile + compose)
|
|
348
|
+
|-- webapp/ # Next.js demo app
|
|
349
|
+
|-- go/ # reference Go backend
|
|
350
|
+
|-- docker-compose.yml
|
|
351
|
+
`-- Makefile
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
## Tech stack
|
|
355
|
+
|
|
356
|
+
- **Client**: TypeScript, MobX, IndexedDB, EventSource (SSE)
|
|
357
|
+
- **Reference server**: Go, Gin, Bun ORM, Postgres (LISTEN/NOTIFY), pgx
|
|
358
|
+
- **Protocol**: append-only changelog, monotonic sync id, sync group filtering
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BaseModel — the base class for all sync engine models.
|
|
3
|
+
*
|
|
4
|
+
* Lifecycle:
|
|
5
|
+
* 1. `new Issue()` — raw construction, observability OFF
|
|
6
|
+
* 2. `issue.hydrate(data)` — populate flat values, recursive for embedded objects
|
|
7
|
+
* 3. `issue.makeModelObservable()` — create MobX boxes + RefCollections
|
|
8
|
+
* 4. `issue.title = "..."` — setter fires, tracked in pendingChanges
|
|
9
|
+
* 5. `issue.save()` — builds transaction, auto-commits to server
|
|
10
|
+
*
|
|
11
|
+
* makeModelObservable() creates the runtime relationship objects:
|
|
12
|
+
* - RefCollection for @ReferenceCollection properties
|
|
13
|
+
* - BackRef for @BackReference properties
|
|
14
|
+
* These are stored on __collections and __backRefs, read by the decorator getters.
|
|
15
|
+
*/
|
|
16
|
+
import { type PropertyChange, type IObjectPool, type IStoreManager } from "./types";
|
|
17
|
+
import { LazyCollectionBase, BackRef } from "./LazyCollection";
|
|
18
|
+
import { type IObservableValue } from "mobx";
|
|
19
|
+
export declare class BaseModel {
|
|
20
|
+
id: string;
|
|
21
|
+
__mobx: {
|
|
22
|
+
[key: string]: IObservableValue<unknown> | undefined;
|
|
23
|
+
};
|
|
24
|
+
__observabilityEnabled: boolean;
|
|
25
|
+
store: IObjectPool | null;
|
|
26
|
+
static get storeManager(): IStoreManager | null;
|
|
27
|
+
static set storeManager(sm: IStoreManager | null);
|
|
28
|
+
/** Runtime lazy collections, keyed by property name. */
|
|
29
|
+
__collections: Record<string, LazyCollectionBase>;
|
|
30
|
+
/** Runtime BackRefs, keyed by property name. Read by @BackReference getters. */
|
|
31
|
+
__backRefs: Record<string, BackRef>;
|
|
32
|
+
private pendingChanges;
|
|
33
|
+
/**
|
|
34
|
+
* Set a property value without triggering change tracking or pendingChanges.
|
|
35
|
+
* Used by revert paths so that rolling back an optimistic update doesn't
|
|
36
|
+
* leave the model in a dirty state.
|
|
37
|
+
*/
|
|
38
|
+
setQuiet(propName: string, value: unknown): void;
|
|
39
|
+
propertyChanged(propName: string, oldValue: unknown, newValue: unknown): void;
|
|
40
|
+
/** Forward an FK change to the pool so it can re-route inverse links. No-op
|
|
41
|
+
* before the model has entered a pool. */
|
|
42
|
+
private maintainParentLinks;
|
|
43
|
+
makeModelObservable(): void;
|
|
44
|
+
save(): Record<string, PropertyChange>;
|
|
45
|
+
get hasUnsavedChanges(): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Revert all unsaved property changes to their last-saved values.
|
|
48
|
+
* Mirror of save() — where save() commits forward, this rolls back.
|
|
49
|
+
*/
|
|
50
|
+
discardUnsavedChanges(): void;
|
|
51
|
+
/**
|
|
52
|
+
* React to property changes on this model without importing MobX.
|
|
53
|
+
* Use on models obtained from the pool — `objectPool.getById` / `objectPool.getAll`.
|
|
54
|
+
*
|
|
55
|
+
* @param selector - reads the property (or derived value) to observe
|
|
56
|
+
* @param callback - fires whenever the selector result changes; receives new and previous value
|
|
57
|
+
* @returns unwatch function — call it to stop observing
|
|
58
|
+
*/
|
|
59
|
+
watch<T>(selector: (model: this) => T, callback: (newValue: T, oldValue: T) => void): () => void;
|
|
60
|
+
/**
|
|
61
|
+
* Stage a bulk field assignment without committing. Changes land in
|
|
62
|
+
* `pendingChanges` and stay local until `save()` (or an enclosing
|
|
63
|
+
* `StoreManager.atomic()` / `store.batch()`) flushes them, or
|
|
64
|
+
* `discardUnsavedChanges()` rolls them back. This is the staging
|
|
65
|
+
* primitive behind `store.<entity>.draft(...)`.
|
|
66
|
+
*
|
|
67
|
+
* Only `@Property`, `@EphemeralProperty`, `@Reference` (ID fields), and
|
|
68
|
+
* `@ReferenceArray` fields are written — relationship objects and internals
|
|
69
|
+
* are ignored.
|
|
70
|
+
*/
|
|
71
|
+
assign(data: Record<string, unknown>): void;
|
|
72
|
+
/** @internal */
|
|
73
|
+
hydrate(data: Record<string, unknown>): void;
|
|
74
|
+
private hydrateNestedModel;
|
|
75
|
+
serialize(): Record<string, unknown>;
|
|
76
|
+
}
|