@zveltio/sdk 1.2.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/README.md +191 -0
- package/dist/client/Auth.d.ts +19 -0
- package/dist/client/Auth.d.ts.map +1 -0
- package/dist/client/Auth.js +45 -0
- package/dist/client/QueryBuilder.d.ts +17 -0
- package/dist/client/QueryBuilder.d.ts.map +1 -0
- package/dist/client/QueryBuilder.js +76 -0
- package/dist/client/RealtimeClient.d.ts +22 -0
- package/dist/client/RealtimeClient.d.ts.map +1 -0
- package/dist/client/RealtimeClient.js +96 -0
- package/dist/client/ZveltioClient.d.ts +20 -0
- package/dist/client/ZveltioClient.d.ts.map +1 -0
- package/dist/client/ZveltioClient.js +78 -0
- package/dist/client.d.ts +82 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +122 -0
- package/dist/core.d.ts +61 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +65 -0
- package/dist/crdt.d.ts +41 -0
- package/dist/crdt.d.ts.map +1 -0
- package/dist/crdt.js +87 -0
- package/dist/extension/index.d.ts +46 -0
- package/dist/extension/index.d.ts.map +1 -0
- package/dist/extension/index.js +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/local-store.d.ts +68 -0
- package/dist/local-store.d.ts.map +1 -0
- package/dist/local-store.js +263 -0
- package/dist/realtime.d.ts +14 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +82 -0
- package/dist/schema-watcher.d.ts +41 -0
- package/dist/schema-watcher.d.ts.map +1 -0
- package/dist/schema-watcher.js +182 -0
- package/dist/svelte.d.ts +38 -0
- package/dist/svelte.d.ts.map +1 -0
- package/dist/svelte.js +48 -0
- package/dist/sync-manager.d.ts +73 -0
- package/dist/sync-manager.d.ts.map +1 -0
- package/dist/sync-manager.js +293 -0
- package/dist/tests/local-store.test.d.ts +2 -0
- package/dist/tests/local-store.test.d.ts.map +1 -0
- package/dist/tests/local-store.test.js +76 -0
- package/dist/tests/setup.d.ts +2 -0
- package/dist/tests/setup.d.ts.map +1 -0
- package/dist/tests/setup.js +3 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +1 -0
- package/package.json +35 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { openDB } from 'idb';
|
|
2
|
+
import { LamportClock, mergeLWW, toDocument, fromDocument } from './crdt.js';
|
|
3
|
+
const DB_NAME = 'zveltio_local';
|
|
4
|
+
const DB_VERSION = 3; // v3: CRDT Lamport clock in meta store
|
|
5
|
+
export class LocalStore {
|
|
6
|
+
db = null;
|
|
7
|
+
clock = null;
|
|
8
|
+
async open() {
|
|
9
|
+
this.db = await openDB(DB_NAME, DB_VERSION, {
|
|
10
|
+
upgrade(db, oldVersion) {
|
|
11
|
+
if (oldVersion < 1) {
|
|
12
|
+
// Store for local data (mirror of server collections)
|
|
13
|
+
const store = db.createObjectStore('records', {
|
|
14
|
+
keyPath: ['collection', 'id'],
|
|
15
|
+
});
|
|
16
|
+
store.createIndex('by-collection', 'collection');
|
|
17
|
+
store.createIndex('by-sync-status', '_syncStatus');
|
|
18
|
+
store.createIndex('by-updated', '_updatedAt');
|
|
19
|
+
// Sync queue (pending operations)
|
|
20
|
+
const queue = db.createObjectStore('sync_queue', { keyPath: 'id' });
|
|
21
|
+
queue.createIndex('by-collection', 'collection');
|
|
22
|
+
queue.createIndex('by-created', 'createdAt');
|
|
23
|
+
// Metadata per collection (last sync timestamp, etc.)
|
|
24
|
+
db.createObjectStore('meta', { keyPath: 'key' });
|
|
25
|
+
}
|
|
26
|
+
if (oldVersion < 2) {
|
|
27
|
+
// Blobs saved offline — upload on first reconnect
|
|
28
|
+
db.createObjectStore('offline_blobs', { keyPath: 'id' });
|
|
29
|
+
}
|
|
30
|
+
// v3: no new stores — Lamport clock stored as key in existing 'meta' store
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
// Initialize CRDT Lamport clock (persisted in meta store)
|
|
34
|
+
let clientId = (await this.db.get('meta', 'crdt_client_id'))?.value;
|
|
35
|
+
if (!clientId) {
|
|
36
|
+
clientId = crypto.randomUUID();
|
|
37
|
+
await this.db.put('meta', { key: 'crdt_client_id', value: clientId });
|
|
38
|
+
}
|
|
39
|
+
const storedLamport = (await this.db.get('meta', 'crdt_lamport'))?.value ?? 0;
|
|
40
|
+
this.clock = new LamportClock(clientId, storedLamport);
|
|
41
|
+
}
|
|
42
|
+
async persistLamport() {
|
|
43
|
+
if (!this.db || !this.clock)
|
|
44
|
+
return;
|
|
45
|
+
await this.db.put('meta', { key: 'crdt_lamport', value: this.clock.current });
|
|
46
|
+
}
|
|
47
|
+
/** Write a local record and add to sync queue */
|
|
48
|
+
async put(collection, id, data) {
|
|
49
|
+
if (!this.db)
|
|
50
|
+
throw new Error('LocalStore not opened');
|
|
51
|
+
const existing = (await this.db.get('records', [collection, id]));
|
|
52
|
+
const lamport = this.clock ? this.clock.tick() : Date.now();
|
|
53
|
+
const clientId = this.clock?.id ?? 'unknown';
|
|
54
|
+
const crdtDoc = toDocument(data, lamport, clientId);
|
|
55
|
+
const record = {
|
|
56
|
+
id,
|
|
57
|
+
collection,
|
|
58
|
+
data,
|
|
59
|
+
_localVersion: (existing?._localVersion || 0) + 1,
|
|
60
|
+
_serverVersion: existing?._serverVersion || 0,
|
|
61
|
+
_syncStatus: 'pending',
|
|
62
|
+
_updatedAt: Date.now(),
|
|
63
|
+
_crdtDoc: crdtDoc,
|
|
64
|
+
};
|
|
65
|
+
const tx = this.db.transaction(['records', 'sync_queue'], 'readwrite');
|
|
66
|
+
// 1. Write local record
|
|
67
|
+
await tx.objectStore('records').put(record);
|
|
68
|
+
// 2. Add to sync queue
|
|
69
|
+
const queueItem = {
|
|
70
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
71
|
+
collection,
|
|
72
|
+
recordId: id,
|
|
73
|
+
operation: existing ? 'update' : 'create',
|
|
74
|
+
payload: data,
|
|
75
|
+
attempts: 0,
|
|
76
|
+
createdAt: Date.now(),
|
|
77
|
+
};
|
|
78
|
+
await tx.objectStore('sync_queue').add(queueItem);
|
|
79
|
+
await tx.done;
|
|
80
|
+
await this.persistLamport();
|
|
81
|
+
return record;
|
|
82
|
+
}
|
|
83
|
+
/** Read a local record (instant, no network) */
|
|
84
|
+
async get(collection, id) {
|
|
85
|
+
if (!this.db)
|
|
86
|
+
throw new Error('LocalStore not opened');
|
|
87
|
+
const record = (await this.db.get('records', [collection, id]));
|
|
88
|
+
if (record?._deletedAt)
|
|
89
|
+
return undefined; // Soft-deleted
|
|
90
|
+
return record;
|
|
91
|
+
}
|
|
92
|
+
/** List records from a collection (local) */
|
|
93
|
+
async list(collection) {
|
|
94
|
+
if (!this.db)
|
|
95
|
+
throw new Error('LocalStore not opened');
|
|
96
|
+
const all = (await this.db.getAllFromIndex('records', 'by-collection', collection));
|
|
97
|
+
return all.filter((r) => !r._deletedAt);
|
|
98
|
+
}
|
|
99
|
+
/** Soft delete local and add to sync queue */
|
|
100
|
+
async delete(collection, id) {
|
|
101
|
+
if (!this.db)
|
|
102
|
+
throw new Error('LocalStore not opened');
|
|
103
|
+
const tx = this.db.transaction(['records', 'sync_queue'], 'readwrite');
|
|
104
|
+
const existing = (await tx.objectStore('records').get([collection, id]));
|
|
105
|
+
if (existing) {
|
|
106
|
+
existing._deletedAt = Date.now();
|
|
107
|
+
existing._syncStatus = 'pending';
|
|
108
|
+
await tx.objectStore('records').put(existing);
|
|
109
|
+
}
|
|
110
|
+
const queueItem = {
|
|
111
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
112
|
+
collection,
|
|
113
|
+
recordId: id,
|
|
114
|
+
operation: 'delete',
|
|
115
|
+
payload: {},
|
|
116
|
+
attempts: 0,
|
|
117
|
+
createdAt: Date.now(),
|
|
118
|
+
};
|
|
119
|
+
await tx.objectStore('sync_queue').add(queueItem);
|
|
120
|
+
await tx.done;
|
|
121
|
+
}
|
|
122
|
+
/** Returns all pending operations from sync queue */
|
|
123
|
+
async getPendingOps() {
|
|
124
|
+
if (!this.db)
|
|
125
|
+
throw new Error('LocalStore not opened');
|
|
126
|
+
return this.db.getAllFromIndex('sync_queue', 'by-created');
|
|
127
|
+
}
|
|
128
|
+
/** Mark an operation as completed (remove from queue, update record status) */
|
|
129
|
+
async markSynced(queueItemId, collection, recordId, serverVersion) {
|
|
130
|
+
if (!this.db)
|
|
131
|
+
throw new Error('LocalStore not opened');
|
|
132
|
+
const tx = this.db.transaction(['records', 'sync_queue'], 'readwrite');
|
|
133
|
+
// Remove from queue
|
|
134
|
+
await tx.objectStore('sync_queue').delete(queueItemId);
|
|
135
|
+
// Update record status
|
|
136
|
+
const record = (await tx
|
|
137
|
+
.objectStore('records')
|
|
138
|
+
.get([collection, recordId]));
|
|
139
|
+
if (record) {
|
|
140
|
+
record._serverVersion = serverVersion;
|
|
141
|
+
record._syncStatus = 'synced';
|
|
142
|
+
await tx.objectStore('records').put(record);
|
|
143
|
+
}
|
|
144
|
+
await tx.done;
|
|
145
|
+
}
|
|
146
|
+
/** Mark an operation as failed (increment attempts, save error) */
|
|
147
|
+
async markFailed(queueItemId, error) {
|
|
148
|
+
if (!this.db)
|
|
149
|
+
throw new Error('LocalStore not opened');
|
|
150
|
+
const item = (await this.db.get('sync_queue', queueItemId));
|
|
151
|
+
if (item) {
|
|
152
|
+
item.attempts += 1;
|
|
153
|
+
item.lastAttemptAt = Date.now();
|
|
154
|
+
item.error = error;
|
|
155
|
+
await this.db.put('sync_queue', item);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Apply data from server (via WebSocket or pull) */
|
|
159
|
+
async applyServerUpdate(collection, id, data, serverVersion) {
|
|
160
|
+
if (!this.db)
|
|
161
|
+
throw new Error('LocalStore not opened');
|
|
162
|
+
const existing = (await this.db.get('records', [collection, id]));
|
|
163
|
+
// Conflict detection: local has changes not yet confirmed by server
|
|
164
|
+
if (existing && existing._localVersion > existing._serverVersion) {
|
|
165
|
+
// CRDT field-level merge if both sides have CRDT docs
|
|
166
|
+
if (existing._crdtDoc && data.__crdt) {
|
|
167
|
+
const remoteCrdtDoc = data.__crdt;
|
|
168
|
+
const merged = mergeLWW(existing._crdtDoc, remoteCrdtDoc);
|
|
169
|
+
const mergedData = fromDocument(merged);
|
|
170
|
+
if (this.clock) {
|
|
171
|
+
this.clock.update(Math.max(...Object.values(merged).map((f) => f.lamport)));
|
|
172
|
+
await this.persistLamport();
|
|
173
|
+
}
|
|
174
|
+
existing.data = mergedData;
|
|
175
|
+
existing._crdtDoc = merged;
|
|
176
|
+
existing._serverVersion = serverVersion;
|
|
177
|
+
existing._syncStatus = 'synced'; // CRDT merge resolved without conflict
|
|
178
|
+
await this.db.put('records', existing);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// No CRDT doc — mark as conflict for manual resolution
|
|
182
|
+
existing._conflictData = data;
|
|
183
|
+
existing._serverVersion = serverVersion;
|
|
184
|
+
existing._syncStatus = 'conflict';
|
|
185
|
+
await this.db.put('records', existing);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const record = {
|
|
189
|
+
id,
|
|
190
|
+
collection,
|
|
191
|
+
data,
|
|
192
|
+
_localVersion: existing?._localVersion || 0,
|
|
193
|
+
_serverVersion: serverVersion,
|
|
194
|
+
_syncStatus: 'synced',
|
|
195
|
+
_updatedAt: Date.now(),
|
|
196
|
+
};
|
|
197
|
+
await this.db.put('records', record);
|
|
198
|
+
}
|
|
199
|
+
/** Get records with conflicts for UI resolution */
|
|
200
|
+
async getConflicts(collection) {
|
|
201
|
+
if (!this.db)
|
|
202
|
+
throw new Error('LocalStore not opened');
|
|
203
|
+
const all = (await this.db.getAllFromIndex('records', 'by-sync-status', 'conflict'));
|
|
204
|
+
if (collection)
|
|
205
|
+
return all.filter((r) => r.collection === collection);
|
|
206
|
+
return all;
|
|
207
|
+
}
|
|
208
|
+
/** Resolve a conflict (user decides which version wins) */
|
|
209
|
+
async resolveConflict(collection, id, resolvedData) {
|
|
210
|
+
if (!this.db)
|
|
211
|
+
throw new Error('LocalStore not opened');
|
|
212
|
+
const record = (await this.db.get('records', [collection, id]));
|
|
213
|
+
if (record) {
|
|
214
|
+
record.data = resolvedData;
|
|
215
|
+
record._syncStatus = 'pending'; // Re-sync with server
|
|
216
|
+
record._localVersion += 1;
|
|
217
|
+
await this.db.put('records', record);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Save an offline blob — returns a temporary ID 'local_blob_<UUID>' */
|
|
221
|
+
async saveBlob(blob, collection, recordId, field) {
|
|
222
|
+
if (!this.db)
|
|
223
|
+
throw new Error('LocalStore not opened');
|
|
224
|
+
const id = `local_blob_${crypto.randomUUID()}`;
|
|
225
|
+
const item = {
|
|
226
|
+
id,
|
|
227
|
+
blob,
|
|
228
|
+
collection,
|
|
229
|
+
recordId,
|
|
230
|
+
field,
|
|
231
|
+
createdAt: Date.now(),
|
|
232
|
+
};
|
|
233
|
+
await this.db.put('offline_blobs', item);
|
|
234
|
+
return id;
|
|
235
|
+
}
|
|
236
|
+
/** Returns all pending offline blobs */
|
|
237
|
+
async getPendingBlobs() {
|
|
238
|
+
if (!this.db)
|
|
239
|
+
throw new Error('LocalStore not opened');
|
|
240
|
+
return this.db.getAll('offline_blobs');
|
|
241
|
+
}
|
|
242
|
+
/** Delete an offline blob after successful upload */
|
|
243
|
+
async deleteBlob(id) {
|
|
244
|
+
if (!this.db)
|
|
245
|
+
throw new Error('LocalStore not opened');
|
|
246
|
+
await this.db.delete('offline_blobs', id);
|
|
247
|
+
}
|
|
248
|
+
/** Clear all local data */
|
|
249
|
+
async clear() {
|
|
250
|
+
if (!this.db)
|
|
251
|
+
throw new Error('LocalStore not opened');
|
|
252
|
+
const tx = this.db.transaction(['records', 'sync_queue', 'meta', 'offline_blobs'], 'readwrite');
|
|
253
|
+
await tx.objectStore('records').clear();
|
|
254
|
+
await tx.objectStore('sync_queue').clear();
|
|
255
|
+
await tx.objectStore('meta').clear();
|
|
256
|
+
await tx.objectStore('offline_blobs').clear();
|
|
257
|
+
await tx.done;
|
|
258
|
+
}
|
|
259
|
+
async close() {
|
|
260
|
+
this.db?.close();
|
|
261
|
+
this.db = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare class ZveltioRealtime {
|
|
2
|
+
private ws;
|
|
3
|
+
private listeners;
|
|
4
|
+
private baseUrl;
|
|
5
|
+
private reconnectAttempts;
|
|
6
|
+
private maxReconnectAttempts;
|
|
7
|
+
private reconnectTimer;
|
|
8
|
+
constructor(baseUrl: string);
|
|
9
|
+
connect(): void;
|
|
10
|
+
private attemptReconnect;
|
|
11
|
+
subscribe(collection: string, callback: (data: any) => void): () => void;
|
|
12
|
+
disconnect(): void;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=realtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"realtime.d.ts","sourceRoot":"","sources":["../src/realtime.ts"],"names":[],"mappings":"AAAA,qBAAa,eAAe;IAC1B,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,SAAS,CAAoD;IACrE,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,oBAAoB,CAAM;IAClC,OAAO,CAAC,cAAc,CAA8C;gBAExD,OAAO,EAAE,MAAM;IAK3B,OAAO,IAAI,IAAI;IAmCf,OAAO,CAAC,gBAAgB;IAQxB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,GAAG,MAAM,IAAI;IAqBxE,UAAU,IAAI,IAAI;CAQnB"}
|
package/dist/realtime.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export class ZveltioRealtime {
|
|
2
|
+
ws = null;
|
|
3
|
+
listeners = new Map();
|
|
4
|
+
baseUrl;
|
|
5
|
+
reconnectAttempts = 0;
|
|
6
|
+
maxReconnectAttempts = 10;
|
|
7
|
+
reconnectTimer = null;
|
|
8
|
+
constructor(baseUrl) {
|
|
9
|
+
// Convert http(s) to ws(s)
|
|
10
|
+
this.baseUrl = baseUrl.replace(/^http/, 'ws');
|
|
11
|
+
}
|
|
12
|
+
connect() {
|
|
13
|
+
const wsUrl = `${this.baseUrl}/api/ws`;
|
|
14
|
+
this.ws = new WebSocket(wsUrl);
|
|
15
|
+
this.ws.onopen = () => {
|
|
16
|
+
this.reconnectAttempts = 0;
|
|
17
|
+
// Re-subscribe to all existing collections using the server's expected protocol
|
|
18
|
+
const collections = [...this.listeners.keys()];
|
|
19
|
+
if (collections.length > 0) {
|
|
20
|
+
this.ws?.send(JSON.stringify({ type: 'subscribe', collections }));
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
this.ws.onmessage = (event) => {
|
|
24
|
+
try {
|
|
25
|
+
const msg = JSON.parse(event.data);
|
|
26
|
+
const collection = msg.collection;
|
|
27
|
+
const subs = this.listeners.get(collection);
|
|
28
|
+
if (subs) {
|
|
29
|
+
subs.forEach((fn) => {
|
|
30
|
+
try {
|
|
31
|
+
fn(msg);
|
|
32
|
+
}
|
|
33
|
+
catch { /* ignore callback errors */ }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch { /* invalid JSON — ignore */ }
|
|
38
|
+
};
|
|
39
|
+
this.ws.onclose = () => {
|
|
40
|
+
this.attemptReconnect();
|
|
41
|
+
};
|
|
42
|
+
this.ws.onerror = () => {
|
|
43
|
+
this.ws?.close();
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
attemptReconnect() {
|
|
47
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts)
|
|
48
|
+
return;
|
|
49
|
+
// Exponential backoff: 1s, 2s, 4s, 8s, ...
|
|
50
|
+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30_000);
|
|
51
|
+
this.reconnectAttempts++;
|
|
52
|
+
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
|
53
|
+
}
|
|
54
|
+
subscribe(collection, callback) {
|
|
55
|
+
if (!this.listeners.has(collection))
|
|
56
|
+
this.listeners.set(collection, new Set());
|
|
57
|
+
this.listeners.get(collection).add(callback);
|
|
58
|
+
// Send subscribe to server using correct protocol { type: 'subscribe', collections: [...] }
|
|
59
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
60
|
+
this.ws.send(JSON.stringify({ type: 'subscribe', collections: [collection] }));
|
|
61
|
+
}
|
|
62
|
+
// Return unsubscribe function
|
|
63
|
+
return () => {
|
|
64
|
+
this.listeners.get(collection)?.delete(callback);
|
|
65
|
+
if (this.listeners.get(collection)?.size === 0) {
|
|
66
|
+
this.listeners.delete(collection);
|
|
67
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
68
|
+
this.ws.send(JSON.stringify({ type: 'unsubscribe', collections: [collection] }));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
disconnect() {
|
|
74
|
+
if (this.reconnectTimer)
|
|
75
|
+
clearTimeout(this.reconnectTimer);
|
|
76
|
+
this.reconnectTimer = null;
|
|
77
|
+
this.reconnectAttempts = this.maxReconnectAttempts; // Prevent auto-reconnect
|
|
78
|
+
this.ws?.close();
|
|
79
|
+
this.ws = null;
|
|
80
|
+
this.listeners.clear();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema Watcher (P6 — End-to-End Type Safety)
|
|
3
|
+
*
|
|
4
|
+
* Watches the Zveltio engine for DDL schema changes via WebSocket and
|
|
5
|
+
* regenerates a TypeScript types file whenever a collection is created,
|
|
6
|
+
* updated, or deleted.
|
|
7
|
+
*
|
|
8
|
+
* Usage (in `zveltio dev --watch`):
|
|
9
|
+
* import { watchSchema } from '@zveltio/sdk/schema-watcher';
|
|
10
|
+
* await watchSchema('http://localhost:3000', './src/zveltio-types.d.ts');
|
|
11
|
+
*/
|
|
12
|
+
export interface CollectionField {
|
|
13
|
+
name: string;
|
|
14
|
+
type: string;
|
|
15
|
+
required?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface CollectionSchema {
|
|
18
|
+
name: string;
|
|
19
|
+
display_name?: string;
|
|
20
|
+
fields: CollectionField[];
|
|
21
|
+
}
|
|
22
|
+
export interface WatchSchemaOptions {
|
|
23
|
+
/** API token for the engine (admin key) */
|
|
24
|
+
apiKey?: string;
|
|
25
|
+
/** Reconnect delay in ms (default: 3000) */
|
|
26
|
+
reconnectDelay?: number;
|
|
27
|
+
/** Called after each successful type regeneration */
|
|
28
|
+
onUpdate?: (collections: CollectionSchema[]) => void;
|
|
29
|
+
/** Called on connection errors */
|
|
30
|
+
onError?: (err: Error) => void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Start watching the engine's WebSocket for schema changes and regenerate
|
|
34
|
+
* TypeScript types at `outputPath` whenever the schema changes.
|
|
35
|
+
*
|
|
36
|
+
* Returns a cleanup function that closes the WebSocket.
|
|
37
|
+
*/
|
|
38
|
+
export declare function watchSchema(engineUrl: string, outputPath: string, options?: WatchSchemaOptions): Promise<() => void>;
|
|
39
|
+
/** One-shot type generation without watching. */
|
|
40
|
+
export declare function generateTypes(engineUrl: string, outputPath: string, apiKey?: string): Promise<void>;
|
|
41
|
+
//# sourceMappingURL=schema-watcher.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema-watcher.d.ts","sourceRoot":"","sources":["../src/schema-watcher.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,kBAAkB;IACjC,2CAA2C;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4CAA4C;IAC5C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,CAAC,WAAW,EAAE,gBAAgB,EAAE,KAAK,IAAI,CAAC;IACrD,kCAAkC;IAClC,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAC;CAChC;AA4GD;;;;;GAKG;AACH,wBAAsB,WAAW,CAC/B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,MAAM,IAAI,CAAC,CAyErB;AAED,iDAAiD;AACjD,wBAAsB,aAAa,CACjC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,IAAI,CAAC,CAKf"}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema Watcher (P6 — End-to-End Type Safety)
|
|
3
|
+
*
|
|
4
|
+
* Watches the Zveltio engine for DDL schema changes via WebSocket and
|
|
5
|
+
* regenerates a TypeScript types file whenever a collection is created,
|
|
6
|
+
* updated, or deleted.
|
|
7
|
+
*
|
|
8
|
+
* Usage (in `zveltio dev --watch`):
|
|
9
|
+
* import { watchSchema } from '@zveltio/sdk/schema-watcher';
|
|
10
|
+
* await watchSchema('http://localhost:3000', './src/zveltio-types.d.ts');
|
|
11
|
+
*/
|
|
12
|
+
const TYPE_MAP = {
|
|
13
|
+
text: 'string',
|
|
14
|
+
textarea: 'string',
|
|
15
|
+
richtext: 'string',
|
|
16
|
+
email: 'string',
|
|
17
|
+
url: 'string',
|
|
18
|
+
slug: 'string',
|
|
19
|
+
color: 'string',
|
|
20
|
+
phone: 'string',
|
|
21
|
+
password: 'string',
|
|
22
|
+
uuid: 'string',
|
|
23
|
+
enum: 'string',
|
|
24
|
+
tags: 'string[]',
|
|
25
|
+
integer: 'number',
|
|
26
|
+
float: 'number',
|
|
27
|
+
number: 'number',
|
|
28
|
+
boolean: 'boolean',
|
|
29
|
+
date: 'string',
|
|
30
|
+
datetime: 'string',
|
|
31
|
+
json: 'Record<string, unknown>',
|
|
32
|
+
file: 'string',
|
|
33
|
+
image: 'string',
|
|
34
|
+
location: '{ lat: number; lng: number }',
|
|
35
|
+
geometry: 'GeoJSON.Geometry',
|
|
36
|
+
vector: 'number[]',
|
|
37
|
+
m2o: 'string',
|
|
38
|
+
o2m: 'string[]',
|
|
39
|
+
m2m: 'string[]',
|
|
40
|
+
};
|
|
41
|
+
function fieldToType(field) {
|
|
42
|
+
return TYPE_MAP[field.type] ?? 'unknown';
|
|
43
|
+
}
|
|
44
|
+
function collectionToInterface(col) {
|
|
45
|
+
const lines = [`export interface ${toPascalCase(col.name)} {`];
|
|
46
|
+
lines.push(` id: string;`);
|
|
47
|
+
lines.push(` created_at: string;`);
|
|
48
|
+
lines.push(` updated_at: string;`);
|
|
49
|
+
for (const field of col.fields) {
|
|
50
|
+
const tsType = fieldToType(field);
|
|
51
|
+
const optional = field.required ? '' : '?';
|
|
52
|
+
lines.push(` ${field.name}${optional}: ${tsType};`);
|
|
53
|
+
}
|
|
54
|
+
lines.push(`}`);
|
|
55
|
+
return lines.join('\n');
|
|
56
|
+
}
|
|
57
|
+
function generateTypesFile(collections) {
|
|
58
|
+
const header = `// Auto-generated by @zveltio/sdk schema-watcher
|
|
59
|
+
// Do not edit manually — changes will be overwritten on schema updates.
|
|
60
|
+
// Generated at: ${new Date().toISOString()}
|
|
61
|
+
|
|
62
|
+
`;
|
|
63
|
+
const interfaces = collections.map(collectionToInterface).join('\n\n');
|
|
64
|
+
const collectionNames = collections.map((c) => `'${c.name}'`).join(' | ');
|
|
65
|
+
const collectionMap = collections
|
|
66
|
+
.map((c) => ` ${c.name}: ${toPascalCase(c.name)};`)
|
|
67
|
+
.join('\n');
|
|
68
|
+
const clientType = `
|
|
69
|
+
export type CollectionName = ${collectionNames || 'never'};
|
|
70
|
+
|
|
71
|
+
export interface CollectionTypeMap {
|
|
72
|
+
${collectionMap}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Pass this to createZveltioClient<ZveltioSchema>() for full type inference */
|
|
76
|
+
export type ZveltioSchema = CollectionTypeMap;
|
|
77
|
+
`;
|
|
78
|
+
return header + interfaces + clientType;
|
|
79
|
+
}
|
|
80
|
+
function toPascalCase(str) {
|
|
81
|
+
return str
|
|
82
|
+
.split('_')
|
|
83
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
84
|
+
.join('');
|
|
85
|
+
}
|
|
86
|
+
async function fetchCollections(engineUrl, apiKey) {
|
|
87
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
88
|
+
if (apiKey)
|
|
89
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
90
|
+
const res = await fetch(`${engineUrl}/api/collections`, { headers });
|
|
91
|
+
if (!res.ok)
|
|
92
|
+
throw new Error(`Failed to fetch collections: ${res.status}`);
|
|
93
|
+
const data = await res.json();
|
|
94
|
+
return data.collections || [];
|
|
95
|
+
}
|
|
96
|
+
async function writeTypes(outputPath, content) {
|
|
97
|
+
// Use Bun.file in Bun runtime, fall back to dynamic import of fs in Node
|
|
98
|
+
if (typeof Bun !== 'undefined') {
|
|
99
|
+
await Bun.write(outputPath, content);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const { writeFile } = await import('node:fs/promises');
|
|
103
|
+
await writeFile(outputPath, content, 'utf8');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Start watching the engine's WebSocket for schema changes and regenerate
|
|
108
|
+
* TypeScript types at `outputPath` whenever the schema changes.
|
|
109
|
+
*
|
|
110
|
+
* Returns a cleanup function that closes the WebSocket.
|
|
111
|
+
*/
|
|
112
|
+
export async function watchSchema(engineUrl, outputPath, options = {}) {
|
|
113
|
+
const { apiKey, reconnectDelay = 3000, onUpdate, onError } = options;
|
|
114
|
+
// Generate initial types on startup
|
|
115
|
+
try {
|
|
116
|
+
const collections = await fetchCollections(engineUrl, apiKey);
|
|
117
|
+
const content = generateTypesFile(collections);
|
|
118
|
+
await writeTypes(outputPath, content);
|
|
119
|
+
console.log(`[zveltio] ✓ Types generated at ${outputPath} (${collections.length} collections)`);
|
|
120
|
+
onUpdate?.(collections);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
124
|
+
console.warn(`[zveltio] Could not generate initial types:`, e.message);
|
|
125
|
+
onError?.(e);
|
|
126
|
+
}
|
|
127
|
+
const wsUrl = engineUrl.replace(/^http/, 'ws') + '/api/ws';
|
|
128
|
+
let ws = null;
|
|
129
|
+
let stopped = false;
|
|
130
|
+
function connect() {
|
|
131
|
+
if (stopped)
|
|
132
|
+
return;
|
|
133
|
+
ws = new WebSocket(wsUrl);
|
|
134
|
+
ws.onopen = () => {
|
|
135
|
+
console.log('[zveltio] Schema watcher connected.');
|
|
136
|
+
// Subscribe to all schema change events
|
|
137
|
+
ws.send(JSON.stringify({ type: 'subscribe', channel: 'schema' }));
|
|
138
|
+
};
|
|
139
|
+
ws.onmessage = async (event) => {
|
|
140
|
+
try {
|
|
141
|
+
const msg = JSON.parse(event.data);
|
|
142
|
+
const isSchemaChange = msg.event === 'schema:changed' ||
|
|
143
|
+
msg.type === 'schema:changed' ||
|
|
144
|
+
['create_collection', 'drop_collection', 'add_field', 'drop_field'].includes(msg.event);
|
|
145
|
+
if (!isSchemaChange)
|
|
146
|
+
return;
|
|
147
|
+
const collections = await fetchCollections(engineUrl, apiKey);
|
|
148
|
+
const content = generateTypesFile(collections);
|
|
149
|
+
await writeTypes(outputPath, content);
|
|
150
|
+
const changed = msg.collection ?? msg.data?.collection ?? '?';
|
|
151
|
+
console.log(`[zveltio] ✓ Types updated — ${changed} changed (${collections.length} total)`);
|
|
152
|
+
onUpdate?.(collections);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
// Non-fatal: keep watching
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
ws.onclose = () => {
|
|
159
|
+
if (!stopped) {
|
|
160
|
+
console.log(`[zveltio] Schema watcher disconnected. Reconnecting in ${reconnectDelay}ms…`);
|
|
161
|
+
setTimeout(connect, reconnectDelay);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
ws.onerror = (event) => {
|
|
165
|
+
const err = new Error('WebSocket error');
|
|
166
|
+
onError?.(err);
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
connect();
|
|
170
|
+
return () => {
|
|
171
|
+
stopped = true;
|
|
172
|
+
ws?.close();
|
|
173
|
+
console.log('[zveltio] Schema watcher stopped.');
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** One-shot type generation without watching. */
|
|
177
|
+
export async function generateTypes(engineUrl, outputPath, apiKey) {
|
|
178
|
+
const collections = await fetchCollections(engineUrl, apiKey);
|
|
179
|
+
const content = generateTypesFile(collections);
|
|
180
|
+
await writeTypes(outputPath, content);
|
|
181
|
+
console.log(`[zveltio] ✓ Types generated at ${outputPath} (${collections.length} collections)`);
|
|
182
|
+
}
|
package/dist/svelte.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte 5 runes integration with SyncManager.
|
|
3
|
+
*
|
|
4
|
+
* Usage in Svelte 5 components:
|
|
5
|
+
*
|
|
6
|
+
* ```svelte
|
|
7
|
+
* <script lang="ts">
|
|
8
|
+
* import { SyncManager } from '@zveltio/sdk';
|
|
9
|
+
* import { useSyncCollection, useSyncStatus } from '@zveltio/sdk/svelte';
|
|
10
|
+
*
|
|
11
|
+
* let todos = $state<any[]>([]);
|
|
12
|
+
* const unsub = useSyncCollection(sync, 'todos', (records) => { todos = records; });
|
|
13
|
+
*
|
|
14
|
+
* let status = $state({ pending: 0, conflicts: 0, isOnline: true });
|
|
15
|
+
* const unsubStatus = useSyncStatus(sync, (s) => { status = s; });
|
|
16
|
+
*
|
|
17
|
+
* onDestroy(() => { unsub(); unsubStatus(); });
|
|
18
|
+
* </script>
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import type { SyncManager } from './sync-manager.js';
|
|
22
|
+
/**
|
|
23
|
+
* Subscribe to a collection via SyncManager.
|
|
24
|
+
* Calls `setter` immediately with current state and on each update.
|
|
25
|
+
* Returns the unsubscribe function.
|
|
26
|
+
*/
|
|
27
|
+
export declare function useSyncCollection(sync: SyncManager, collection: string, setter: (records: any[]) => void): () => void;
|
|
28
|
+
/**
|
|
29
|
+
* Subscribe to sync status (pending, conflicts, isOnline).
|
|
30
|
+
* Calls `setter` immediately and on each sync cycle.
|
|
31
|
+
* Returns the cleanup function.
|
|
32
|
+
*/
|
|
33
|
+
export declare function useSyncStatus(sync: SyncManager, setter: (status: {
|
|
34
|
+
pending: number;
|
|
35
|
+
conflicts: number;
|
|
36
|
+
isOnline: boolean;
|
|
37
|
+
}) => void, intervalMs?: number): () => void;
|
|
38
|
+
//# sourceMappingURL=svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"svelte.d.ts","sourceRoot":"","sources":["../src/svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,WAAW,EACjB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,KAAK,IAAI,GAC/B,MAAM,IAAI,CAGZ;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,CAAC,MAAM,EAAE;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;CACnB,KAAK,IAAI,EACV,UAAU,SAAO,GAChB,MAAM,IAAI,CAeZ"}
|
package/dist/svelte.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte 5 runes integration with SyncManager.
|
|
3
|
+
*
|
|
4
|
+
* Usage in Svelte 5 components:
|
|
5
|
+
*
|
|
6
|
+
* ```svelte
|
|
7
|
+
* <script lang="ts">
|
|
8
|
+
* import { SyncManager } from '@zveltio/sdk';
|
|
9
|
+
* import { useSyncCollection, useSyncStatus } from '@zveltio/sdk/svelte';
|
|
10
|
+
*
|
|
11
|
+
* let todos = $state<any[]>([]);
|
|
12
|
+
* const unsub = useSyncCollection(sync, 'todos', (records) => { todos = records; });
|
|
13
|
+
*
|
|
14
|
+
* let status = $state({ pending: 0, conflicts: 0, isOnline: true });
|
|
15
|
+
* const unsubStatus = useSyncStatus(sync, (s) => { status = s; });
|
|
16
|
+
*
|
|
17
|
+
* onDestroy(() => { unsub(); unsubStatus(); });
|
|
18
|
+
* </script>
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Subscribe to a collection via SyncManager.
|
|
23
|
+
* Calls `setter` immediately with current state and on each update.
|
|
24
|
+
* Returns the unsubscribe function.
|
|
25
|
+
*/
|
|
26
|
+
export function useSyncCollection(sync, collection, setter) {
|
|
27
|
+
const col = sync.collection(collection);
|
|
28
|
+
return col.subscribe(setter);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Subscribe to sync status (pending, conflicts, isOnline).
|
|
32
|
+
* Calls `setter` immediately and on each sync cycle.
|
|
33
|
+
* Returns the cleanup function.
|
|
34
|
+
*/
|
|
35
|
+
export function useSyncStatus(sync, setter, intervalMs = 2000) {
|
|
36
|
+
// Emit immediately
|
|
37
|
+
sync.getStatus().then(setter);
|
|
38
|
+
// Periodic poll (SyncManager doesn't have event system for status)
|
|
39
|
+
const timer = setInterval(() => {
|
|
40
|
+
sync
|
|
41
|
+
.getStatus()
|
|
42
|
+
.then(setter)
|
|
43
|
+
.catch(() => {
|
|
44
|
+
/* ignore */
|
|
45
|
+
});
|
|
46
|
+
}, intervalMs);
|
|
47
|
+
return () => clearInterval(timer);
|
|
48
|
+
}
|