@zveltio/sdk 3.0.0-beta.5 → 3.0.0-beta.70

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.
Files changed (105) hide show
  1. package/dist/client/Auth.d.ts.map +1 -1
  2. package/dist/client/Auth.js +3 -0
  3. package/dist/client/QueryBuilder.d.ts.map +1 -1
  4. package/dist/client/QueryBuilder.js +4 -0
  5. package/dist/client/RealtimeClient.d.ts +19 -1
  6. package/dist/client/RealtimeClient.d.ts.map +1 -1
  7. package/dist/client/RealtimeClient.js +65 -12
  8. package/dist/client/ZveltioClient.d.ts.map +1 -1
  9. package/dist/client/ZveltioClient.js +12 -1
  10. package/dist/client.d.ts +28 -2
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +41 -14
  13. package/dist/core.d.ts.map +1 -1
  14. package/dist/core.js +21 -3
  15. package/dist/crdt.d.ts.map +1 -1
  16. package/dist/crdt.js +11 -2
  17. package/dist/ddl/index.d.ts.map +1 -1
  18. package/dist/errors.d.ts +58 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +124 -0
  21. package/dist/extension/index.d.ts +459 -29
  22. package/dist/extension/index.d.ts.map +1 -1
  23. package/dist/extension/index.js +3 -0
  24. package/dist/extension/jsonb.d.ts +62 -0
  25. package/dist/extension/jsonb.d.ts.map +1 -0
  26. package/dist/extension/jsonb.js +64 -0
  27. package/dist/extension/multipart.d.ts +58 -0
  28. package/dist/extension/multipart.d.ts.map +1 -0
  29. package/dist/extension/multipart.js +61 -0
  30. package/dist/extension/numeric.d.ts +94 -0
  31. package/dist/extension/numeric.d.ts.map +1 -0
  32. package/dist/extension/numeric.js +162 -0
  33. package/dist/extension/permission-gate.d.ts +15 -0
  34. package/dist/extension/permission-gate.d.ts.map +1 -1
  35. package/dist/extension/permission-gate.js +33 -1
  36. package/dist/index.d.ts +3 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -0
  39. package/dist/local-store.d.ts +5 -0
  40. package/dist/local-store.d.ts.map +1 -1
  41. package/dist/local-store.js +62 -10
  42. package/dist/offline/index.d.ts +3 -3
  43. package/dist/offline/index.js +51 -15
  44. package/dist/publish/index.d.ts.map +1 -1
  45. package/dist/publish/index.js +6 -1
  46. package/dist/realtime.d.ts +18 -1
  47. package/dist/realtime.d.ts.map +1 -1
  48. package/dist/realtime.js +34 -3
  49. package/dist/rpc/index.d.ts.map +1 -1
  50. package/dist/rpc/index.js +2 -0
  51. package/dist/schema-codegen.d.ts +3 -1
  52. package/dist/schema-codegen.d.ts.map +1 -1
  53. package/dist/schema-codegen.js +41 -7
  54. package/dist/schema-watcher.d.ts +10 -2
  55. package/dist/schema-watcher.d.ts.map +1 -1
  56. package/dist/schema-watcher.js +30 -10
  57. package/dist/studio/index.d.ts +1 -0
  58. package/dist/studio/index.d.ts.map +1 -1
  59. package/dist/studio/index.js +3 -0
  60. package/dist/studio/slot-owner-registry.d.ts +13 -0
  61. package/dist/studio/slot-owner-registry.d.ts.map +1 -0
  62. package/dist/studio/slot-owner-registry.js +21 -0
  63. package/dist/svelte.d.ts +1 -1
  64. package/dist/svelte.d.ts.map +1 -1
  65. package/dist/svelte.js +4 -2
  66. package/dist/sync-manager.d.ts +30 -4
  67. package/dist/sync-manager.d.ts.map +1 -1
  68. package/dist/sync-manager.js +101 -15
  69. package/dist/testing/index.d.ts.map +1 -1
  70. package/dist/testing/index.js +23 -1
  71. package/dist/testing/with-test-db.d.ts.map +1 -1
  72. package/dist/testing/with-test-db.js +21 -14
  73. package/dist/tests/client.test.d.ts +2 -0
  74. package/dist/tests/client.test.d.ts.map +1 -0
  75. package/dist/tests/client.test.js +61 -0
  76. package/dist/tests/crdt-orset.test.d.ts +2 -0
  77. package/dist/tests/crdt-orset.test.d.ts.map +1 -0
  78. package/dist/tests/crdt-orset.test.js +23 -0
  79. package/dist/tests/local-store.test.js +44 -0
  80. package/dist/tests/multipart.test.d.ts +2 -0
  81. package/dist/tests/multipart.test.d.ts.map +1 -0
  82. package/dist/tests/multipart.test.js +37 -0
  83. package/dist/tests/offline-provider-crdt.test.d.ts +2 -0
  84. package/dist/tests/offline-provider-crdt.test.d.ts.map +1 -0
  85. package/dist/tests/offline-provider-crdt.test.js +119 -0
  86. package/dist/tests/realtime.test.d.ts +2 -0
  87. package/dist/tests/realtime.test.d.ts.map +1 -0
  88. package/dist/tests/realtime.test.js +284 -0
  89. package/dist/tests/sync-manager.test.d.ts +2 -0
  90. package/dist/tests/sync-manager.test.d.ts.map +1 -0
  91. package/dist/tests/sync-manager.test.js +65 -0
  92. package/dist/tests/with-test-db.test.d.ts +2 -0
  93. package/dist/tests/with-test-db.test.d.ts.map +1 -0
  94. package/dist/tests/with-test-db.test.js +57 -0
  95. package/dist/types/index.d.ts.map +1 -1
  96. package/dist/validate/index.d.ts +49 -0
  97. package/dist/validate/index.d.ts.map +1 -1
  98. package/dist/validate/index.js +503 -7
  99. package/dist/validate/shared-message-keys.d.ts +16 -0
  100. package/dist/validate/shared-message-keys.d.ts.map +1 -0
  101. package/dist/validate/shared-message-keys.js +227 -0
  102. package/package.json +7 -7
  103. package/dist/generate-types.d.ts +0 -37
  104. package/dist/generate-types.d.ts.map +0 -1
  105. package/dist/generate-types.js +0 -157
@@ -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(fnOrOpts, maybeFn) {
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
- const statements = splitStatements(raw);
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=client.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.test.d.ts","sourceRoot":"","sources":["../../src/tests/client.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,61 @@
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('asks the engine who its key is at the route that answers it', async () => {
31
+ const urls = captureUrls();
32
+ const client = createZveltioClient({ baseUrl: 'https://engine.test', apiKey: 'zvk_x' });
33
+ await client.auth.apiKey();
34
+ expect(urls).toEqual(['https://engine.test/api/api-keys/self']);
35
+ });
36
+ it('encodes the storage folder into the query string', async () => {
37
+ const urls = captureUrls();
38
+ const client = createZveltioClient({ baseUrl: 'https://engine.test' });
39
+ await client.storage.list('invoices&limit=9999');
40
+ expect(urls).toEqual(['https://engine.test/api/storage?folder_id=invoices%26limit%3D9999']);
41
+ });
42
+ it('sends the upload folder under the field name the engine reads', async () => {
43
+ let body;
44
+ globalThis.fetch = (async (_input, init) => {
45
+ body = init?.body;
46
+ return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
47
+ });
48
+ const client = createZveltioClient({ baseUrl: 'https://engine.test' });
49
+ await client.storage.upload(new File(['x'], 'a.txt'), 'folder-uuid');
50
+ expect(body?.get('folder_id')).toBe('folder-uuid');
51
+ expect(body?.has('folder')).toBe(false);
52
+ });
53
+ it('leaves an ordinary id and collection name untouched', async () => {
54
+ const urls = captureUrls();
55
+ const client = createZveltioClient({ baseUrl: 'https://engine.test/' });
56
+ await client.collection('order_items').get('018f2a1e-0000-7000-8000-000000000000');
57
+ expect(urls).toEqual([
58
+ 'https://engine.test/api/data/order_items/018f2a1e-0000-7000-8000-000000000000',
59
+ ]);
60
+ });
61
+ });
@@ -0,0 +1,2 @@
1
+ import './setup';
2
+ //# sourceMappingURL=crdt-orset.test.d.ts.map
@@ -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
+ });
@@ -74,3 +74,47 @@ describe('LocalStore — conflict detection', () => {
74
74
  expect(rec?._conflictData).toBeUndefined();
75
75
  });
76
76
  });
77
+ describe('LocalStore — what the SyncManager actually passes', () => {
78
+ let store;
79
+ beforeEach(async () => {
80
+ store = new LocalStore();
81
+ await store.open();
82
+ await store.clear();
83
+ });
84
+ afterEach(async () => {
85
+ await store.close();
86
+ });
87
+ it('flags a conflict when the confirmed server version is an epoch timestamp', async () => {
88
+ // `syncNow()` and `pull()` both pass `Date.now()` as the server version —
89
+ // the collections API returns no version column. From the first ACK on,
90
+ // `_serverVersion` is ~1.79e12 and `_localVersion` is still a small counter,
91
+ // so the version comparison alone can never be true again and every local
92
+ // edit was overwritten in silence.
93
+ await store.put('posts', 'rec-ts', { title: 'v1' });
94
+ const [op] = await store.getPendingOps();
95
+ await store.markSynced(op.id, 'posts', 'rec-ts', Date.now());
96
+ await store.put('posts', 'rec-ts', { title: 'local edit' });
97
+ await store.applyServerUpdate('posts', 'rec-ts', { title: 'server wins' }, Date.now());
98
+ const after = await store.get('posts', 'rec-ts');
99
+ expect(after?._syncStatus).toBe('conflict');
100
+ expect(after?.data).toEqual({ title: 'local edit' });
101
+ expect(after?._conflictData).toEqual({ title: 'server wins' });
102
+ });
103
+ it('queues the resolved row, so a resolution reaches the server', async () => {
104
+ await store.applyServerUpdate('posts', 'rec-res', { title: 'server' }, Date.now());
105
+ await store.resolveConflict('posts', 'rec-res', { title: 'resolved' });
106
+ const ops = await store.getPendingOps();
107
+ const mine = ops.filter((o) => o.recordId === 'rec-res');
108
+ expect(mine, 'the resolution was never queued').toHaveLength(1);
109
+ expect(mine[0].payload).toEqual({ title: 'resolved' });
110
+ expect(mine[0].operation).toBe('update');
111
+ });
112
+ it('keeps the CRDT document across a clean server update', async () => {
113
+ await store.put('posts', 'rec-crdt', { title: 'local' });
114
+ const [op] = await store.getPendingOps();
115
+ await store.markSynced(op.id, 'posts', 'rec-crdt', Date.now());
116
+ await store.applyServerUpdate('posts', 'rec-crdt', { title: 'server' }, Date.now());
117
+ const after = await store.get('posts', 'rec-crdt');
118
+ expect(after?._crdtDoc, 'the CRDT document was dropped, so no later merge can run').toBeDefined();
119
+ });
120
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=multipart.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"multipart.test.d.ts","sourceRoot":"","sources":["../../src/tests/multipart.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { MULTIPART_REQUIRED, readMultipart } from '../extension/multipart.js';
3
+ /**
4
+ * Six upload handlers opened with a bare `await c.req.formData()` and put their
5
+ * "no file provided" 400 on the line after it. `formData()` throws when the body
6
+ * is not multipart, so that 400 was unreachable for exactly the requests it was
7
+ * written for, and they came back as a bare 500 instead.
8
+ *
9
+ * Both directions are asserted here. A guard that rejects everything would also
10
+ * make the suite green, and would break every upload in the product.
11
+ */
12
+ const ctxWith = (body, headers = {}) => {
13
+ const req = new Request('http://x/upload', { method: 'POST', body, headers });
14
+ return { req: { formData: () => req.formData() } };
15
+ };
16
+ describe('readMultipart', () => {
17
+ it('parses a real multipart body, fields and files alike', async () => {
18
+ const fd = new FormData();
19
+ fd.append('file', new File(['salut'], 'a.txt', { type: 'text/plain' }));
20
+ fd.append('path', '/docs');
21
+ const form = await readMultipart(ctxWith(fd));
22
+ expect(form).not.toBeNull();
23
+ expect(form?.get('file')?.name).toBe('a.txt');
24
+ expect(form?.get('path')).toBe('/docs');
25
+ });
26
+ it('answers null for every body shape that used to throw', async () => {
27
+ expect(await readMultipart(ctxWith('{}', { 'content-type': 'application/json' }))).toBeNull();
28
+ expect(await readMultipart(ctxWith(null))).toBeNull();
29
+ expect(await readMultipart(ctxWith('salut', { 'content-type': 'text/plain' }))).toBeNull();
30
+ });
31
+ it('does not leak the parser own message to the caller', async () => {
32
+ // The body parser's error reads like "FormData parse error" — nothing a
33
+ // caller can act on, and echoing a parser's internals over HTTP is how
34
+ // request contents end up in error strings.
35
+ expect(MULTIPART_REQUIRED).toEqual({ error: 'Expected a multipart/form-data body.' });
36
+ });
37
+ });
@@ -0,0 +1,2 @@
1
+ import './setup';
2
+ //# sourceMappingURL=offline-provider-crdt.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"offline-provider-crdt.test.d.ts","sourceRoot":"","sources":["../../src/tests/offline-provider-crdt.test.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,CAAC"}
@@ -0,0 +1,119 @@
1
+ import './setup';
2
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
3
+ import { LocalStore } from '../local-store.js';
4
+ import { createOfflineProvider } from '../offline/index.js';
5
+ /**
6
+ * The CRDT provider is the DEFAULT (`config.provider ?? 'crdt'`) and it did
7
+ * nothing at all: `pull()` was an empty body, `subscribe()` returned an
8
+ * unsubscribe for nothing, and `push()` returned `0` — which reads as "there
9
+ * was nothing to send" rather than "I did not look". An application that called
10
+ * `createOfflineProvider({ engineUrl })` and pushed on a timer got a clean run
11
+ * and an empty server, forever, with nothing in any log.
12
+ *
13
+ * The test that existed asserted exactly that: `await expect(p.push())
14
+ * .resolves.toBe(0)`, under the title "builds a working stub". It passed for the
15
+ * whole life of the defect.
16
+ *
17
+ * So these assert the crossing — what ends up in the local store, and what the
18
+ * count means — rather than that the methods are callable.
19
+ */
20
+ const ENGINE = 'http://engine.test';
21
+ /** Rows the fake engine holds, by collection. */
22
+ let served = {};
23
+ /** Every request the provider made, so "did it even ask" is answerable. */
24
+ let requests = [];
25
+ const realFetch = globalThis.fetch;
26
+ beforeEach(async () => {
27
+ served = {};
28
+ requests = [];
29
+ globalThis.fetch = (async (input, init) => {
30
+ const url = typeof input === 'string' ? input : input.toString();
31
+ requests.push(`${init?.method ?? 'GET'} ${url.replace(ENGINE, '')}`);
32
+ const match = url.match(/\/api\/data\/([^/?]+)/);
33
+ const name = match?.[1] ?? '';
34
+ const page = Number(new URL(url).searchParams.get('page') ?? '1');
35
+ // One page only: page 2 comes back empty, which is how `pull` stops.
36
+ const data = page === 1 ? (served[name] ?? []) : [];
37
+ return new Response(JSON.stringify({ data, total: data.length, page, limit: 200 }), {
38
+ status: 200,
39
+ headers: { 'content-type': 'application/json' },
40
+ });
41
+ });
42
+ const store = new LocalStore();
43
+ await store.open();
44
+ await store.clear();
45
+ await store.close();
46
+ });
47
+ afterEach(() => {
48
+ globalThis.fetch = realFetch;
49
+ });
50
+ async function localRows(collection) {
51
+ const store = new LocalStore();
52
+ await store.open();
53
+ const rows = await store.list(collection);
54
+ await store.close();
55
+ return rows;
56
+ }
57
+ describe('createOfflineProvider — the crdt path actually syncs', () => {
58
+ it('is the default provider, which is why the no-op mattered', async () => {
59
+ const p = await createOfflineProvider({ engineUrl: ENGINE });
60
+ expect(p.kind).toBe('crdt');
61
+ await p.close();
62
+ });
63
+ it('pull writes the server rows into the local store', async () => {
64
+ served.orders = [
65
+ { id: 'o-1', total: 10, status: 'new' },
66
+ { id: 'o-2', total: 20, status: 'paid' },
67
+ ];
68
+ const p = await createOfflineProvider({ engineUrl: ENGINE, tables: ['orders'] });
69
+ await p.pull();
70
+ await p.close();
71
+ const rows = await localRows('orders');
72
+ expect(rows.map((r) => r.id).sort()).toEqual(['o-1', 'o-2']);
73
+ expect(rows.find((r) => r.id === 'o-1')?.data.total).toBe(10);
74
+ // The old implementation made no request at all. This is the assertion that
75
+ // would have caught it.
76
+ expect(requests.some((r) => r.startsWith('GET /api/data/orders'))).toBe(true);
77
+ });
78
+ it('pull refuses an empty table list instead of quietly doing nothing', async () => {
79
+ // Silence here is exactly what the no-op was. A provider asked to replicate
80
+ // nothing is a configuration mistake, and an empty local database looks the
81
+ // same as an empty server.
82
+ const p = await createOfflineProvider({ engineUrl: ENGINE });
83
+ await expect(p.pull()).rejects.toThrow(/tables.*is empty/i);
84
+ await p.close();
85
+ });
86
+ it('push reports how many queued operations it actually sent', async () => {
87
+ const p = await createOfflineProvider({ engineUrl: ENGINE, tables: ['orders'] });
88
+ // Queue two local writes the way an offline application does.
89
+ const store = new LocalStore();
90
+ await store.open();
91
+ await store.put('orders', 'o-9', { total: 99 });
92
+ await store.put('orders', 'o-10', { total: 100 });
93
+ const queued = await store.getPendingOps();
94
+ await store.close();
95
+ expect(queued.length).toBe(2);
96
+ const sent = await p.push();
97
+ await p.close();
98
+ expect(sent).toBe(2);
99
+ // Not just a number: the writes have to have left the machine.
100
+ expect(requests.filter((r) => r.startsWith('POST /api/data/orders')).length).toBe(2);
101
+ });
102
+ it('subscribe delivers the local rows, and the unsubscribe stops delivery', async () => {
103
+ served.orders = [{ id: 'o-1', total: 10 }];
104
+ const p = await createOfflineProvider({ engineUrl: ENGINE, tables: ['orders'] });
105
+ await p.pull();
106
+ const seen = [];
107
+ const off = p.subscribe('orders', (rows) => seen.push(rows));
108
+ await Bun.sleep(20);
109
+ expect(seen.length).toBeGreaterThan(0);
110
+ expect(seen.at(-1).map((r) => r.id)).toEqual(['o-1']);
111
+ off();
112
+ const countAtUnsubscribe = seen.length;
113
+ // A second pull notifies listeners; an unsubscribed callback must not hear it.
114
+ await p.pull();
115
+ await Bun.sleep(20);
116
+ expect(seen.length).toBe(countAtUnsubscribe);
117
+ await p.close();
118
+ });
119
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=realtime.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"realtime.test.d.ts","sourceRoot":"","sources":["../../src/tests/realtime.test.ts"],"names":[],"mappings":""}