@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
|
@@ -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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"realtime.test.d.ts","sourceRoot":"","sources":["../../src/tests/realtime.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'bun:test';
|
|
2
|
+
import { ZveltioRealtime } from '../realtime.js';
|
|
3
|
+
const RealWebSocket = globalThis.WebSocket;
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
globalThis.WebSocket = RealWebSocket;
|
|
6
|
+
});
|
|
7
|
+
/** Records the constructor arguments instead of dialling out. */
|
|
8
|
+
function captureSockets() {
|
|
9
|
+
const calls = [];
|
|
10
|
+
globalThis.WebSocket = class {
|
|
11
|
+
static OPEN = 1;
|
|
12
|
+
readyState = 0;
|
|
13
|
+
constructor(...args) {
|
|
14
|
+
calls.push(args);
|
|
15
|
+
}
|
|
16
|
+
close() { }
|
|
17
|
+
send() { }
|
|
18
|
+
};
|
|
19
|
+
return calls;
|
|
20
|
+
}
|
|
21
|
+
describe('ZveltioRealtime upgrade headers', () => {
|
|
22
|
+
// `/api/ws` accepts an API key in `X-API-Key`; a server-side client had no
|
|
23
|
+
// way to send one, so it could read a collection over REST and never
|
|
24
|
+
// subscribe to it.
|
|
25
|
+
it('passes headers to the socket when given', () => {
|
|
26
|
+
const calls = captureSockets();
|
|
27
|
+
const rt = new ZveltioRealtime('https://engine.test', { headers: { 'X-API-Key': 'zvk_x' } });
|
|
28
|
+
rt.connect();
|
|
29
|
+
expect(calls[0]).toEqual(['wss://engine.test/api/ws', { headers: { 'X-API-Key': 'zvk_x' } }]);
|
|
30
|
+
rt.disconnect();
|
|
31
|
+
});
|
|
32
|
+
it('uses the one-argument constructor without them', () => {
|
|
33
|
+
const calls = captureSockets();
|
|
34
|
+
const rt = new ZveltioRealtime('http://engine.test');
|
|
35
|
+
rt.connect();
|
|
36
|
+
expect(calls[0]).toEqual(['ws://engine.test/api/ws']);
|
|
37
|
+
rt.disconnect();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-manager.test.d.ts","sourceRoot":"","sources":["../../src/tests/sync-manager.test.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,CAAC"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import './setup';
|
|
2
|
+
import { describe, it, expect, afterEach } from 'bun:test';
|
|
3
|
+
import { SyncManager } from '../sync-manager.js';
|
|
4
|
+
/**
|
|
5
|
+
* A client whose `get` answers the server's copy of a record — the only call the
|
|
6
|
+
* realtime handler makes.
|
|
7
|
+
*/
|
|
8
|
+
function fakeClient() {
|
|
9
|
+
return {
|
|
10
|
+
collection: () => ({ get: async (id) => ({ id, title: 'from server' }) }),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Stands in for `ZveltioRealtime`; `push` delivers a frame as the socket would. */
|
|
14
|
+
function fakeRealtime() {
|
|
15
|
+
let handler;
|
|
16
|
+
return {
|
|
17
|
+
subscribe: (_collection, cb) => {
|
|
18
|
+
handler = cb;
|
|
19
|
+
return () => { };
|
|
20
|
+
},
|
|
21
|
+
disconnect: () => { },
|
|
22
|
+
push: async (msg) => handler?.(msg),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
let sync;
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await sync?.stop();
|
|
28
|
+
sync = undefined;
|
|
29
|
+
});
|
|
30
|
+
describe('SyncManager — realtime frames as the engine sends them', () => {
|
|
31
|
+
// The frame shape is `broadcastEvent` in packages/engine/src/routes/ws.ts.
|
|
32
|
+
// The handler used to wait for `record.created` / `record_id`, names the
|
|
33
|
+
// socket never sends, so no server push ever reached the local store.
|
|
34
|
+
it('applies an insert and drops a delete without queueing one back', async () => {
|
|
35
|
+
sync = new SyncManager(fakeClient(), { syncInterval: 60_000 });
|
|
36
|
+
await sync.start();
|
|
37
|
+
const store = sync.store;
|
|
38
|
+
await store.clear();
|
|
39
|
+
const rt = fakeRealtime();
|
|
40
|
+
sync.realtime = rt;
|
|
41
|
+
let latest = [];
|
|
42
|
+
sync.collection('posts').subscribe((records) => {
|
|
43
|
+
latest = records;
|
|
44
|
+
});
|
|
45
|
+
await rt.push({ type: 'event', collection: 'posts', event: 'insert', data: { id: 'p1' } });
|
|
46
|
+
expect((await store.get('posts', 'p1'))?.data).toMatchObject({ title: 'from server' });
|
|
47
|
+
expect(latest.map((r) => r.id)).toEqual(['p1']);
|
|
48
|
+
await rt.push({ type: 'event', collection: 'posts', event: 'delete', data: { id: 'p1' } });
|
|
49
|
+
expect(await store.get('posts', 'p1')).toBeUndefined();
|
|
50
|
+
// A queued delete here would send the server's own deletion back to it.
|
|
51
|
+
expect(await store.getPendingOps()).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('SyncManager — stop() before start() settles', () => {
|
|
55
|
+
// A React effect cleanup (and StrictMode, on every mount in development) calls
|
|
56
|
+
// `stop()` while `start()` is still awaiting the store. `start()` then armed
|
|
57
|
+
// the sync interval anyway, and nothing was left to clear it.
|
|
58
|
+
it('leaves no timer armed', async () => {
|
|
59
|
+
sync = new SyncManager(fakeClient(), { syncInterval: 60_000 });
|
|
60
|
+
const starting = sync.start();
|
|
61
|
+
await sync.stop();
|
|
62
|
+
await starting;
|
|
63
|
+
expect(sync.syncTimer).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"with-test-db.test.d.ts","sourceRoot":"","sources":["../../src/tests/with-test-db.test.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,CAAC"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import './setup';
|
|
2
|
+
import { describe, it, expect } from 'bun:test';
|
|
3
|
+
import { Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler, } from 'kysely';
|
|
4
|
+
import { applyMigrationStrings } from '../testing/with-test-db.js';
|
|
5
|
+
/**
|
|
6
|
+
* The harness other suites trust.
|
|
7
|
+
*
|
|
8
|
+
* A Zveltio migration keeps its rollback in the same file behind a `-- DOWN`
|
|
9
|
+
* marker, and the documented way to use this helper is to glob
|
|
10
|
+
* `engine/migrations/*.sql` and hand every file to it. Running the whole file
|
|
11
|
+
* creates the schema and then drops it again inside the same call.
|
|
12
|
+
*/
|
|
13
|
+
function recordingDb(sink) {
|
|
14
|
+
const connection = {
|
|
15
|
+
async executeQuery(compiled) {
|
|
16
|
+
sink.push(compiled.sql);
|
|
17
|
+
return { rows: [] };
|
|
18
|
+
},
|
|
19
|
+
async *streamQuery() { },
|
|
20
|
+
};
|
|
21
|
+
const driver = {
|
|
22
|
+
async init() { },
|
|
23
|
+
async acquireConnection() {
|
|
24
|
+
return connection;
|
|
25
|
+
},
|
|
26
|
+
async beginTransaction() { },
|
|
27
|
+
async commitTransaction() { },
|
|
28
|
+
async rollbackTransaction() { },
|
|
29
|
+
async releaseConnection() { },
|
|
30
|
+
async destroy() { },
|
|
31
|
+
};
|
|
32
|
+
return new Kysely({
|
|
33
|
+
dialect: {
|
|
34
|
+
createAdapter: () => new PostgresAdapter(),
|
|
35
|
+
createDriver: () => driver,
|
|
36
|
+
createIntrospector: (db) => new PostgresIntrospector(db),
|
|
37
|
+
createQueryCompiler: () => new PostgresQueryCompiler(),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
describe('applyMigrationStrings', () => {
|
|
42
|
+
it('does not run the DOWN half of a migration', async () => {
|
|
43
|
+
const executed = [];
|
|
44
|
+
const db = recordingDb(executed);
|
|
45
|
+
await applyMigrationStrings(db, ['CREATE TABLE zvd_t (id TEXT);\n-- DOWN\nDROP TABLE zvd_t;']);
|
|
46
|
+
expect(executed.join('\n')).toContain('CREATE TABLE');
|
|
47
|
+
expect(executed.join('\n'), 'the rollback ran against the schema it had just created').not.toContain('DROP TABLE');
|
|
48
|
+
await db.destroy();
|
|
49
|
+
});
|
|
50
|
+
it('still runs every statement of a file with no DOWN section', async () => {
|
|
51
|
+
const executed = [];
|
|
52
|
+
const db = recordingDb(executed);
|
|
53
|
+
await applyMigrationStrings(db, ['CREATE TABLE a (id TEXT);\nCREATE TABLE b (id TEXT);']);
|
|
54
|
+
expect(executed).toHaveLength(2);
|
|
55
|
+
await db.destroy();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAEvB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAGD,MAAM,WAAW,aAAa,CAAC,CAAC,GAAG,GAAG;IACpC,OAAO,EAAE,CAAC,EAAE,CAAC;IACb,UAAU,EAAE;QACV,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,KAAK,EAAE,MAAM,CAAC;KACf,CAAC;CACH;AAGD,MAAM,WAAW,cAAc,CAAC,CAAC,GAAG,GAAG;IACrC,MAAM,EAAE,CAAC,CAAC;CACX;AAGD,MAAM,WAAW,cAAc,CAAC,CAAC,GAAG,GAAG;IACrC,MAAM,EAAE,CAAC,CAAC;CACX;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC;IAEnB,IAAI,EAAE,GAAG,CAAC;IACV,SAAS,EAAE,MAAM,CAAC;CACnB"}
|
package/dist/validate/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* - Building the Studio bundle. Too slow; pair with `zveltio extension build`
|
|
24
24
|
* which can call validate as a sub-step.
|
|
25
25
|
*/
|
|
26
|
+
export { SHARED_MESSAGE_KEYS } from './shared-message-keys.js';
|
|
26
27
|
export interface ValidationError {
|
|
27
28
|
/** Stable machine-readable code (e.g. `MANIFEST_NAME_MISMATCH`). */
|
|
28
29
|
code: string;
|
|
@@ -30,6 +31,8 @@ export interface ValidationError {
|
|
|
30
31
|
message: string;
|
|
31
32
|
/** Optional file path the error applies to, relative to the extension root. */
|
|
32
33
|
file?: string;
|
|
34
|
+
/** `warning` items are surfaced but do NOT fail validation (`result.ok`). */
|
|
35
|
+
severity?: 'error' | 'warning';
|
|
33
36
|
}
|
|
34
37
|
export interface ValidationResult {
|
|
35
38
|
ok: boolean;
|
|
@@ -79,6 +82,52 @@ export interface BundleSizeInput {
|
|
|
79
82
|
bundleSizeKbMax?: number;
|
|
80
83
|
}
|
|
81
84
|
export declare function validateBundleSize(input: BundleSizeInput): ValidationError[];
|
|
85
|
+
/** Read-only core endpoints a schema is legitimately allowed to call (relation
|
|
86
|
+
* pickers etc.). Keep this list tiny and read-only. */
|
|
87
|
+
export declare const SDUI_SHARED_READ_ALLOWLIST: ReadonlySet<string>;
|
|
88
|
+
/**
|
|
89
|
+
* Schema keys whose value is user-visible text.
|
|
90
|
+
*
|
|
91
|
+
* The host renders these through `t()`, which looks the string up in the
|
|
92
|
+
* message bundle and falls back to the literal. That fallback is why a typo is
|
|
93
|
+
* invisible at runtime: the user simply sees `auth.saml.ui.begin_certificate`
|
|
94
|
+
* rendered as a placeholder. Catching it here is the only place it is cheap.
|
|
95
|
+
*/
|
|
96
|
+
export declare const SDUI_I18N_SLOTS: readonly string[];
|
|
97
|
+
export interface SduiValidationInput {
|
|
98
|
+
/** Parsed schema JSON (a PageSchema or SettingsSchema). */
|
|
99
|
+
schema: unknown;
|
|
100
|
+
/** Owning extension name, e.g. `finance/invoicing`. */
|
|
101
|
+
extName: string;
|
|
102
|
+
/** Route paths the extension's engine actually serves, relative to its mount
|
|
103
|
+
* (e.g. `['/invoices', '/invoices/:id/pay']`). The CLI extracts these from
|
|
104
|
+
* `engine/**` source; pass `undefined` to skip the path-existence check
|
|
105
|
+
* (foreign-namespace check still runs). */
|
|
106
|
+
providedRoutes?: string[];
|
|
107
|
+
/** Extension names this one declares as dependencies (manifest.dependencies).
|
|
108
|
+
* A schema may read a dependency's `/ext/<dep>/` routes (relation pickers),
|
|
109
|
+
* which is otherwise treated as foreign. */
|
|
110
|
+
dependencies?: string[];
|
|
111
|
+
/** Message keys this schema may resolve against: the extension's own
|
|
112
|
+
* `studio/messages/en.json` keys, unioned with `SHARED_MESSAGE_KEYS`.
|
|
113
|
+
* Omit to skip the i18n checks (endpoint checks still run). */
|
|
114
|
+
messageKeys?: Iterable<string>;
|
|
115
|
+
/** For diagnostics. */
|
|
116
|
+
file?: string;
|
|
117
|
+
}
|
|
118
|
+
export declare function validateSduiSchema(input: SduiValidationInput): ValidationError[];
|
|
119
|
+
/** Extract route paths an extension serves from its engine source text.
|
|
120
|
+
*
|
|
121
|
+
* Heuristic over `.get/post/put/patch/delete('…')` and `.route('/prefix', …)`
|
|
122
|
+
* mounts. Nested Hono sub-apps (e.g. `app.route('/query', aiQueryRoutes)`) used
|
|
123
|
+
* to be invisible here, so schemas pointing at the real `/query/history` path
|
|
124
|
+
* failed `SDUI_ENDPOINT_UNKNOWN` while a wrong bare `/history` falsely passed.
|
|
125
|
+
*
|
|
126
|
+
* Mounts register `/prefix` and `/prefix/*`; matching allows any path under that
|
|
127
|
+
* prefix (see `isProvidedRoute`). Parsing each factory body is intentionally
|
|
128
|
+
* avoided — route handlers often contain regexes/`${…}` that defeat brace
|
|
129
|
+
* matching. */
|
|
130
|
+
export declare function extractEngineRoutes(engineSourceTexts: string[]): string[];
|
|
82
131
|
export interface ValidateExtensionInput {
|
|
83
132
|
manifest: ManifestValidationInput;
|
|
84
133
|
peerDeps: PeerDepsValidationInput;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/validate/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAIH,MAAM,WAAW,eAAe;IAC9B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/validate/index.ts"],"names":[],"mappings":"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"}
|