@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.
- 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/RealtimeClient.d.ts +19 -1
- package/dist/client/RealtimeClient.d.ts.map +1 -1
- package/dist/client/RealtimeClient.js +65 -12
- package/dist/client/ZveltioClient.d.ts.map +1 -1
- package/dist/client/ZveltioClient.js +12 -1
- package/dist/client.d.ts +28 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +41 -14
- 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 +459 -29
- 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 +3 -1
- 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 +18 -1
- package/dist/realtime.d.ts.map +1 -1
- package/dist/realtime.js +34 -3
- 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 +10 -2
- package/dist/schema-watcher.d.ts.map +1 -1
- package/dist/schema-watcher.js +30 -10
- 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 +61 -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 +284 -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 +503 -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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-store.d.ts","sourceRoot":"","sources":["../src/local-store.ts"],"names":[],"mappings":"AACA,OAAO,EAAoD,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAG/F,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"local-store.d.ts","sourceRoot":"","sources":["../src/local-store.ts"],"names":[],"mappings":"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"}
|
package/dist/local-store.js
CHANGED
|
@@ -46,6 +46,7 @@ export class LocalStore {
|
|
|
46
46
|
await this.db.put('meta', { key: 'crdt_lamport', value: this.clock.current });
|
|
47
47
|
}
|
|
48
48
|
/** Write a local record and add to sync queue */
|
|
49
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
49
50
|
async put(collection, id, data) {
|
|
50
51
|
if (!this.db)
|
|
51
52
|
throw new Error('LocalStore not opened');
|
|
@@ -155,14 +156,33 @@ export class LocalStore {
|
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
158
|
/** Apply data from server (via WebSocket or pull) */
|
|
158
|
-
async applyServerUpdate(collection, id,
|
|
159
|
+
async applyServerUpdate(collection, id,
|
|
160
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
161
|
+
data, serverVersion) {
|
|
159
162
|
if (!this.db)
|
|
160
163
|
throw new Error('LocalStore not opened');
|
|
161
164
|
const existing = (await this.db.get('records', [collection, id]));
|
|
162
|
-
// Conflict detection: local has changes not yet confirmed by server
|
|
163
|
-
|
|
165
|
+
// Conflict detection: local has changes not yet confirmed by server.
|
|
166
|
+
//
|
|
167
|
+
// `_syncStatus` is the signal that survives contact with the SyncManager.
|
|
168
|
+
// The version comparison alone does not: `syncNow()` and `pull()` both pass
|
|
169
|
+
// `Date.now()` as the server version, because the collections API returns no
|
|
170
|
+
// version column — so after the first ACK `_serverVersion` is an epoch
|
|
171
|
+
// millisecond count while `_localVersion` is still a small counter, and
|
|
172
|
+
// `2 > 1790144009198` is false forever. Every local edit made after that
|
|
173
|
+
// first sync was overwritten in silence: status `synced`, no `_conflictData`,
|
|
174
|
+
// nothing in `getConflicts()`. The unit tests missed it because they hand
|
|
175
|
+
// this method versions on the same scale as the counter (1, 2, 3, 5).
|
|
176
|
+
//
|
|
177
|
+
// The version comparison is kept as a second signal: it still catches the
|
|
178
|
+
// race where an ACK for an earlier operation marks the record synced while a
|
|
179
|
+
// later one is queued.
|
|
180
|
+
if (existing &&
|
|
181
|
+
(existing._syncStatus !== 'synced' || existing._localVersion > existing._serverVersion)) {
|
|
164
182
|
// CRDT field-level merge if both sides have CRDT docs
|
|
183
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
165
184
|
if (existing._crdtDoc && data.__crdt) {
|
|
185
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
166
186
|
const remoteCrdtDoc = data.__crdt;
|
|
167
187
|
const merged = mergeLWW(existing._crdtDoc, remoteCrdtDoc);
|
|
168
188
|
const mergedData = fromDocument(merged);
|
|
@@ -192,9 +212,22 @@ export class LocalStore {
|
|
|
192
212
|
_serverVersion: serverVersion,
|
|
193
213
|
_syncStatus: 'synced',
|
|
194
214
|
_updatedAt: Date.now(),
|
|
215
|
+
// Carried over: rebuilding the record from scratch dropped the CRDT
|
|
216
|
+
// document, so the next merge had nothing on the local side to merge with
|
|
217
|
+
// and fell through to the manual-conflict branch.
|
|
218
|
+
...(existing?._crdtDoc ? { _crdtDoc: existing._crdtDoc } : {}),
|
|
195
219
|
};
|
|
196
220
|
await this.db.put('records', record);
|
|
197
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* The server deleted a record: drop the local copy WITHOUT queueing a delete.
|
|
224
|
+
* `delete()` would queue one, echoing the server's own deletion back at it.
|
|
225
|
+
*/
|
|
226
|
+
async applyServerDelete(collection, id) {
|
|
227
|
+
if (!this.db)
|
|
228
|
+
throw new Error('LocalStore not opened');
|
|
229
|
+
await this.db.delete('records', [collection, id]);
|
|
230
|
+
}
|
|
198
231
|
/** Get records with conflicts for UI resolution */
|
|
199
232
|
async getConflicts(collection) {
|
|
200
233
|
if (!this.db)
|
|
@@ -205,16 +238,35 @@ export class LocalStore {
|
|
|
205
238
|
return all;
|
|
206
239
|
}
|
|
207
240
|
/** Resolve a conflict (user decides which version wins) */
|
|
208
|
-
async resolveConflict(collection, id,
|
|
241
|
+
async resolveConflict(collection, id,
|
|
242
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
243
|
+
resolvedData) {
|
|
209
244
|
if (!this.db)
|
|
210
245
|
throw new Error('LocalStore not opened');
|
|
211
246
|
const record = (await this.db.get('records', [collection, id]));
|
|
212
|
-
if (record)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
247
|
+
if (!record)
|
|
248
|
+
return;
|
|
249
|
+
record.data = resolvedData;
|
|
250
|
+
record._syncStatus = 'pending'; // Re-sync with server
|
|
251
|
+
record._localVersion += 1;
|
|
252
|
+
record._conflictData = undefined;
|
|
253
|
+
// Nothing re-synced it. The status said `pending` and the queue held no
|
|
254
|
+
// operation carrying the resolved data, so a resolution — whether a person
|
|
255
|
+
// chose it in the UI or the CRDT merge produced it — stayed on the device.
|
|
256
|
+
// The only operation for this record was the one the server had already
|
|
257
|
+
// rejected.
|
|
258
|
+
const tx = this.db.transaction(['records', 'sync_queue'], 'readwrite');
|
|
259
|
+
await tx.objectStore('records').put(record);
|
|
260
|
+
await tx.objectStore('sync_queue').add({
|
|
261
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
262
|
+
collection,
|
|
263
|
+
recordId: id,
|
|
264
|
+
operation: 'update',
|
|
265
|
+
payload: resolvedData,
|
|
266
|
+
attempts: 0,
|
|
267
|
+
createdAt: Date.now(),
|
|
268
|
+
});
|
|
269
|
+
await tx.done;
|
|
218
270
|
}
|
|
219
271
|
/** Save an offline blob — returns a temporary ID 'local_blob_<UUID>' */
|
|
220
272
|
async saveBlob(blob, collection, recordId, field) {
|
package/dist/offline/index.d.ts
CHANGED
|
@@ -69,9 +69,9 @@ export interface OfflineProviderConfig {
|
|
|
69
69
|
websocket?: typeof WebSocket;
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
|
-
* Public interface every provider implements.
|
|
73
|
-
*
|
|
74
|
-
*
|
|
72
|
+
* Public interface every provider implements. The CRDT provider wraps
|
|
73
|
+
* `SyncManager`; the Electric provider wraps a websocket. Both expose the same
|
|
74
|
+
* surface.
|
|
75
75
|
*/
|
|
76
76
|
export interface OfflineProvider {
|
|
77
77
|
readonly kind: OfflineProviderKind;
|
package/dist/offline/index.js
CHANGED
|
@@ -45,7 +45,7 @@ export class ElectricNotConfigured extends Error {
|
|
|
45
45
|
constructor(reason) {
|
|
46
46
|
super(`Electric SQL provider is not configured: ${reason}. ` +
|
|
47
47
|
`Set provider: 'crdt' for the default sync path, or stand up an Electric ` +
|
|
48
|
-
`service and pass electricUrl. See docs/
|
|
48
|
+
`service and pass electricUrl. See docs/engine/offline-sync.md.`);
|
|
49
49
|
this.name = 'ElectricNotConfigured';
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -76,27 +76,63 @@ export async function createOfflineProvider(config) {
|
|
|
76
76
|
return makeCrdtAdapter(config);
|
|
77
77
|
}
|
|
78
78
|
// ── CRDT adapter (today's working path) ─────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The CRDT provider, wrapping `SyncManager`.
|
|
81
|
+
*
|
|
82
|
+
* It did not wrap anything. `pull()` was an empty body, `subscribe()` returned
|
|
83
|
+
* an unsubscribe that unsubscribed from nothing, and `push()` returned `0` —
|
|
84
|
+
* which is the worst of the four, because `0` reads as "there was nothing to
|
|
85
|
+
* send" rather than "I did not look". `SyncManager` was imported and then
|
|
86
|
+
* discarded with `void SyncManager`, and the interface docs a few lines up said
|
|
87
|
+
* "Today the CRDT provider wraps SyncManager" while it did not.
|
|
88
|
+
*
|
|
89
|
+
* This is the DEFAULT provider (`config.provider ?? 'crdt'`), so an application
|
|
90
|
+
* that called `createOfflineProvider({ engineUrl })` and pushed on a timer got a
|
|
91
|
+
* clean run and an empty server, forever, with nothing in any log.
|
|
92
|
+
*
|
|
93
|
+
* The store is opened but background sync is NOT started: this interface is
|
|
94
|
+
* explicitly pull/push, and a timer firing `syncNow()` underneath would make the
|
|
95
|
+
* number `push()` returns meaningless.
|
|
96
|
+
*/
|
|
97
|
+
async function makeCrdtAdapter(config) {
|
|
98
|
+
// Lazy imports keep the bundle tree-shakeable for consumers who only want the
|
|
99
|
+
// type definitions.
|
|
100
|
+
const [{ SyncManager }, { ZveltioClient }] = await Promise.all([
|
|
101
|
+
import('../sync-manager.js'),
|
|
102
|
+
import('../client.js'),
|
|
103
|
+
]);
|
|
104
|
+
const client = new ZveltioClient({ baseUrl: config.engineUrl });
|
|
105
|
+
const sync = new SyncManager(client);
|
|
106
|
+
await sync.open();
|
|
107
|
+
const tables = config.tables ?? [];
|
|
85
108
|
return {
|
|
86
109
|
kind: 'crdt',
|
|
87
110
|
async pull() {
|
|
88
|
-
|
|
111
|
+
// Silence here is what the old implementation was. A provider asked to
|
|
112
|
+
// pull with nothing to pull from is a configuration mistake, and saying so
|
|
113
|
+
// costs one throw; not saying so costs an empty local database that looks
|
|
114
|
+
// like an empty server.
|
|
115
|
+
if (tables.length === 0) {
|
|
116
|
+
throw new Error('createOfflineProvider: `tables` is empty, so there is nothing to pull. ' +
|
|
117
|
+
'List the collections to replicate, e.g. { tables: ["orders", "customers"] }.');
|
|
118
|
+
}
|
|
119
|
+
await sync.pull(tables);
|
|
89
120
|
},
|
|
90
121
|
async push() {
|
|
91
|
-
|
|
122
|
+
// The count is the drop in pending operations across the sync. Operations
|
|
123
|
+
// that failed stay pending and are therefore not counted, which is what
|
|
124
|
+
// "the number of ops sent" has to mean if the number is to be worth
|
|
125
|
+
// anything.
|
|
126
|
+
const before = await sync.getStatus();
|
|
127
|
+
await sync.syncNow();
|
|
128
|
+
const after = await sync.getStatus();
|
|
129
|
+
return Math.max(0, before.pending - after.pending);
|
|
92
130
|
},
|
|
93
|
-
subscribe(
|
|
94
|
-
return () =>
|
|
95
|
-
/* */
|
|
96
|
-
};
|
|
131
|
+
subscribe(table, cb) {
|
|
132
|
+
return sync.collection(table).subscribe((rows) => cb(rows));
|
|
97
133
|
},
|
|
98
134
|
async close() {
|
|
99
|
-
|
|
135
|
+
await sync.stop();
|
|
100
136
|
},
|
|
101
137
|
};
|
|
102
138
|
}
|
|
@@ -131,7 +167,7 @@ async function makeElectricProvider(config) {
|
|
|
131
167
|
return (await res.json());
|
|
132
168
|
}
|
|
133
169
|
let auth = await mintToken();
|
|
134
|
-
|
|
170
|
+
const electricUrl = config.electricUrl ?? auth.electricUrl;
|
|
135
171
|
// Per-table subscription registry.
|
|
136
172
|
const subscribers = new Map();
|
|
137
173
|
// Token refresh — fire-and-forget background timer that mints a fresh
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/publish/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,+EAA+E;AAC/E,MAAM,WAAW,cAAc;IAC7B,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,SAAS,EAAE,MAAM,CAAC;IAClB,mCAAmC;IACnC,SAAS,EAAE,UAAU,CAAC;IACtB,oDAAoD;IACpD,UAAU,EAAE,UAAU,CAAC;CACxB;AAED,qFAAqF;AACrF,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,SAAS,CAAC;IACrB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,oCAAoC;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC;CACf;AAED,iBAAS,UAAU,CAAC,KAAK,EAAE,WAAW,GAAG,UAAU,GAAG,MAAM,CAK3D;AAED,iBAAS,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAKhD;AAED,iBAAS,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAO9C;AAED,6CAA6C;AAC7C,wBAAsB,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAGlE;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAY7E;AAQD,qEAAqE;AACrE,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAE1E;AAED,yEAAyE;AACzE,wBAAsB,eAAe,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAEzE;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,UAAU,EACnB,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,kBAAkB,CAAC,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/publish/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,+EAA+E;AAC/E,MAAM,WAAW,cAAc;IAC7B,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,SAAS,EAAE,MAAM,CAAC;IAClB,mCAAmC;IACnC,SAAS,EAAE,UAAU,CAAC;IACtB,oDAAoD;IACpD,UAAU,EAAE,UAAU,CAAC;CACxB;AAED,qFAAqF;AACrF,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,SAAS,CAAC;IACrB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,oCAAoC;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC;CACf;AAED,iBAAS,UAAU,CAAC,KAAK,EAAE,WAAW,GAAG,UAAU,GAAG,MAAM,CAK3D;AAED,iBAAS,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAKhD;AAED,iBAAS,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAO9C;AAED,6CAA6C;AAC7C,wBAAsB,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAGlE;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAY7E;AAQD,qEAAqE;AACrE,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAE1E;AAED,yEAAyE;AACzE,wBAAsB,eAAe,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAEzE;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,UAAU,EACnB,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,kBAAkB,CAAC,CAa7B;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,UAAU,EACnB,SAAS,EAAE,kBAAkB,EAC7B,SAAS,EAAE,UAAU,GACpB,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA+BvD;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,UAAU,GACpB,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC,CASlD;AAGD,eAAO,MAAM,MAAM;;;;CAA+C,CAAC"}
|
package/dist/publish/index.js
CHANGED
|
@@ -86,6 +86,7 @@ export async function signBundle(archive, keypair) {
|
|
|
86
86
|
const bundleSha256 = await sha256Hex(archive);
|
|
87
87
|
const key = await importPrivateKey(keypair.privateJwk);
|
|
88
88
|
const dataBytes = new TextEncoder().encode(bundleSha256.toLowerCase());
|
|
89
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
89
90
|
const sigBuf = await crypto.subtle.sign({ name: 'Ed25519' }, key, dataBytes);
|
|
90
91
|
return {
|
|
91
92
|
algorithm: 'ed25519',
|
|
@@ -123,7 +124,11 @@ export async function verifyBundle(archive, signature, publicJwk) {
|
|
|
123
124
|
}
|
|
124
125
|
const key = await importPublicKey(publicJwk);
|
|
125
126
|
const dataBytes = new TextEncoder().encode(signature.bundleSha256.toLowerCase());
|
|
126
|
-
const ok = await crypto.subtle.verify({ name: 'Ed25519' }, key,
|
|
127
|
+
const ok = await crypto.subtle.verify({ name: 'Ed25519' }, key,
|
|
128
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
129
|
+
sigBytes,
|
|
130
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
131
|
+
dataBytes);
|
|
127
132
|
return ok ? { ok: true } : { ok: false, reason: 'Ed25519 verification failed' };
|
|
128
133
|
}
|
|
129
134
|
/**
|
package/dist/realtime.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine closes a socket with this code when the session or API key it
|
|
3
|
+
* opened with is no longer valid (signed out, revoked, expired, user
|
|
4
|
+
* deactivated). Reconnecting is refused with 401, so clients stop instead.
|
|
5
|
+
*/
|
|
6
|
+
export declare const WS_UNAUTHORIZED = 4001;
|
|
1
7
|
export declare class ZveltioRealtime {
|
|
2
8
|
private ws;
|
|
3
9
|
private listeners;
|
|
@@ -5,10 +11,21 @@ export declare class ZveltioRealtime {
|
|
|
5
11
|
private reconnectAttempts;
|
|
6
12
|
private maxReconnectAttempts;
|
|
7
13
|
private reconnectTimer;
|
|
8
|
-
|
|
14
|
+
private headers;
|
|
15
|
+
private unauthorizedCallbacks;
|
|
16
|
+
/**
|
|
17
|
+
* @param options.headers Sent with the upgrade — e.g. `{ 'X-API-Key': key }`
|
|
18
|
+
* for a server-side client. Bun and Node (undici) accept them; a browser
|
|
19
|
+
* cannot set headers on a WebSocket and authenticates by session cookie.
|
|
20
|
+
*/
|
|
21
|
+
constructor(baseUrl: string, options?: {
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
});
|
|
9
24
|
connect(): void;
|
|
10
25
|
private attemptReconnect;
|
|
11
26
|
subscribe(collection: string, callback: (data: any) => void): () => void;
|
|
27
|
+
/** Called when the engine ends the socket because its credentials were revoked. */
|
|
28
|
+
onUnauthorized(callback: () => void): () => void;
|
|
12
29
|
disconnect(): void;
|
|
13
30
|
}
|
|
14
31
|
//# sourceMappingURL=realtime.d.ts.map
|
package/dist/realtime.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"realtime.d.ts","sourceRoot":"","sources":["../src/realtime.ts"],"names":[],"mappings":"AAAA,qBAAa,eAAe;IAC1B,OAAO,CAAC,EAAE,CAA0B;
|
|
1
|
+
{"version":3,"file":"realtime.d.ts","sourceRoot":"","sources":["../src/realtime.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,eAAe,OAAO,CAAC;AAEpC,qBAAa,eAAe;IAC1B,OAAO,CAAC,EAAE,CAA0B;IAEpC,OAAO,CAAC,SAAS,CAAoD;IACrE,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,oBAAoB,CAAM;IAClC,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,OAAO,CAAqC;IACpD,OAAO,CAAC,qBAAqB,CAAyB;IAEtD;;;;OAIG;gBACS,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAO;IAM/E,OAAO,IAAI,IAAI;IAmDf,OAAO,CAAC,gBAAgB;IASxB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,GAAG,MAAM,IAAI;IAqBxE,mFAAmF;IACnF,cAAc,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI;IAKhD,UAAU,IAAI,IAAI;CAQnB"}
|
package/dist/realtime.js
CHANGED
|
@@ -1,17 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine closes a socket with this code when the session or API key it
|
|
3
|
+
* opened with is no longer valid (signed out, revoked, expired, user
|
|
4
|
+
* deactivated). Reconnecting is refused with 401, so clients stop instead.
|
|
5
|
+
*/
|
|
6
|
+
export const WS_UNAUTHORIZED = 4001;
|
|
1
7
|
export class ZveltioRealtime {
|
|
2
8
|
ws = null;
|
|
9
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
3
10
|
listeners = new Map();
|
|
4
11
|
baseUrl;
|
|
5
12
|
reconnectAttempts = 0;
|
|
6
13
|
maxReconnectAttempts = 10;
|
|
7
14
|
reconnectTimer = null;
|
|
8
|
-
|
|
15
|
+
headers;
|
|
16
|
+
unauthorizedCallbacks = new Set();
|
|
17
|
+
/**
|
|
18
|
+
* @param options.headers Sent with the upgrade — e.g. `{ 'X-API-Key': key }`
|
|
19
|
+
* for a server-side client. Bun and Node (undici) accept them; a browser
|
|
20
|
+
* cannot set headers on a WebSocket and authenticates by session cookie.
|
|
21
|
+
*/
|
|
22
|
+
constructor(baseUrl, options = {}) {
|
|
9
23
|
// Convert http(s) to ws(s)
|
|
10
24
|
this.baseUrl = baseUrl.replace(/^http/, 'ws');
|
|
25
|
+
this.headers = options.headers;
|
|
11
26
|
}
|
|
12
27
|
connect() {
|
|
13
28
|
const wsUrl = `${this.baseUrl}/api/ws`;
|
|
14
|
-
|
|
29
|
+
// The two-argument form with `{ headers }` is the Bun/undici extension; the
|
|
30
|
+
// standard constructor stays in use when there is nothing to send.
|
|
31
|
+
this.ws = this.headers
|
|
32
|
+
? new WebSocket(wsUrl, { headers: this.headers })
|
|
33
|
+
: new WebSocket(wsUrl);
|
|
15
34
|
this.ws.onopen = () => {
|
|
16
35
|
this.reconnectAttempts = 0;
|
|
17
36
|
// Re-subscribe to all existing collections using the server's expected protocol
|
|
@@ -40,7 +59,13 @@ export class ZveltioRealtime {
|
|
|
40
59
|
/* invalid JSON — ignore */
|
|
41
60
|
}
|
|
42
61
|
};
|
|
43
|
-
this.ws.onclose = () => {
|
|
62
|
+
this.ws.onclose = (event) => {
|
|
63
|
+
if (event?.code === WS_UNAUTHORIZED) {
|
|
64
|
+
// No retry: every attempt would be refused. `connect()` again once the
|
|
65
|
+
// app holds a valid session or key.
|
|
66
|
+
this.unauthorizedCallbacks.forEach((cb) => cb());
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
44
69
|
this.attemptReconnect();
|
|
45
70
|
};
|
|
46
71
|
this.ws.onerror = () => {
|
|
@@ -55,6 +80,7 @@ export class ZveltioRealtime {
|
|
|
55
80
|
this.reconnectAttempts++;
|
|
56
81
|
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
|
57
82
|
}
|
|
83
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
58
84
|
subscribe(collection, callback) {
|
|
59
85
|
if (!this.listeners.has(collection))
|
|
60
86
|
this.listeners.set(collection, new Set());
|
|
@@ -74,6 +100,11 @@ export class ZveltioRealtime {
|
|
|
74
100
|
}
|
|
75
101
|
};
|
|
76
102
|
}
|
|
103
|
+
/** Called when the engine ends the socket because its credentials were revoked. */
|
|
104
|
+
onUnauthorized(callback) {
|
|
105
|
+
this.unauthorizedCallbacks.add(callback);
|
|
106
|
+
return () => this.unauthorizedCallbacks.delete(callback);
|
|
107
|
+
}
|
|
77
108
|
disconnect() {
|
|
78
109
|
if (this.reconnectTimer)
|
|
79
110
|
clearTimeout(this.reconnectTimer);
|
package/dist/rpc/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/rpc/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACjC,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAEjC,MAAM,WAAW,gBAAgB;IAC/B,mFAAmF;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC5E;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB;AAED;;;;;;GAMG;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/rpc/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACjC,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAEjC,MAAM,WAAW,gBAAgB;IAC/B,mFAAmF;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC5E;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB;AAED;;;;;;GAMG;AAGH,wBAAgB,eAAe,CAAC,CAAC,SAAS,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,EAC3D,IAAI,EAAE,gBAAgB,GACrB,UAAU,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC,CAqB1B;AAID,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/rpc/index.js
CHANGED
|
@@ -38,9 +38,11 @@ import { hc } from 'hono/client';
|
|
|
38
38
|
* `ZveltioApi` from `@zveltio/engine/api-types`. For an
|
|
39
39
|
* extension's own routes, pass `typeof yourApp`.
|
|
40
40
|
*/
|
|
41
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
41
42
|
export function createRpcClient(opts) {
|
|
42
43
|
const baseUrl = opts.baseUrl.replace(/\/$/, '');
|
|
43
44
|
const includeCredentials = opts.includeCredentials !== false;
|
|
45
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy any; tracked in hardening plan item H-01
|
|
44
46
|
const customFetch = (async (input, init) => {
|
|
45
47
|
const dynHeaders = opts.getHeaders ? await opts.getHeaders() : {};
|
|
46
48
|
const merged = new Headers(init?.headers ?? {});
|
package/dist/schema-codegen.d.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* column names in editors and `tsc` catches typos.
|
|
9
9
|
*
|
|
10
10
|
* Scope intentionally narrow:
|
|
11
|
-
* -
|
|
11
|
+
* - Reads only the UP half of each file (everything before `-- DOWN`).
|
|
12
|
+
* - Handles `CREATE TABLE [IF NOT EXISTS]`, `ALTER TABLE … ADD COLUMN`
|
|
13
|
+
* and `ALTER TABLE … DROP COLUMN`.
|
|
12
14
|
* - Maps the common Postgres types we see in extension migrations to TS.
|
|
13
15
|
* - Ignores constraint-only ALTER TABLE statements (PRIMARY KEY, FK).
|
|
14
16
|
* - Ignores CREATE INDEX, CREATE TYPE, CREATE FUNCTION, etc.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-codegen.d.ts","sourceRoot":"","sources":["../src/schema-codegen.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"schema-codegen.d.ts","sourceRoot":"","sources":["../src/schema-codegen.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,mFAAmF;IACnF,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,KAAK,EAAE,CAAC;CACjB;AAoND;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CA+BtD;AA6BD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,YAAY,CAoE7D;AAID,MAAM,WAAW,cAAc;IAC7B,6DAA6D;IAC7D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,YAAY,EAAE,IAAI,GAAE,cAAmB,GAAG,MAAM,CA0BtF"}
|
package/dist/schema-codegen.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* column names in editors and `tsc` catches typos.
|
|
9
9
|
*
|
|
10
10
|
* Scope intentionally narrow:
|
|
11
|
-
* -
|
|
11
|
+
* - Reads only the UP half of each file (everything before `-- DOWN`).
|
|
12
|
+
* - Handles `CREATE TABLE [IF NOT EXISTS]`, `ALTER TABLE … ADD COLUMN`
|
|
13
|
+
* and `ALTER TABLE … DROP COLUMN`.
|
|
12
14
|
* - Maps the common Postgres types we see in extension migrations to TS.
|
|
13
15
|
* - Ignores constraint-only ALTER TABLE statements (PRIMARY KEY, FK).
|
|
14
16
|
* - Ignores CREATE INDEX, CREATE TYPE, CREATE FUNCTION, etc.
|
|
@@ -90,6 +92,19 @@ function mapPgTypeToTs(pgType) {
|
|
|
90
92
|
return isArray ? `${tsBase}[]` : tsBase;
|
|
91
93
|
}
|
|
92
94
|
// ─── SQL parsing ───────────────────────────────────────────────────────────
|
|
95
|
+
/**
|
|
96
|
+
* Keep only the UP half of a migration file. Extension migrations carry their
|
|
97
|
+
* rollback in the same file, after a line that is exactly `-- DOWN`; the
|
|
98
|
+
* engine's runner (`parseMigrationSql`) splits on that marker and only ever
|
|
99
|
+
* executes the UP half. Codegen read both halves, so a `DROP TABLE` /
|
|
100
|
+
* `ADD COLUMN` written for rollback described the generated types as much as
|
|
101
|
+
* the real schema did. Same anchored regex as the runner — a loose `-- DOWN`
|
|
102
|
+
* prefix match once cost the engine half a schema.
|
|
103
|
+
*/
|
|
104
|
+
function upSection(sql) {
|
|
105
|
+
const i = sql.search(/^--\s*DOWN\s*$/im);
|
|
106
|
+
return i < 0 ? sql : sql.slice(0, i);
|
|
107
|
+
}
|
|
93
108
|
/**
|
|
94
109
|
* Strip line comments (`-- …`) and block comments (`/* … * /`) so they don't
|
|
95
110
|
* confuse the table/column regexes. Dollar-quoted blocks are preserved
|
|
@@ -301,13 +316,18 @@ export function parseSchema(sqlChunks) {
|
|
|
301
316
|
}
|
|
302
317
|
return tables.get(name);
|
|
303
318
|
};
|
|
304
|
-
const allSql = sqlChunks.map(stripComments).join('\n');
|
|
319
|
+
const allSql = sqlChunks.map(upSection).map(stripComments).join('\n');
|
|
305
320
|
// CREATE TABLE [IF NOT EXISTS] <name> ( <body> )
|
|
306
321
|
const createRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|([a-zA-Z_][a-zA-Z0-9_]*))\s*\(/gi;
|
|
307
|
-
|
|
308
|
-
while ((m = createRe.exec(allSql)) !== null) {
|
|
322
|
+
for (const m of allSql.matchAll(createRe)) {
|
|
309
323
|
const tableName = m[1] ?? m[2];
|
|
310
|
-
|
|
324
|
+
// From the MATCH, not from the regex's `lastIndex`.
|
|
325
|
+
//
|
|
326
|
+
// `matchAll` clones the regex internally, so `createRe.lastIndex` stays 0
|
|
327
|
+
// here — it is only advanced by `exec`. Converting this loop from
|
|
328
|
+
// `while ((m = createRe.exec(...)))` and keeping `lastIndex` silently made
|
|
329
|
+
// every table body start at the top of the file; five tests caught it.
|
|
330
|
+
const openIdx = m.index + m[0].length - 1; // position of '('
|
|
311
331
|
const closeIdx = findMatchingParen(allSql, openIdx);
|
|
312
332
|
if (closeIdx < 0)
|
|
313
333
|
continue; // unbalanced — skip
|
|
@@ -316,11 +336,10 @@ export function parseSchema(sqlChunks) {
|
|
|
316
336
|
const tableCols = ensureTable(tableName);
|
|
317
337
|
for (const c of columns)
|
|
318
338
|
tableCols.set(c.name, c);
|
|
319
|
-
createRe.lastIndex = closeIdx + 1;
|
|
320
339
|
}
|
|
321
340
|
// ALTER TABLE <name> ADD COLUMN [IF NOT EXISTS] <col> <type> <rest>
|
|
322
341
|
const alterRe = /ALTER\s+TABLE\s+(?:"([^"]+)"|([a-zA-Z_][a-zA-Z0-9_]*))\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|([a-zA-Z_][a-zA-Z0-9_]*))\s+([^;]+?)(?=;|$)/gi;
|
|
323
|
-
|
|
342
|
+
for (const m of allSql.matchAll(alterRe)) {
|
|
324
343
|
const tableName = m[1] ?? m[2];
|
|
325
344
|
const colName = m[3] ?? m[4];
|
|
326
345
|
const tail = m[5];
|
|
@@ -330,6 +349,21 @@ export function parseSchema(sqlChunks) {
|
|
|
330
349
|
tableCols.set(col.name, col);
|
|
331
350
|
}
|
|
332
351
|
}
|
|
352
|
+
// ALTER TABLE <name> DROP COLUMN [IF EXISTS] <col>
|
|
353
|
+
//
|
|
354
|
+
// Run last, after every CREATE and ADD COLUMN: a column the migrations
|
|
355
|
+
// removed used to stay in the generated types, so `ctx.db` promised a
|
|
356
|
+
// column Postgres no longer has — the silent runtime failure this module
|
|
357
|
+
// exists to prevent.
|
|
358
|
+
//
|
|
359
|
+
// ponytail: a third pass, not an ordered walk of the statements. A column
|
|
360
|
+
// dropped and then re-added by a later migration would be lost; no
|
|
361
|
+
// extension does that today. Make the parser statement-ordered if one does.
|
|
362
|
+
const dropRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:"([^"]+)"|([a-zA-Z_][a-zA-Z0-9_]*))\s+DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?(?:"([^"]+)"|([a-zA-Z_][a-zA-Z0-9_]*))/gi;
|
|
363
|
+
for (const m of allSql.matchAll(dropRe)) {
|
|
364
|
+
// Never `ensureTable` here — a DROP alone must not invent a table.
|
|
365
|
+
tables.get(m[1] ?? m[2])?.delete(m[3] ?? m[4]);
|
|
366
|
+
}
|
|
333
367
|
return {
|
|
334
368
|
tables: order.map((name) => ({ name, columns: [...tables.get(name).values()] })),
|
|
335
369
|
};
|
package/dist/schema-watcher.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* updated, or deleted.
|
|
7
7
|
*
|
|
8
8
|
* Usage (in `zveltio dev --watch`):
|
|
9
|
-
* import { watchSchema } from '@zveltio/sdk
|
|
9
|
+
* import { watchSchema } from '@zveltio/sdk';
|
|
10
10
|
* await watchSchema('http://localhost:3000', './src/zveltio-types.d.ts');
|
|
11
11
|
*/
|
|
12
12
|
export interface CollectionField {
|
|
@@ -20,8 +20,16 @@ export interface CollectionSchema {
|
|
|
20
20
|
fields: CollectionField[];
|
|
21
21
|
}
|
|
22
22
|
export interface WatchSchemaOptions {
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* A root-tenant API key holding the explicit scope
|
|
25
|
+
* `{ collection: '$schema', actions: ['read'] }` — a `*` scope does not include it.
|
|
26
|
+
*/
|
|
24
27
|
apiKey?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Extra headers for the collections fetch and the socket upgrade — e.g. a
|
|
30
|
+
* `cookie` carrying the session of a user who may alter collections.
|
|
31
|
+
*/
|
|
32
|
+
headers?: Record<string, string>;
|
|
25
33
|
/** Reconnect delay in ms (default: 3000) */
|
|
26
34
|
reconnectDelay?: number;
|
|
27
35
|
/** Called after each successful type regeneration */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-watcher.d.ts","sourceRoot":"","sources":["../src/schema-watcher.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;
|
|
1
|
+
{"version":3,"file":"schema-watcher.d.ts","sourceRoot":"","sources":["../src/schema-watcher.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,4CAA4C;IAC5C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,CAAC,WAAW,EAAE,gBAAgB,EAAE,KAAK,IAAI,CAAC;IACrD,kCAAkC;IAClC,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAC;CAChC;AA8GD;;;;;GAKG;AACH,wBAAsB,WAAW,CAC/B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,MAAM,IAAI,CAAC,CA4FrB;AAED,iDAAiD;AACjD,wBAAsB,aAAa,CACjC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,IAAI,CAAC,CAKf"}
|
package/dist/schema-watcher.js
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* updated, or deleted.
|
|
7
7
|
*
|
|
8
8
|
* Usage (in `zveltio dev --watch`):
|
|
9
|
-
* import { watchSchema } from '@zveltio/sdk
|
|
9
|
+
* import { watchSchema } from '@zveltio/sdk';
|
|
10
10
|
* await watchSchema('http://localhost:3000', './src/zveltio-types.d.ts');
|
|
11
11
|
*/
|
|
12
|
+
import { WS_UNAUTHORIZED } from './realtime.js';
|
|
12
13
|
const TYPE_MAP = {
|
|
13
14
|
text: 'string',
|
|
14
15
|
textarea: 'string',
|
|
@@ -81,8 +82,8 @@ function toPascalCase(str) {
|
|
|
81
82
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
82
83
|
.join('');
|
|
83
84
|
}
|
|
84
|
-
async function fetchCollections(engineUrl, apiKey) {
|
|
85
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
85
|
+
async function fetchCollections(engineUrl, apiKey, extra) {
|
|
86
|
+
const headers = { 'Content-Type': 'application/json', ...extra };
|
|
86
87
|
if (apiKey)
|
|
87
88
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
88
89
|
const res = await fetch(`${engineUrl}/api/collections`, { headers });
|
|
@@ -108,10 +109,10 @@ async function writeTypes(outputPath, content) {
|
|
|
108
109
|
* Returns a cleanup function that closes the WebSocket.
|
|
109
110
|
*/
|
|
110
111
|
export async function watchSchema(engineUrl, outputPath, options = {}) {
|
|
111
|
-
const { apiKey, reconnectDelay = 3000, onUpdate, onError } = options;
|
|
112
|
+
const { apiKey, headers, reconnectDelay = 3000, onUpdate, onError } = options;
|
|
112
113
|
// Generate initial types on startup
|
|
113
114
|
try {
|
|
114
|
-
const collections = await fetchCollections(engineUrl, apiKey);
|
|
115
|
+
const collections = await fetchCollections(engineUrl, apiKey, headers);
|
|
115
116
|
const content = generateTypesFile(collections);
|
|
116
117
|
await writeTypes(outputPath, content);
|
|
117
118
|
console.log(`[zveltio] ✓ Types generated at ${outputPath} (${collections.length} collections)`);
|
|
@@ -128,21 +129,34 @@ export async function watchSchema(engineUrl, outputPath, options = {}) {
|
|
|
128
129
|
function connect() {
|
|
129
130
|
if (stopped)
|
|
130
131
|
return;
|
|
131
|
-
|
|
132
|
+
// The key `fetchCollections` sends, on the upgrade too: without it the
|
|
133
|
+
// engine refuses the socket (401) and the watcher only ever reconnected.
|
|
134
|
+
// The two-argument form is the Bun/undici extension; a browser cannot set it.
|
|
135
|
+
const upgradeHeaders = { ...headers, ...(apiKey ? { 'X-API-Key': apiKey } : {}) };
|
|
136
|
+
ws =
|
|
137
|
+
Object.keys(upgradeHeaders).length > 0
|
|
138
|
+
? new WebSocket(wsUrl, { headers: upgradeHeaders })
|
|
139
|
+
: new WebSocket(wsUrl);
|
|
132
140
|
ws.onopen = () => {
|
|
133
141
|
console.log('[zveltio] Schema watcher connected.');
|
|
134
|
-
//
|
|
135
|
-
|
|
142
|
+
// The engine's schema channel: `$` cannot start a collection name, so it
|
|
143
|
+
// never collides with a collection's record events.
|
|
144
|
+
ws.send(JSON.stringify({ type: 'subscribe', channel: '$schema' }));
|
|
136
145
|
};
|
|
137
146
|
ws.onmessage = async (event) => {
|
|
138
147
|
try {
|
|
139
148
|
const msg = JSON.parse(event.data);
|
|
149
|
+
// The subscribe was refused: this principal may not alter collections.
|
|
150
|
+
if (msg.type === 'error') {
|
|
151
|
+
onError?.(new Error(`Schema watcher: ${msg.message ?? 'subscribe refused'}`));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
140
154
|
const isSchemaChange = msg.event === 'schema:changed' ||
|
|
141
155
|
msg.type === 'schema:changed' ||
|
|
142
156
|
['create_collection', 'drop_collection', 'add_field', 'drop_field'].includes(msg.event);
|
|
143
157
|
if (!isSchemaChange)
|
|
144
158
|
return;
|
|
145
|
-
const collections = await fetchCollections(engineUrl, apiKey);
|
|
159
|
+
const collections = await fetchCollections(engineUrl, apiKey, headers);
|
|
146
160
|
const content = generateTypesFile(collections);
|
|
147
161
|
await writeTypes(outputPath, content);
|
|
148
162
|
const changed = msg.collection ?? msg.data?.collection ?? '?';
|
|
@@ -153,7 +167,13 @@ export async function watchSchema(engineUrl, outputPath, options = {}) {
|
|
|
153
167
|
// Non-fatal: keep watching
|
|
154
168
|
}
|
|
155
169
|
};
|
|
156
|
-
ws.onclose = () => {
|
|
170
|
+
ws.onclose = (event) => {
|
|
171
|
+
if (!stopped && event?.code === WS_UNAUTHORIZED) {
|
|
172
|
+
// The API key was revoked or expired: every retry would be refused.
|
|
173
|
+
stopped = true;
|
|
174
|
+
onError?.(new Error('Schema watcher stopped: the engine revoked its credentials.'));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
157
177
|
if (!stopped) {
|
|
158
178
|
console.log(`[zveltio] Schema watcher disconnected. Reconnecting in ${reconnectDelay}ms…`);
|
|
159
179
|
setTimeout(connect, reconnectDelay);
|