@zveltio/sdk 3.0.0-beta.5 → 3.0.0-beta.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/Auth.d.ts.map +1 -1
- package/dist/client/Auth.js +3 -0
- package/dist/client/QueryBuilder.d.ts.map +1 -1
- package/dist/client/QueryBuilder.js +4 -0
- package/dist/client/ZveltioClient.d.ts.map +1 -1
- package/dist/client/ZveltioClient.js +5 -0
- package/dist/client.d.ts +4 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +30 -13
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +21 -3
- package/dist/crdt.d.ts.map +1 -1
- package/dist/crdt.js +11 -2
- package/dist/ddl/index.d.ts.map +1 -1
- package/dist/errors.d.ts +58 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +124 -0
- package/dist/extension/index.d.ts +419 -19
- package/dist/extension/index.d.ts.map +1 -1
- package/dist/extension/index.js +3 -0
- package/dist/extension/jsonb.d.ts +62 -0
- package/dist/extension/jsonb.d.ts.map +1 -0
- package/dist/extension/jsonb.js +64 -0
- package/dist/extension/multipart.d.ts +58 -0
- package/dist/extension/multipart.d.ts.map +1 -0
- package/dist/extension/multipart.js +61 -0
- package/dist/extension/numeric.d.ts +94 -0
- package/dist/extension/numeric.d.ts.map +1 -0
- package/dist/extension/numeric.js +162 -0
- package/dist/extension/permission-gate.d.ts +15 -0
- package/dist/extension/permission-gate.d.ts.map +1 -1
- package/dist/extension/permission-gate.js +33 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/local-store.d.ts +5 -0
- package/dist/local-store.d.ts.map +1 -1
- package/dist/local-store.js +62 -10
- package/dist/offline/index.d.ts +3 -3
- package/dist/offline/index.js +51 -15
- package/dist/publish/index.d.ts.map +1 -1
- package/dist/publish/index.js +6 -1
- package/dist/realtime.d.ts +9 -1
- package/dist/realtime.d.ts.map +1 -1
- package/dist/realtime.js +15 -2
- package/dist/rpc/index.d.ts.map +1 -1
- package/dist/rpc/index.js +2 -0
- package/dist/schema-codegen.d.ts +3 -1
- package/dist/schema-codegen.d.ts.map +1 -1
- package/dist/schema-codegen.js +41 -7
- package/dist/schema-watcher.d.ts +1 -1
- package/dist/schema-watcher.js +1 -1
- package/dist/studio/index.d.ts +1 -0
- package/dist/studio/index.d.ts.map +1 -1
- package/dist/studio/index.js +3 -0
- package/dist/studio/slot-owner-registry.d.ts +13 -0
- package/dist/studio/slot-owner-registry.d.ts.map +1 -0
- package/dist/studio/slot-owner-registry.js +21 -0
- package/dist/svelte.d.ts +1 -1
- package/dist/svelte.d.ts.map +1 -1
- package/dist/svelte.js +4 -2
- package/dist/sync-manager.d.ts +30 -4
- package/dist/sync-manager.d.ts.map +1 -1
- package/dist/sync-manager.js +101 -15
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +23 -1
- package/dist/testing/with-test-db.d.ts.map +1 -1
- package/dist/testing/with-test-db.js +21 -14
- package/dist/tests/client.test.d.ts +2 -0
- package/dist/tests/client.test.d.ts.map +1 -0
- package/dist/tests/client.test.js +55 -0
- package/dist/tests/crdt-orset.test.d.ts +2 -0
- package/dist/tests/crdt-orset.test.d.ts.map +1 -0
- package/dist/tests/crdt-orset.test.js +23 -0
- package/dist/tests/local-store.test.js +44 -0
- package/dist/tests/multipart.test.d.ts +2 -0
- package/dist/tests/multipart.test.d.ts.map +1 -0
- package/dist/tests/multipart.test.js +37 -0
- package/dist/tests/offline-provider-crdt.test.d.ts +2 -0
- package/dist/tests/offline-provider-crdt.test.d.ts.map +1 -0
- package/dist/tests/offline-provider-crdt.test.js +119 -0
- package/dist/tests/realtime.test.d.ts +2 -0
- package/dist/tests/realtime.test.d.ts.map +1 -0
- package/dist/tests/realtime.test.js +39 -0
- package/dist/tests/sync-manager.test.d.ts +2 -0
- package/dist/tests/sync-manager.test.d.ts.map +1 -0
- package/dist/tests/sync-manager.test.js +65 -0
- package/dist/tests/with-test-db.test.d.ts +2 -0
- package/dist/tests/with-test-db.test.d.ts.map +1 -0
- package/dist/tests/with-test-db.test.js +57 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/validate/index.d.ts +49 -0
- package/dist/validate/index.d.ts.map +1 -1
- package/dist/validate/index.js +494 -7
- package/dist/validate/shared-message-keys.d.ts +16 -0
- package/dist/validate/shared-message-keys.d.ts.map +1 -0
- package/dist/validate/shared-message-keys.js +227 -0
- package/package.json +7 -7
- package/dist/generate-types.d.ts +0 -37
- package/dist/generate-types.d.ts.map +0 -1
- package/dist/generate-types.js +0 -157
package/dist/sync-manager.d.ts
CHANGED
|
@@ -20,6 +20,10 @@ export declare class SyncManager {
|
|
|
20
20
|
private syncTimer;
|
|
21
21
|
private isOnline;
|
|
22
22
|
private isSyncing;
|
|
23
|
+
/** Set by `stop()`; `start()` checks it after every await it makes. */
|
|
24
|
+
private stopped;
|
|
25
|
+
private readonly onOnline;
|
|
26
|
+
private readonly onOffline;
|
|
23
27
|
private listeners;
|
|
24
28
|
constructor(client: ZveltioClient, config?: SyncManagerConfig);
|
|
25
29
|
start(realtimeUrl?: string): Promise<void>;
|
|
@@ -32,22 +36,22 @@ export declare class SyncManager {
|
|
|
32
36
|
collection(name: string): {
|
|
33
37
|
/** List records — reads LOCAL instantly */
|
|
34
38
|
list: () => Promise<{
|
|
35
|
-
_syncStatus: "
|
|
39
|
+
_syncStatus: "conflict" | "synced" | "pending";
|
|
36
40
|
id: string;
|
|
37
41
|
}[]>;
|
|
38
42
|
/** Get one record — reads LOCAL instantly */
|
|
39
43
|
get: (id: string) => Promise<{
|
|
40
|
-
_syncStatus: "
|
|
44
|
+
_syncStatus: "conflict" | "synced" | "pending";
|
|
41
45
|
id: string;
|
|
42
46
|
} | null>;
|
|
43
47
|
/** Create — writes LOCAL + queues sync */
|
|
44
48
|
create: (data: Record<string, any>) => Promise<{
|
|
45
|
-
_syncStatus: "
|
|
49
|
+
_syncStatus: "conflict" | "synced" | "pending";
|
|
46
50
|
id: string;
|
|
47
51
|
}>;
|
|
48
52
|
/** Update — writes LOCAL + queues sync */
|
|
49
53
|
update: (id: string, data: Record<string, any>) => Promise<{
|
|
50
|
-
_syncStatus: "
|
|
54
|
+
_syncStatus: "conflict" | "synced" | "pending";
|
|
51
55
|
id: string;
|
|
52
56
|
}>;
|
|
53
57
|
/** Delete — soft delete LOCAL + queues sync */
|
|
@@ -62,6 +66,28 @@ export declare class SyncManager {
|
|
|
62
66
|
/** Force sync now (non-blocking) */
|
|
63
67
|
syncNow(): Promise<void>;
|
|
64
68
|
private notifyListeners;
|
|
69
|
+
/**
|
|
70
|
+
* Open the local store without starting background sync.
|
|
71
|
+
*
|
|
72
|
+
* `start()` opens the store, attaches online/offline listeners, connects
|
|
73
|
+
* realtime and installs a periodic timer. A caller driving `pull()` and
|
|
74
|
+
* `push()` explicitly — the `OfflineProvider` contract in `offline/` — wants
|
|
75
|
+
* the store and none of the rest, and a timer firing `syncNow()` underneath it
|
|
76
|
+
* makes "how many operations did that push send" unanswerable.
|
|
77
|
+
*/
|
|
78
|
+
open(): Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* Pull rows for the named collections from the server into the local store.
|
|
81
|
+
*
|
|
82
|
+
* There was no way to do this. Records reached the local store only through
|
|
83
|
+
* realtime events, one at a time, which means a client that was offline while
|
|
84
|
+
* a row was created never learns it exists: it missed the event and nothing
|
|
85
|
+
* ever asks the server what it has. `syncNow()` is push-only despite the name.
|
|
86
|
+
*
|
|
87
|
+
* Pages through `list()` rather than asking for everything at once, and stops
|
|
88
|
+
* on the first page that comes back empty or short.
|
|
89
|
+
*/
|
|
90
|
+
pull(collections: string[], pageSize?: number): Promise<number>;
|
|
65
91
|
/** Status: pending operations count, conflicts count */
|
|
66
92
|
getStatus(): Promise<{
|
|
67
93
|
pending: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync-manager.d.ts","sourceRoot":"","sources":["../src/sync-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAMjD,MAAM,MAAM,sBAAsB,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEtD,MAAM,WAAW,iBAAiB;IAChC,0CAA0C;IAC1C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,oDAAoD;IACpD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oDAAoD;
|
|
1
|
+
{"version":3,"file":"sync-manager.d.ts","sourceRoot":"","sources":["../src/sync-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAMjD,MAAM,MAAM,sBAAsB,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEtD,MAAM,WAAW,iBAAiB;IAChC,0CAA0C;IAC1C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,oDAAoD;IACpD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oDAAoD;IAEpD,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,KAAK,GAAG,CAAC;IAC9C,mHAAmH;IACnH,kBAAkB,CAAC,EAAE,sBAAsB,CAAC;CAC7C;AAED,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAa;IAC1B,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,QAAQ,CAAgC;IAChD,OAAO,CAAC,MAAM,CAA8B;IAC5C,OAAO,CAAC,SAAS,CAA+C;IAChE,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,SAAS,CAAkB;IACnC,uEAAuE;IACvE,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAGvB;IACF,OAAO,CAAC,QAAQ,CAAC,SAAS,CAExB;IAEF,OAAO,CAAC,SAAS,CAAyD;gBAE9D,MAAM,EAAE,aAAa,EAAE,MAAM,GAAE,iBAAsB;IAY3D,KAAK,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA+BhD;;;;;OAKG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM;QAEnB,2CAA2C;;;;;QAU3C,6CAA6C;kBAC7B,MAAM;;;;QAUtB,0CAA0C;uBAErB,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;;;;QAYxC,0CAA0C;qBAEvB,MAAM,QAAQ,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;;;;QAapD,+CAA+C;qBAC5B,MAAM;QAMzB,qDAAqD;8BAE/B,CAAC,OAAO,EAAE,GAAG,EAAE,KAAK,IAAI;QA8C9C,8CAA8C;;QAK9C,kCAAkC;8BAEN,MAAM,gBAAgB,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;;IAQzE,oCAAoC;IAC9B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YAwGhB,eAAe;IAmB7B;;;;;;;;OAQG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B;;;;;;;;;;OAUG;IACG,IAAI,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,QAAQ,SAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAuBlE,wDAAwD;IAClD,SAAS,IAAI,OAAO,CAAC;QACzB,OAAO,EAAE,MAAM,CAAC;QAChB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,OAAO,CAAC;KACnB,CAAC;IAUI,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAY5B"}
|
package/dist/sync-manager.js
CHANGED
|
@@ -9,6 +9,16 @@ export class SyncManager {
|
|
|
9
9
|
syncTimer = null;
|
|
10
10
|
isOnline = true;
|
|
11
11
|
isSyncing = false;
|
|
12
|
+
/** Set by `stop()`; `start()` checks it after every await it makes. */
|
|
13
|
+
stopped = false;
|
|
14
|
+
onOnline = () => {
|
|
15
|
+
this.isOnline = true;
|
|
16
|
+
this.syncNow(); // Sync immediately on reconnect
|
|
17
|
+
};
|
|
18
|
+
onOffline = () => {
|
|
19
|
+
this.isOnline = false;
|
|
20
|
+
};
|
|
21
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
12
22
|
listeners = new Map();
|
|
13
23
|
constructor(client, config = {}) {
|
|
14
24
|
this.store = new LocalStore();
|
|
@@ -22,21 +32,24 @@ export class SyncManager {
|
|
|
22
32
|
};
|
|
23
33
|
}
|
|
24
34
|
async start(realtimeUrl) {
|
|
35
|
+
this.stopped = false;
|
|
25
36
|
await this.store.open();
|
|
37
|
+
// A caller that unmounts before `start()` settles calls `stop()` first — a
|
|
38
|
+
// React effect cleanup, and StrictMode runs one on every mount in
|
|
39
|
+
// development. Arming the timer and socket after that leaked both forever.
|
|
40
|
+
if (this.stopped)
|
|
41
|
+
return this.store.close();
|
|
26
42
|
// Online/offline detection
|
|
27
43
|
if (typeof window !== 'undefined') {
|
|
28
|
-
window.addEventListener('online',
|
|
29
|
-
|
|
30
|
-
this.syncNow(); // Sync immediately on reconnect
|
|
31
|
-
});
|
|
32
|
-
window.addEventListener('offline', () => {
|
|
33
|
-
this.isOnline = false;
|
|
34
|
-
});
|
|
44
|
+
window.addEventListener('online', this.onOnline);
|
|
45
|
+
window.addEventListener('offline', this.onOffline);
|
|
35
46
|
this.isOnline = navigator.onLine;
|
|
36
47
|
}
|
|
37
48
|
// Realtime: receives push updates from server
|
|
38
49
|
if (realtimeUrl) {
|
|
39
50
|
const { ZveltioRealtime } = await import('./realtime.js');
|
|
51
|
+
if (this.stopped)
|
|
52
|
+
return;
|
|
40
53
|
this.realtime = new ZveltioRealtime(realtimeUrl);
|
|
41
54
|
this.realtime.connect();
|
|
42
55
|
// Subscriptions are per-collection via collection()
|
|
@@ -75,6 +88,7 @@ export class SyncManager {
|
|
|
75
88
|
};
|
|
76
89
|
},
|
|
77
90
|
/** Create — writes LOCAL + queues sync */
|
|
91
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
78
92
|
create: async (data) => {
|
|
79
93
|
const id = data.id || generateUUID();
|
|
80
94
|
const record = await this.store.put(name, id, data);
|
|
@@ -87,6 +101,7 @@ export class SyncManager {
|
|
|
87
101
|
};
|
|
88
102
|
},
|
|
89
103
|
/** Update — writes LOCAL + queues sync */
|
|
104
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
90
105
|
update: async (id, data) => {
|
|
91
106
|
const existing = await this.store.get(name, id);
|
|
92
107
|
const merged = { ...(existing?.data || {}), ...data };
|
|
@@ -106,6 +121,7 @@ export class SyncManager {
|
|
|
106
121
|
this.syncNow();
|
|
107
122
|
},
|
|
108
123
|
/** Subscribe la changes (realtime + local writes) */
|
|
124
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
109
125
|
subscribe: (callback) => {
|
|
110
126
|
if (!this.listeners.has(name))
|
|
111
127
|
this.listeners.set(name, new Set());
|
|
@@ -114,19 +130,24 @@ export class SyncManager {
|
|
|
114
130
|
let unsubRealtime;
|
|
115
131
|
if (this.realtime) {
|
|
116
132
|
unsubRealtime = this.realtime.subscribe(name, async (event) => {
|
|
117
|
-
// Apply update from server to local store
|
|
118
|
-
|
|
133
|
+
// Apply update from server to local store. The engine's socket
|
|
134
|
+
// sends `{ type: 'event', event: 'insert'|'update'|'delete', data }`
|
|
135
|
+
// (routes/ws.ts `broadcastEvent`); the id lives on `data`.
|
|
136
|
+
const id = event.data?.id;
|
|
137
|
+
if (event.type !== 'event' || !id)
|
|
138
|
+
return;
|
|
139
|
+
if (event.event === 'insert' || event.event === 'update') {
|
|
119
140
|
try {
|
|
120
|
-
const serverRecord = await this.client.collection(name).get(
|
|
121
|
-
await this.store.applyServerUpdate(name,
|
|
141
|
+
const serverRecord = await this.client.collection(name).get(id);
|
|
142
|
+
await this.store.applyServerUpdate(name, id, serverRecord, Date.now());
|
|
122
143
|
this.notifyListeners(name);
|
|
123
144
|
}
|
|
124
145
|
catch {
|
|
125
146
|
/* offline or error — ignore, periodic sync will resolve */
|
|
126
147
|
}
|
|
127
148
|
}
|
|
128
|
-
else if (event.event === '
|
|
129
|
-
await this.store.
|
|
149
|
+
else if (event.event === 'delete') {
|
|
150
|
+
await this.store.applyServerDelete(name, id);
|
|
130
151
|
this.notifyListeners(name);
|
|
131
152
|
}
|
|
132
153
|
});
|
|
@@ -150,6 +171,7 @@ export class SyncManager {
|
|
|
150
171
|
return this.store.getConflicts(name);
|
|
151
172
|
},
|
|
152
173
|
/** Resolve a conflict manually */
|
|
174
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
153
175
|
resolveConflict: async (id, resolvedData) => {
|
|
154
176
|
await this.store.resolveConflict(name, id, resolvedData);
|
|
155
177
|
this.notifyListeners(name);
|
|
@@ -170,6 +192,7 @@ export class SyncManager {
|
|
|
170
192
|
const file = new File([blobItem.blob], `offline_${blobItem.id}`, {
|
|
171
193
|
type: blobItem.blob.type,
|
|
172
194
|
});
|
|
195
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
173
196
|
const result = (await this.client.storage.upload(file));
|
|
174
197
|
const url = result?.url || result?.publicUrl || result?.path || '';
|
|
175
198
|
if (!url)
|
|
@@ -214,6 +237,7 @@ export class SyncManager {
|
|
|
214
237
|
}
|
|
215
238
|
await this.store.markSynced(op.id, op.collection, op.recordId, serverVersion);
|
|
216
239
|
this.notifyListeners(op.collection);
|
|
240
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
217
241
|
}
|
|
218
242
|
catch (err) {
|
|
219
243
|
// Conflict from server (409) — apply conflict resolution
|
|
@@ -224,15 +248,23 @@ export class SyncManager {
|
|
|
224
248
|
// CRDT LWW merge if both sides have CRDT docs and strategy is 'lww'
|
|
225
249
|
if (this.config.conflictResolution === 'lww' &&
|
|
226
250
|
localRecord?._crdtDoc &&
|
|
251
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
227
252
|
serverRecord.__crdt) {
|
|
253
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
228
254
|
const merged = mergeLWW(localRecord._crdtDoc, serverRecord.__crdt);
|
|
229
|
-
|
|
230
|
-
await this.store.resolveConflict(op.collection, op.recordId, resolvedData);
|
|
255
|
+
await this.store.resolveConflict(op.collection, op.recordId, fromDocument(merged));
|
|
231
256
|
}
|
|
232
257
|
else {
|
|
233
258
|
const resolved = this.config.onConflict(localRecord?.data, serverRecord);
|
|
234
259
|
await this.store.resolveConflict(op.collection, op.recordId, resolved);
|
|
235
260
|
}
|
|
261
|
+
// The operation the server just rejected stayed in the queue with
|
|
262
|
+
// its original payload and its attempt count untouched, so the next
|
|
263
|
+
// tick sent the same payload, took the same 409 and resolved the
|
|
264
|
+
// same conflict again — every `syncInterval`, with no end. Count
|
|
265
|
+
// the attempt so `maxRetries` retires it; the resolved row is
|
|
266
|
+
// queued by `resolveConflict` as its own operation.
|
|
267
|
+
await this.store.markFailed(op.id, 'rejected with 409 — resolved locally');
|
|
236
268
|
}
|
|
237
269
|
catch {
|
|
238
270
|
/* fallback: server wins — ignore error */
|
|
@@ -268,6 +300,53 @@ export class SyncManager {
|
|
|
268
300
|
}
|
|
269
301
|
});
|
|
270
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* Open the local store without starting background sync.
|
|
305
|
+
*
|
|
306
|
+
* `start()` opens the store, attaches online/offline listeners, connects
|
|
307
|
+
* realtime and installs a periodic timer. A caller driving `pull()` and
|
|
308
|
+
* `push()` explicitly — the `OfflineProvider` contract in `offline/` — wants
|
|
309
|
+
* the store and none of the rest, and a timer firing `syncNow()` underneath it
|
|
310
|
+
* makes "how many operations did that push send" unanswerable.
|
|
311
|
+
*/
|
|
312
|
+
async open() {
|
|
313
|
+
await this.store.open();
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Pull rows for the named collections from the server into the local store.
|
|
317
|
+
*
|
|
318
|
+
* There was no way to do this. Records reached the local store only through
|
|
319
|
+
* realtime events, one at a time, which means a client that was offline while
|
|
320
|
+
* a row was created never learns it exists: it missed the event and nothing
|
|
321
|
+
* ever asks the server what it has. `syncNow()` is push-only despite the name.
|
|
322
|
+
*
|
|
323
|
+
* Pages through `list()` rather than asking for everything at once, and stops
|
|
324
|
+
* on the first page that comes back empty or short.
|
|
325
|
+
*/
|
|
326
|
+
async pull(collections, pageSize = 200) {
|
|
327
|
+
let applied = 0;
|
|
328
|
+
for (const name of collections) {
|
|
329
|
+
for (let page = 1;; page++) {
|
|
330
|
+
const result = await this.client.collection(name).list({ page, limit: pageSize });
|
|
331
|
+
const rows = result?.data ?? [];
|
|
332
|
+
for (const row of rows) {
|
|
333
|
+
const id = row.id;
|
|
334
|
+
if (!id)
|
|
335
|
+
continue;
|
|
336
|
+
// `Date.now()` as the server version: the collections API does not
|
|
337
|
+
// return a version column, and the local store only compares versions
|
|
338
|
+
// to decide whether an incoming row is newer than one it already has.
|
|
339
|
+
// A server row arriving now IS newer than anything already local.
|
|
340
|
+
await this.store.applyServerUpdate(name, id, row, Date.now());
|
|
341
|
+
applied++;
|
|
342
|
+
}
|
|
343
|
+
if (rows.length < pageSize)
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
await this.notifyListeners(name);
|
|
347
|
+
}
|
|
348
|
+
return applied;
|
|
349
|
+
}
|
|
271
350
|
/** Status: pending operations count, conflicts count */
|
|
272
351
|
async getStatus() {
|
|
273
352
|
const pending = await this.store.getPendingOps();
|
|
@@ -279,9 +358,16 @@ export class SyncManager {
|
|
|
279
358
|
};
|
|
280
359
|
}
|
|
281
360
|
async stop() {
|
|
361
|
+
this.stopped = true;
|
|
282
362
|
if (this.syncTimer)
|
|
283
363
|
clearInterval(this.syncTimer);
|
|
364
|
+
this.syncTimer = null;
|
|
365
|
+
if (typeof window !== 'undefined') {
|
|
366
|
+
window.removeEventListener('online', this.onOnline);
|
|
367
|
+
window.removeEventListener('offline', this.onOffline);
|
|
368
|
+
}
|
|
284
369
|
this.realtime?.disconnect();
|
|
370
|
+
this.realtime = null;
|
|
285
371
|
await this.store.close();
|
|
286
372
|
}
|
|
287
373
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAIhF,MAAM,WAAW,QAAQ;IACvB,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAC;IACd,iCAAiC;IACjC,IAAI,EAAE,OAAO,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B;;;;;;;;;;OAUG;IACH,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,MAAM;IACrB,yDAAyD;IACzD,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,CAAC;IAC3B,yCAAyC;IACzC,KAAK,IAAI,IAAI,CAAC;IACd,mDAAmD;IACnD,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;CAC7C;AAED;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"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"}
|
package/dist/testing/index.js
CHANGED
|
@@ -24,10 +24,13 @@ import { Hono } from 'hono';
|
|
|
24
24
|
* `.executeTakeFirstOrThrow()` returns a preset if matched, otherwise
|
|
25
25
|
* an empty list (for execute) or undefined.
|
|
26
26
|
*/
|
|
27
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
27
28
|
export function mockDb(presets = {}) {
|
|
28
29
|
const calls = [];
|
|
29
30
|
const presetMap = new Map(Object.entries(presets));
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
30
32
|
function buildChain(prefix) {
|
|
33
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
31
34
|
const proxy = new Proxy(() => { }, {
|
|
32
35
|
get(_, prop) {
|
|
33
36
|
if (typeof prop === 'symbol')
|
|
@@ -78,6 +81,7 @@ export function mockDb(presets = {}) {
|
|
|
78
81
|
});
|
|
79
82
|
return proxy;
|
|
80
83
|
}
|
|
84
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
81
85
|
return buildChain([]);
|
|
82
86
|
}
|
|
83
87
|
function matchPreset(chain, presets) {
|
|
@@ -93,7 +97,9 @@ function matchPreset(chain, presets) {
|
|
|
93
97
|
}
|
|
94
98
|
export function mockEventBus() {
|
|
95
99
|
const emitted = [];
|
|
100
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
96
101
|
const listeners = new Map();
|
|
102
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
97
103
|
const beforeHandlers = new Map();
|
|
98
104
|
return {
|
|
99
105
|
emit(event, payload) {
|
|
@@ -128,12 +134,14 @@ export function mockEventBus() {
|
|
|
128
134
|
};
|
|
129
135
|
},
|
|
130
136
|
async runBefore(event, seed) {
|
|
137
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
131
138
|
const payload = { ...seed };
|
|
132
139
|
let aborted = false;
|
|
133
140
|
let abortReason = '';
|
|
134
141
|
payload.abort = (reason) => {
|
|
135
142
|
aborted = true;
|
|
136
143
|
abortReason = reason;
|
|
144
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
137
145
|
const err = new Error(`Aborted: ${reason}`);
|
|
138
146
|
err.name = 'AbortHookError';
|
|
139
147
|
err.reason = reason;
|
|
@@ -187,8 +195,10 @@ export function mockServiceRegistry() {
|
|
|
187
195
|
}
|
|
188
196
|
// ─── Mock queryAlter / entityAccess scoped registries ──────────────────────
|
|
189
197
|
function mockQueryAlterScope() {
|
|
198
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
190
199
|
const entries = [];
|
|
191
200
|
return {
|
|
201
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
192
202
|
register(def) {
|
|
193
203
|
entries.push(def);
|
|
194
204
|
},
|
|
@@ -201,8 +211,10 @@ function mockQueryAlterScope() {
|
|
|
201
211
|
};
|
|
202
212
|
}
|
|
203
213
|
function mockEntityAccessScope() {
|
|
214
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
204
215
|
const entries = [];
|
|
205
216
|
return {
|
|
217
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
206
218
|
register(def) {
|
|
207
219
|
entries.push(def);
|
|
208
220
|
},
|
|
@@ -214,6 +226,7 @@ function mockEntityAccessScope() {
|
|
|
214
226
|
},
|
|
215
227
|
};
|
|
216
228
|
}
|
|
229
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
217
230
|
export function mockAuth(opts = {}) {
|
|
218
231
|
const user = opts.user === undefined ? { id: 'test-user', email: 'test@example.com', roles: [] } : opts.user;
|
|
219
232
|
return {
|
|
@@ -224,6 +237,7 @@ export function mockAuth(opts = {}) {
|
|
|
224
237
|
},
|
|
225
238
|
};
|
|
226
239
|
}
|
|
240
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
227
241
|
export function createTestContext(opts = {}) {
|
|
228
242
|
const db = opts.db ?? mockDb();
|
|
229
243
|
const events = opts.events ?? mockEventBus();
|
|
@@ -239,14 +253,20 @@ export function createTestContext(opts = {}) {
|
|
|
239
253
|
deserialize: (_t, v) => v,
|
|
240
254
|
serialize: (_t, v) => v,
|
|
241
255
|
},
|
|
256
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
242
257
|
events: events,
|
|
243
258
|
checkPermission: async () => true,
|
|
244
259
|
getUserRoles: async () => ['admin'],
|
|
260
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
245
261
|
DDLManager: {},
|
|
262
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
246
263
|
services: mockServiceRegistry(),
|
|
264
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
247
265
|
queryAlter: mockQueryAlterScope(),
|
|
266
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
248
267
|
entityAccess: mockEntityAccessScope(),
|
|
249
268
|
registerPublicRoute: () => { },
|
|
269
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
250
270
|
internals: {},
|
|
251
271
|
...(opts.extra ?? {}),
|
|
252
272
|
};
|
|
@@ -259,7 +279,9 @@ export function createTestContext(opts = {}) {
|
|
|
259
279
|
* mounts the sub-app at `/ext/<extension.name>` by default — same shape
|
|
260
280
|
* the engine produces in production.
|
|
261
281
|
*/
|
|
262
|
-
export async function createTestApp(
|
|
282
|
+
export async function createTestApp(
|
|
283
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
284
|
+
extension, opts = {}) {
|
|
263
285
|
const ctx = opts.ctx ?? createTestContext();
|
|
264
286
|
const strategy = extension.mountStrategy ?? 'global';
|
|
265
287
|
if (strategy === 'subapp') {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"with-test-db.d.ts","sourceRoot":"","sources":["../../src/testing/with-test-db.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"with-test-db.d.ts","sourceRoot":"","sources":["../../src/testing/with-test-db.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGrC,MAAM,WAAW,iBAAiB;IAChC,qDAAqD;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,MAAM;IACrB,8DAA8D;IAE9D,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;IAChB,+CAA+C;IAC/C,gBAAgB,EAAE,MAAM,CAAC;IACzB,iEAAiE;IACjE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AA4BD;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAAC,IAAI,GAAE,iBAAsB,GAAG,OAAO,CAAC,MAAM,CAAC,CA8E/E;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,UAAU,CAAC,CAAC,EAEhC,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,iBAAiB,EAE/D,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GACxC,OAAO,CAAC,CAAC,CAAC,CAYZ;AAED;;;;;;;;;;;GAWG;AAGH,wBAAsB,qBAAqB,CAAC,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBhG;AAED;;;GAGG;AAGH,wBAAsB,mBAAmB,CAAC,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAIzF;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAStD;AAkBD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAuErD;AAGD,eAAO,MAAM,iBAAiB;;CAAsB,CAAC"}
|
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
* weight just to use `mockDb`. By importing lazily, the cost is
|
|
25
25
|
* opt-in at the call site.
|
|
26
26
|
*/
|
|
27
|
+
import { parseMigrationSql } from '../validate/migration-parse.js';
|
|
27
28
|
// ── Module-level reuse cache ────────────────────────────────────────────────
|
|
28
29
|
//
|
|
29
30
|
// When `reuse: true`, we keep a single container alive for the process
|
|
30
31
|
// lifetime. The first call to `withTestDb` boots it; subsequent calls open
|
|
31
32
|
// fresh DBs *inside* the same container (CREATE DATABASE per-call), so
|
|
32
33
|
// tests stay isolated without paying the container-start cost again.
|
|
34
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
33
35
|
let _sharedContainer = null;
|
|
34
|
-
let _sharedClient = null;
|
|
35
|
-
let _sharedDbCounter = 0;
|
|
36
36
|
async function loadPgContainer() {
|
|
37
37
|
try {
|
|
38
38
|
return await import('@testcontainers/postgresql');
|
|
@@ -64,6 +64,7 @@ export async function startTestDb(opts = {}) {
|
|
|
64
64
|
let port;
|
|
65
65
|
let username;
|
|
66
66
|
let password;
|
|
67
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
67
68
|
let createdContainer = null;
|
|
68
69
|
if (opts.reuse && _sharedContainer) {
|
|
69
70
|
host = _sharedContainer.getHost();
|
|
@@ -87,14 +88,15 @@ export async function startTestDb(opts = {}) {
|
|
|
87
88
|
const adminPool = await openPgPool(adminConnStr);
|
|
88
89
|
try {
|
|
89
90
|
await adminPool.query(`CREATE DATABASE "${database}"`);
|
|
90
|
-
_sharedDbCounter++;
|
|
91
91
|
}
|
|
92
92
|
finally {
|
|
93
93
|
await adminPool.end();
|
|
94
94
|
}
|
|
95
95
|
const connectionString = `postgres://${username}:${password}@${host}:${port}/${database}`;
|
|
96
96
|
const pool = await openPgPool(connectionString);
|
|
97
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
97
98
|
const db = new Kysely({
|
|
99
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
98
100
|
dialect: new PostgresDialect({ pool: pool }),
|
|
99
101
|
});
|
|
100
102
|
if (opts.migrations && opts.migrations.length > 0) {
|
|
@@ -147,7 +149,11 @@ export async function startTestDb(opts = {}) {
|
|
|
147
149
|
* });
|
|
148
150
|
* });
|
|
149
151
|
*/
|
|
150
|
-
export async function withTestDb(
|
|
152
|
+
export async function withTestDb(
|
|
153
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
154
|
+
fnOrOpts,
|
|
155
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
156
|
+
maybeFn) {
|
|
151
157
|
const opts = typeof fnOrOpts === 'function' ? {} : fnOrOpts;
|
|
152
158
|
const fn = typeof fnOrOpts === 'function' ? fnOrOpts : maybeFn;
|
|
153
159
|
if (typeof fn !== 'function') {
|
|
@@ -173,10 +179,17 @@ export async function withTestDb(fnOrOpts, maybeFn) {
|
|
|
173
179
|
* const files = glob('engine/migrations/*.sql').map((p) => readFileSync(p, 'utf8'));
|
|
174
180
|
* await applyMigrationStrings(db, files);
|
|
175
181
|
*/
|
|
182
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
176
183
|
export async function applyMigrationStrings(db, sqlStrings) {
|
|
177
184
|
const { sql } = await loadKysely();
|
|
178
185
|
for (const raw of sqlStrings) {
|
|
179
|
-
|
|
186
|
+
// Only the UP half. A Zveltio migration keeps its rollback in the same file
|
|
187
|
+
// behind a `-- DOWN` marker, and the runner that applies it in production
|
|
188
|
+
// splits on that marker — this helper did not, so the documented example
|
|
189
|
+
// (glob `engine/migrations/*.sql`, hand them all to this function) created
|
|
190
|
+
// the schema and then dropped it again, in the same call. Every assertion
|
|
191
|
+
// after it failed on a table that had existed a statement earlier.
|
|
192
|
+
const statements = splitStatements(parseMigrationSql(raw).up);
|
|
180
193
|
for (const stmt of statements) {
|
|
181
194
|
const trimmed = stmt.trim();
|
|
182
195
|
if (!trimmed)
|
|
@@ -189,6 +202,7 @@ export async function applyMigrationStrings(db, sqlStrings) {
|
|
|
189
202
|
* Same as `applyMigrationStrings` but takes file paths. Reads each file
|
|
190
203
|
* synchronously, applies in order. Bun-native — uses `Bun.file()`.
|
|
191
204
|
*/
|
|
205
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
192
206
|
export async function applyMigrationFiles(db, paths) {
|
|
193
207
|
// Bun.file().text() returns a Promise<string>.
|
|
194
208
|
const contents = await Promise.all(paths.map((p) => Bun.file(p).text()));
|
|
@@ -200,15 +214,6 @@ export async function applyMigrationFiles(db, paths) {
|
|
|
200
214
|
* reuse container is active.
|
|
201
215
|
*/
|
|
202
216
|
export async function stopReusedTestDb() {
|
|
203
|
-
if (_sharedClient) {
|
|
204
|
-
try {
|
|
205
|
-
await _sharedClient.end();
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
/* */
|
|
209
|
-
}
|
|
210
|
-
_sharedClient = null;
|
|
211
|
-
}
|
|
212
217
|
if (_sharedContainer) {
|
|
213
218
|
try {
|
|
214
219
|
await _sharedContainer.stop();
|
|
@@ -221,12 +226,14 @@ export async function stopReusedTestDb() {
|
|
|
221
226
|
}
|
|
222
227
|
// ── Internals ───────────────────────────────────────────────────────────────
|
|
223
228
|
/** Open a pg connection pool. Uses `pg` (peer dep with testcontainers). */
|
|
229
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
224
230
|
async function openPgPool(connectionString) {
|
|
225
231
|
const pg = await import('pg').catch(() => {
|
|
226
232
|
throw new Error(`withTestDb requires the 'pg' npm package. Install it alongside testcontainers:\n` +
|
|
227
233
|
` bun add -d testcontainers pg @types/pg`);
|
|
228
234
|
});
|
|
229
235
|
// pg's Pool — the Kysely PostgresDialect accepts this directly.
|
|
236
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
230
237
|
return new pg.Pool({ connectionString });
|
|
231
238
|
}
|
|
232
239
|
/**
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.test.d.ts","sourceRoot":"","sources":["../../src/tests/client.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'bun:test';
|
|
2
|
+
import { createZveltioClient } from '../client.js';
|
|
3
|
+
const realFetch = globalThis.fetch;
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
globalThis.fetch = realFetch;
|
|
6
|
+
});
|
|
7
|
+
/** Records the URL each call is given and answers `{}` without a network hop. */
|
|
8
|
+
function captureUrls() {
|
|
9
|
+
const urls = [];
|
|
10
|
+
globalThis.fetch = (async (input) => {
|
|
11
|
+
urls.push(String(input));
|
|
12
|
+
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
|
|
13
|
+
});
|
|
14
|
+
return urls;
|
|
15
|
+
}
|
|
16
|
+
describe('ZveltioClient URL building', () => {
|
|
17
|
+
it('encodes a record id into a single path segment', async () => {
|
|
18
|
+
const urls = captureUrls();
|
|
19
|
+
const client = createZveltioClient({ baseUrl: 'https://engine.test' });
|
|
20
|
+
const products = client.collection('products');
|
|
21
|
+
await products.get('a/b');
|
|
22
|
+
await products.update('a/b', { name: 'x' });
|
|
23
|
+
await products.delete('a/b');
|
|
24
|
+
expect(urls).toEqual([
|
|
25
|
+
'https://engine.test/api/data/products/a%2Fb',
|
|
26
|
+
'https://engine.test/api/data/products/a%2Fb',
|
|
27
|
+
'https://engine.test/api/data/products/a%2Fb',
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
it('encodes the storage folder into the query string', async () => {
|
|
31
|
+
const urls = captureUrls();
|
|
32
|
+
const client = createZveltioClient({ baseUrl: 'https://engine.test' });
|
|
33
|
+
await client.storage.list('invoices&limit=9999');
|
|
34
|
+
expect(urls).toEqual(['https://engine.test/api/storage?folder_id=invoices%26limit%3D9999']);
|
|
35
|
+
});
|
|
36
|
+
it('sends the upload folder under the field name the engine reads', async () => {
|
|
37
|
+
let body;
|
|
38
|
+
globalThis.fetch = (async (_input, init) => {
|
|
39
|
+
body = init?.body;
|
|
40
|
+
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
|
|
41
|
+
});
|
|
42
|
+
const client = createZveltioClient({ baseUrl: 'https://engine.test' });
|
|
43
|
+
await client.storage.upload(new File(['x'], 'a.txt'), 'folder-uuid');
|
|
44
|
+
expect(body?.get('folder_id')).toBe('folder-uuid');
|
|
45
|
+
expect(body?.has('folder')).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
it('leaves an ordinary id and collection name untouched', async () => {
|
|
48
|
+
const urls = captureUrls();
|
|
49
|
+
const client = createZveltioClient({ baseUrl: 'https://engine.test/' });
|
|
50
|
+
await client.collection('order_items').get('018f2a1e-0000-7000-8000-000000000000');
|
|
51
|
+
expect(urls).toEqual([
|
|
52
|
+
'https://engine.test/api/data/order_items/018f2a1e-0000-7000-8000-000000000000',
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crdt-orset.test.d.ts","sourceRoot":"","sources":["../../src/tests/crdt-orset.test.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import './setup';
|
|
2
|
+
import { describe, it, expect } from 'bun:test';
|
|
3
|
+
import { orSetAdd, orSetRemove, orSetMerge, orSetValues } from '../crdt.js';
|
|
4
|
+
describe('OR-Set — removal survives a merge with a stale replica', () => {
|
|
5
|
+
it('does not resurrect an element that one replica removed', () => {
|
|
6
|
+
const added = orSetAdd([], 'x');
|
|
7
|
+
const uid = added[0].uid;
|
|
8
|
+
const stale = [...added]; // a replica that never saw the removal
|
|
9
|
+
const removed = orSetRemove(added, uid);
|
|
10
|
+
const first = orSetMerge(removed, stale);
|
|
11
|
+
expect(orSetValues(first)).toEqual([]);
|
|
12
|
+
// The stale replica syncs again. Without its tombstone the merged set has
|
|
13
|
+
// no record of the removal, so the element reads as a fresh addition.
|
|
14
|
+
const second = orSetMerge(first, stale);
|
|
15
|
+
expect(orSetValues(second), 'the removed element came back').toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
it('an element added after a removal is kept', () => {
|
|
18
|
+
const first = orSetAdd([], 'x');
|
|
19
|
+
const removed = orSetRemove(first, first[0].uid);
|
|
20
|
+
const readded = orSetAdd(removed, 'x');
|
|
21
|
+
expect(orSetValues(orSetMerge(readded, removed))).toEqual(['x']);
|
|
22
|
+
});
|
|
23
|
+
});
|