@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
@@ -1 +1 @@
1
- {"version":3,"file":"local-store.d.ts","sourceRoot":"","sources":["../src/local-store.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"local-store.d.ts","sourceRoot":"","sources":["../src/local-store.ts"],"names":[],"mappings":"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"}
@@ -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, data, serverVersion) {
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
- if (existing && existing._localVersion > existing._serverVersion) {
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, resolvedData) {
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
- record.data = resolvedData;
214
- record._syncStatus = 'pending'; // Re-sync with server
215
- record._localVersion += 1;
216
- await this.db.put('records', record);
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) {
@@ -69,9 +69,9 @@ export interface OfflineProviderConfig {
69
69
  websocket?: typeof WebSocket;
70
70
  }
71
71
  /**
72
- * Public interface every provider implements. Today the CRDT provider
73
- * wraps `SyncManager`; the Electric provider wraps a websocket. Both
74
- * expose the same surface.
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;
@@ -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/OFFLINE-SYNC.md.`);
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
- async function makeCrdtAdapter(_config) {
80
- // The existing SyncManager lives in `sync-manager.ts`. We don't import
81
- // it at the top of this file to keep the bundle tree-shakeable for
82
- // consumers who only want the type definitions. Lazy import here.
83
- const { SyncManager } = await import('../sync-manager.js');
84
- void SyncManager;
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
- /* delegated to SyncManager in the full impl */
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
- return 0;
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(_table, _cb) {
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
- let electricUrl = config.electricUrl ?? auth.electricUrl;
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,CAY7B;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,CA6BvD;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"}
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"}
@@ -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, sigBytes, dataBytes);
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
  /**
@@ -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
- constructor(baseUrl: string);
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
@@ -1 +1 @@
1
- {"version":3,"file":"realtime.d.ts","sourceRoot":"","sources":["../src/realtime.ts"],"names":[],"mappings":"AAAA,qBAAa,eAAe;IAC1B,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,SAAS,CAAoD;IACrE,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,oBAAoB,CAAM;IAClC,OAAO,CAAC,cAAc,CAA8C;gBAExD,OAAO,EAAE,MAAM;IAK3B,OAAO,IAAI,IAAI;IAyCf,OAAO,CAAC,gBAAgB;IAQxB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,GAAG,MAAM,IAAI;IAqBxE,UAAU,IAAI,IAAI;CAQnB"}
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
- constructor(baseUrl) {
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
- this.ws = new WebSocket(wsUrl);
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);
@@ -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;AACH,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,CAoB1B;AAID,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
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 ?? {});
@@ -8,7 +8,9 @@
8
8
  * column names in editors and `tsc` catches typos.
9
9
  *
10
10
  * Scope intentionally narrow:
11
- * - Handles `CREATE TABLE [IF NOT EXISTS]` and `ALTER TABLE … ADD COLUMN`.
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;;;;;;;;;;;;;;;;;;GAkBG;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;AAsMD;;;;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,CA+C7D;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"}
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"}
@@ -8,7 +8,9 @@
8
8
  * column names in editors and `tsc` catches typos.
9
9
  *
10
10
  * Scope intentionally narrow:
11
- * - Handles `CREATE TABLE [IF NOT EXISTS]` and `ALTER TABLE … ADD COLUMN`.
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
- let m;
308
- while ((m = createRe.exec(allSql)) !== null) {
322
+ for (const m of allSql.matchAll(createRe)) {
309
323
  const tableName = m[1] ?? m[2];
310
- const openIdx = createRe.lastIndex - 1; // position of '('
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
- while ((m = alterRe.exec(allSql)) !== null) {
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
  };
@@ -6,7 +6,7 @@
6
6
  * updated, or deleted.
7
7
  *
8
8
  * Usage (in `zveltio dev --watch`):
9
- * import { watchSchema } from '@zveltio/sdk/schema-watcher';
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
- /** API token for the engine (admin key) */
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;AAEH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,kBAAkB;IACjC,2CAA2C;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4CAA4C;IAC5C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,CAAC,WAAW,EAAE,gBAAgB,EAAE,KAAK,IAAI,CAAC;IACrD,kCAAkC;IAClC,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAC;CAChC;AA0GD;;;;;GAKG;AACH,wBAAsB,WAAW,CAC/B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,MAAM,IAAI,CAAC,CAyErB;AAED,iDAAiD;AACjD,wBAAsB,aAAa,CACjC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,IAAI,CAAC,CAKf"}
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"}
@@ -6,9 +6,10 @@
6
6
  * updated, or deleted.
7
7
  *
8
8
  * Usage (in `zveltio dev --watch`):
9
- * import { watchSchema } from '@zveltio/sdk/schema-watcher';
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
- ws = new WebSocket(wsUrl);
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
- // Subscribe to all schema change events
135
- ws.send(JSON.stringify({ type: 'subscribe', channel: 'schema' }));
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);