@lix-js/sdk 0.10.0 → 0.12.0

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 (42) hide show
  1. package/README.md +49 -78
  2. package/dist/binding-types.d.ts +9 -18
  3. package/dist/binding.browser.js +18 -5
  4. package/dist/binding.node.js +5 -11
  5. package/dist/bundled-plugins/plugin_csv.lixplugin +0 -0
  6. package/dist/bundled-plugins/plugin_markdown.lixplugin +0 -0
  7. package/dist/errors.d.ts +0 -2
  8. package/dist/errors.js +0 -13
  9. package/dist/index.d.ts +3 -3
  10. package/dist/index.js +1 -1
  11. package/dist/indexeddb-backend.d.ts +19 -0
  12. package/dist/indexeddb-backend.js +121 -0
  13. package/dist/lix.d.ts +3 -6
  14. package/dist/lix.js +28 -54
  15. package/dist/open-lix.d.ts +4 -13
  16. package/dist/open-lix.js +45 -152
  17. package/dist/remote/client.d.ts +2 -3
  18. package/dist/remote/client.js +50 -23
  19. package/dist/remote/{protocol.d.ts → server-protocol.d.ts} +40 -32
  20. package/dist/remote/{protocol.js → server-protocol.js} +36 -17
  21. package/dist/result.d.ts +2 -1
  22. package/dist/result.js +12 -0
  23. package/dist/storage-adapter.d.ts +30 -0
  24. package/dist/storage-adapter.js +12 -0
  25. package/dist/types.d.ts +25 -27
  26. package/dist/value.d.ts +2 -1
  27. package/dist/value.js +23 -10
  28. package/dist/wasm/lix_js_sdk.d.ts +5 -10
  29. package/dist/wasm/lix_js_sdk.js +33 -45
  30. package/dist/wasm/lix_js_sdk_bg.wasm +0 -0
  31. package/dist/wasm/lix_js_sdk_bg.wasm.d.ts +3 -6
  32. package/dist/worker/client.d.ts +1 -12
  33. package/dist/worker/client.js +0 -161
  34. package/dist/worker/host.js +0 -23
  35. package/dist/worker/protocol.d.ts +1 -15
  36. package/package.json +8 -12
  37. package/dist/client-state.d.ts +0 -53
  38. package/dist/client-state.js +0 -318
  39. package/dist/local-storage-adapter.d.ts +0 -26
  40. package/dist/local-storage-adapter.js +0 -117
  41. package/dist/snapshot-persistence.d.ts +0 -7
  42. package/dist/snapshot-persistence.js +0 -26
package/dist/lix.js CHANGED
@@ -1,7 +1,5 @@
1
1
  import { invalidArgument } from "./errors.js";
2
- import { ACTIVE_BRANCH_CLIENT_STATE_KEY, unavailableClientState, } from "./client-state.js";
3
- import { normalizeOptionals, wrapExecuteResult } from "./result.js";
4
- import { isSnapshotPersistenceAfterCommitError } from "./snapshot-persistence.js";
2
+ import { normalizeOptionals, wrapExecuteBatchResult, wrapExecuteResult, } from "./result.js";
5
3
  import { normalizeParam, toNativeValue } from "./value.js";
6
4
  const transactionFinalizer = new FinalizationRegistry(({ transaction, onFinish }) => {
7
5
  void transaction
@@ -17,9 +15,7 @@ const observeFinalizer = new FinalizationRegistry(({ observe, onClose }) => {
17
15
  });
18
16
  export class Lix {
19
17
  binding;
20
- managedClientState;
21
18
  closePromise;
22
- clientState;
23
19
  #activeBranchListeners = new Set();
24
20
  #inFlightOperations = new Set();
25
21
  #observations = new Map();
@@ -27,20 +23,8 @@ export class Lix {
27
23
  #transactionsOpening = 0;
28
24
  #activeTransactions = 0;
29
25
  #acceptingOperations = true;
30
- constructor(binding, managedClientState) {
26
+ constructor(binding) {
31
27
  this.binding = binding;
32
- this.managedClientState = managedClientState;
33
- this.clientState = managedClientState
34
- ? {
35
- get: (key) => managedClientState.get(key),
36
- set: (key, value) => this.#runOperation(() => managedClientState.set(key, value)),
37
- delete: (key) => this.#runOperation(() => managedClientState.delete(key)),
38
- subscribe: (listener) => {
39
- this.#assertAcceptingOperations();
40
- return managedClientState.subscribe(listener);
41
- },
42
- }
43
- : unavailableClientState();
44
28
  }
45
29
  async execute(sql, params = [], options) {
46
30
  assertExecuteArgs("lix", sql, params, options);
@@ -50,7 +34,7 @@ export class Lix {
50
34
  const normalizedStatements = normalizeBatchStatements(statements, options);
51
35
  return this.#runOperation(async () => {
52
36
  const results = await this.binding.executeBatch(normalizedStatements, options);
53
- return results.map(wrapExecuteResult);
37
+ return results.map(wrapExecuteBatchResult);
54
38
  });
55
39
  }
56
40
  observe(sql, params = []) {
@@ -110,16 +94,6 @@ export class Lix {
110
94
  async switchBranch(options) {
111
95
  return this.#runOperation(async () => {
112
96
  const receipt = await this.binding.switchBranch(options);
113
- try {
114
- if (this.managedClientState) {
115
- await this.managedClientState.set(ACTIVE_BRANCH_CLIENT_STATE_KEY, receipt.branchId);
116
- }
117
- }
118
- catch {
119
- // The remote branch switch already committed. Client persistence is a
120
- // best-effort reopen preference and cannot turn that success into a
121
- // rejected switch with ambiguous branch state.
122
- }
123
97
  for (const listener of [...this.#activeBranchListeners]) {
124
98
  try {
125
99
  listener();
@@ -155,9 +129,13 @@ export class Lix {
155
129
  this.#observations.clear();
156
130
  this.closePromise = (async () => {
157
131
  await Promise.allSettled([...this.#inFlightOperations]);
158
- await this.binding.close();
159
- await this.managedClientState?.close();
132
+ const results = await Promise.allSettled([
133
+ Promise.resolve().then(() => this.binding.close()),
134
+ ]);
160
135
  this.#activeBranchListeners.clear();
136
+ const failure = results.find((result) => result.status === "rejected");
137
+ if (failure)
138
+ throw failure.reason;
161
139
  })();
162
140
  }
163
141
  await this.closePromise;
@@ -256,31 +234,22 @@ export class LixTransaction {
256
234
  throw transactionClosedError();
257
235
  if (!this.finishPromise) {
258
236
  this.finishPromise = (async () => {
259
- if (kind === "transaction.commit")
260
- await this.binding.commit();
261
- else
262
- await this.binding.rollback();
263
- this.finished = true;
264
- transactionFinalizer.unregister(this);
265
- this.onFinish();
237
+ try {
238
+ if (kind === "transaction.commit")
239
+ await this.binding.commit();
240
+ else
241
+ await this.binding.rollback();
242
+ }
243
+ finally {
244
+ // A terminal binding call consumes the underlying transaction even
245
+ // when its durable commit or rollback reports an error.
246
+ this.finished = true;
247
+ transactionFinalizer.unregister(this);
248
+ this.onFinish();
249
+ }
266
250
  })();
267
251
  }
268
- try {
269
- await this.finishPromise;
270
- }
271
- catch (error) {
272
- if (isSnapshotPersistenceAfterCommitError(error)) {
273
- // The transaction finished in Rust; only durable snapshot saving
274
- // failed. Release the transaction lifecycle while reporting that
275
- // durability failure to the caller.
276
- this.finished = true;
277
- transactionFinalizer.unregister(this);
278
- this.onFinish();
279
- throw error;
280
- }
281
- this.finishPromise = undefined;
282
- throw error;
283
- }
252
+ await this.finishPromise;
284
253
  }
285
254
  }
286
255
  function transactionClosedError() {
@@ -336,9 +305,14 @@ function normalizeBatchStatements(statements, options) {
336
305
  if (!Array.isArray(params)) {
337
306
  throw invalidArgument("executeBatch", `statements[${statementIndex}].params`, "array", typeof params);
338
307
  }
308
+ if (statement.label !== undefined &&
309
+ typeof statement.label !== "string") {
310
+ throw invalidArgument("executeBatch", `statements[${statementIndex}].label`, "string", typeof statement.label);
311
+ }
339
312
  return {
340
313
  sql: statement.sql,
341
314
  params: params.map((param, parameterIndex) => toNativeValue(normalizeParam(param, parameterIndex))),
315
+ ...(statement.label === undefined ? {} : { label: statement.label }),
342
316
  };
343
317
  }
344
318
  catch (error) {
@@ -1,17 +1,8 @@
1
1
  import { Lix } from "./lix.js";
2
- import type { LocalFilesystemOptions, OpenLixOptions, SQLiteOptions } from "./types.js";
2
+ import type { IndexedDbStorageOptions, OpenLixOptions } from "./types.js";
3
3
  export { Lix, LixTransaction, ObserveEvents } from "./lix.js";
4
- export declare class SQLite {
5
- readonly path: string;
6
- constructor(options: SQLiteOptions);
7
- }
8
- export declare class LocalFilesystem {
9
- readonly path: string;
10
- readonly lixDir: string | undefined;
11
- readonly syncAllFiles: boolean;
12
- constructor(options: LocalFilesystemOptions);
13
- importPaths(paths: readonly string[]): Promise<void>;
14
- syncDiskToLix(): Promise<void>;
15
- private client;
4
+ export declare class IndexedDbStorage {
5
+ readonly name: string;
6
+ constructor(options: IndexedDbStorageOptions);
16
7
  }
17
8
  export declare function openLix(options?: OpenLixOptions): Promise<Lix>;
package/dist/open-lix.js CHANGED
@@ -1,63 +1,15 @@
1
- import { localFilesystemAlreadyOpen, localFilesystemNotOpen, } from "./errors.js";
2
- import { ACTIVE_ACCOUNT_CLIENT_STATE_KEY, ACTIVE_BRANCH_CLIENT_STATE_KEY, openClientState, openStoredClientState, } from "./client-state.js";
3
1
  import { Lix } from "./lix.js";
2
+ import { isLixStorage } from "./storage-adapter.js";
4
3
  export { Lix, LixTransaction, ObserveEvents } from "./lix.js";
5
- export class SQLite {
6
- path;
4
+ const openStorages = new WeakSet();
5
+ const openIndexedDbStorageNames = new Set();
6
+ export class IndexedDbStorage {
7
+ name;
7
8
  constructor(options) {
8
- if (!options ||
9
- typeof options.path !== "string" ||
10
- options.path.length === 0) {
11
- throw new TypeError("SQLite requires a non-empty path");
9
+ if (!options || typeof options.name !== "string" || options.name.length === 0) {
10
+ throw new TypeError("IndexedDbStorage requires a non-empty name");
12
11
  }
13
- this.path = options.path;
14
- }
15
- }
16
- const openLocalFilesystems = new WeakMap();
17
- export class LocalFilesystem {
18
- path;
19
- lixDir;
20
- syncAllFiles;
21
- constructor(options) {
22
- if (!options ||
23
- typeof options.path !== "string" ||
24
- options.path.length === 0) {
25
- throw new TypeError("LocalFilesystem requires a non-empty path");
26
- }
27
- if (options.lixDir !== undefined &&
28
- (typeof options.lixDir !== "string" || options.lixDir.length === 0)) {
29
- throw new TypeError("LocalFilesystem lixDir must be a non-empty string");
30
- }
31
- if (typeof options.syncAllFiles !== "boolean") {
32
- throw new TypeError("LocalFilesystem syncAllFiles must be a boolean");
33
- }
34
- this.path = options.path;
35
- this.lixDir = options.lixDir;
36
- this.syncAllFiles = options.syncAllFiles;
37
- }
38
- async importPaths(paths) {
39
- if (!Array.isArray(paths)) {
40
- throw new TypeError("importPaths() paths must be an array");
41
- }
42
- for (const path of paths) {
43
- if (typeof path !== "string" || path.length === 0) {
44
- throw new TypeError("importPaths() paths must contain non-empty strings");
45
- }
46
- if (path.endsWith("/")) {
47
- throw new TypeError("importPaths() paths must not end with a trailing slash");
48
- }
49
- }
50
- await this.client("importPaths").importFilesystemPaths([...paths]);
51
- }
52
- async syncDiskToLix() {
53
- return this.client("syncDiskToLix").syncDiskToLix();
54
- }
55
- client(operation) {
56
- const client = openLocalFilesystems.get(this);
57
- if (!client) {
58
- throw localFilesystemNotOpen(operation);
59
- }
60
- return client;
12
+ this.name = options.name;
61
13
  }
62
14
  }
63
15
  export async function openLix(options = {}) {
@@ -74,122 +26,63 @@ export async function openLix(options = {}) {
74
26
  }
75
27
  if (options.server !== undefined) {
76
28
  const { openRemoteLixBinding } = await import("./remote/client.js");
77
- if (options.storage === undefined) {
78
- return new Lix(await openRemoteLixBinding(options.server));
79
- }
80
- assertSnapshotStorage(options.storage);
81
- const clientState = await openStoredClientState({
82
- storage: options.storage,
83
- namespace: remoteClientStateNamespace(options.server.url),
84
- });
85
- const restoredBranchId = clientState.get(ACTIVE_BRANCH_CLIENT_STATE_KEY);
86
- const restoredAccountId = clientState.get(ACTIVE_ACCOUNT_CLIENT_STATE_KEY);
87
- let remoteBinding;
88
- try {
89
- try {
90
- remoteBinding = await openRemoteLixBinding(options.server, {
91
- initialActiveBranchId: restoredBranchId,
92
- initialActiveAccountId: restoredAccountId,
93
- });
94
- }
95
- catch (error) {
96
- if (!restoredBranchId || !isBranchNotFoundError(error))
97
- throw error;
98
- remoteBinding = await openRemoteLixBinding(options.server, {
99
- initialActiveAccountId: restoredAccountId,
100
- });
101
- }
102
- const activeBranchId = await remoteBinding.activeBranchId();
103
- const activeAccountId = await remoteBinding.activeAccountId();
104
- if (activeBranchId !== restoredBranchId) {
105
- await clientState.set(ACTIVE_BRANCH_CLIENT_STATE_KEY, activeBranchId);
106
- }
107
- if (activeAccountId !== restoredAccountId) {
108
- await clientState.set(ACTIVE_ACCOUNT_CLIENT_STATE_KEY, activeAccountId);
109
- }
110
- return new Lix(remoteBinding, clientState);
111
- }
112
- catch (error) {
113
- await remoteBinding?.close().catch(() => undefined);
114
- await clientState.close().catch(() => undefined);
115
- throw error;
29
+ if ("storage" in options && options.storage !== undefined) {
30
+ throw new TypeError("openLix() remote mode does not accept storage");
116
31
  }
32
+ return new Lix(await openRemoteLixBinding(options.server));
117
33
  }
118
34
  const { openLixWorkerBinding } = await import("./worker/client.js");
119
35
  if (options.storage === undefined) {
120
36
  return new Lix(await openLixWorkerBinding({ kind: "memory" }, undefined, options.telemetry));
121
37
  }
122
- if (options.storage instanceof SQLite) {
123
- return new Lix(await openLixWorkerBinding({
124
- kind: "sqlite",
125
- path: options.storage.path,
126
- }, undefined, options.telemetry));
127
- }
128
- if (options.storage instanceof LocalFilesystem) {
38
+ if (isLixStorage(options.storage)) {
129
39
  const storage = options.storage;
130
- if (openLocalFilesystems.has(storage)) {
131
- throw localFilesystemAlreadyOpen();
40
+ if (openStorages.has(storage)) {
41
+ throw storageAlreadyOpen();
132
42
  }
133
- openLocalFilesystems.set(storage, null);
43
+ openStorages.add(storage);
44
+ let binding;
45
+ const disconnect = () => {
46
+ storage.lixStorage.connect(undefined);
47
+ openStorages.delete(storage);
48
+ };
134
49
  try {
135
- const binding = await openLixWorkerBinding({
136
- kind: "localFilesystem",
137
- path: storage.path,
138
- lixDir: storage.lixDir,
139
- syncAllFiles: storage.syncAllFiles,
140
- }, () => openLocalFilesystems.delete(storage), options.telemetry);
141
- openLocalFilesystems.set(storage, binding);
50
+ binding = await openLixWorkerBinding(storage.lixStorage.config, disconnect, options.telemetry);
51
+ storage.lixStorage.connect({
52
+ importFilesystemPaths: (paths) => binding.importFilesystemPaths(paths),
53
+ syncDiskToLix: () => binding.syncDiskToLix(),
54
+ });
142
55
  return new Lix(binding);
143
56
  }
144
57
  catch (error) {
145
- openLocalFilesystems.delete(storage);
58
+ disconnect();
59
+ await binding?.close().catch(() => undefined);
146
60
  throw error;
147
61
  }
148
62
  }
149
- if (isSnapshotStorage(options.storage)) {
150
- const { openPersistentLixWorkerBinding } = await import("./worker/client.js");
151
- const binding = await openPersistentLixWorkerBinding({
152
- storage: options.storage,
153
- namespace: "local",
154
- telemetry: options.telemetry,
155
- });
63
+ if (options.storage instanceof IndexedDbStorage) {
64
+ const storage = options.storage;
65
+ const databaseName = storage.name;
66
+ if (openIndexedDbStorageNames.has(databaseName)) {
67
+ throw new Error("IndexedDbStorage is already open");
68
+ }
69
+ openIndexedDbStorageNames.add(databaseName);
70
+ let binding;
156
71
  try {
157
- const clientState = await openClientState({ binding });
158
- return new Lix(binding, clientState);
72
+ binding = await openLixWorkerBinding({ kind: "indexedDb", name: databaseName }, () => openIndexedDbStorageNames.delete(databaseName), options.telemetry);
73
+ return new Lix(binding);
159
74
  }
160
75
  catch (error) {
161
- await binding.close().catch(() => undefined);
76
+ openIndexedDbStorageNames.delete(databaseName);
77
+ await binding?.close().catch(() => undefined);
162
78
  throw error;
163
79
  }
164
80
  }
165
- throw new TypeError("openLix() requires storage to be SQLite, LocalFilesystem, or a Lix snapshot storage adapter");
166
- }
167
- function isSnapshotStorage(value) {
168
- return (typeof value === "object" &&
169
- value !== null &&
170
- typeof value.load === "function" &&
171
- typeof value.save === "function");
172
- }
173
- function assertSnapshotStorage(value) {
174
- if (!isSnapshotStorage(value)) {
175
- throw new TypeError("openLix() remote storage must implement load() and save()");
176
- }
177
- }
178
- function remoteClientStateNamespace(value) {
179
- let url;
180
- try {
181
- url = new URL(value);
182
- }
183
- catch {
184
- throw new TypeError("openLix() remote server url must be an absolute URL");
185
- }
186
- url.pathname = url.pathname.replace(/\/$/, "");
187
- url.search = "";
188
- url.hash = "";
189
- return `remote:${url.href}`;
81
+ throw new TypeError("openLix() requires a Lix storage adapter or IndexedDbStorage");
190
82
  }
191
- function isBranchNotFoundError(error) {
192
- return (error instanceof Error &&
193
- "code" in error &&
194
- error.code === "LIX_BRANCH_NOT_FOUND");
83
+ function storageAlreadyOpen() {
84
+ const error = new Error("openLix() storage is already open; close the existing Lix or create a new storage adapter");
85
+ error.name = "LixError";
86
+ error.code = "LIX_STORAGE_IN_USE";
87
+ return error;
195
88
  }
@@ -1,9 +1,8 @@
1
1
  import type { LixBinding } from "../binding-types.js";
2
2
  import type { RemoteLixServerOptions } from "../types.js";
3
- import { type RemoteHandshakeRequest } from "./protocol.js";
3
+ import { type ServerProtocolHandshakeRequest } from "./server-protocol.js";
4
4
  type RemoteLixClientOptions = {
5
- initialActiveBranchId?: RemoteHandshakeRequest["activeBranchId"];
6
- initialActiveAccountId?: RemoteHandshakeRequest["activeAccountId"];
5
+ initialActiveBranchId?: ServerProtocolHandshakeRequest["activeBranchId"];
7
6
  };
8
7
  export declare function openRemoteLixBinding(options: RemoteLixServerOptions, clientOptions?: RemoteLixClientOptions): Promise<LixBinding>;
9
8
  export {};
@@ -1,4 +1,4 @@
1
- import { decodeExecuteResult, decodeHandshake, decodeObserveEvent, encodeWireValue, errorFromResponseBody, protocolError, record, REMOTE_PROTOCOL_PATH, remoteError, } from "./protocol.js";
1
+ import { decodeExecuteBatchResult, decodeExecuteResult, decodeHandshake, decodeObserveEvent, encodeWireValue, errorFromResponseBody, protocolError, record, SERVER_PROTOCOL_PATH, remoteError, } from "./server-protocol.js";
2
2
  import { readSseEvents } from "./sse.js";
3
3
  const OBSERVE_RETRY_BASE_MS = 100;
4
4
  const OBSERVE_RETRY_MAX_MS = 5_000;
@@ -32,7 +32,6 @@ class RemoteLixBinding {
32
32
  #fetch;
33
33
  #headers;
34
34
  #initialActiveBranchId;
35
- #initialActiveAccountId;
36
35
  #observationHub;
37
36
  #requestBlobBases = new Map();
38
37
  #sessionId;
@@ -66,13 +65,9 @@ class RemoteLixBinding {
66
65
  throw new TypeError("initialActiveBranchId must be a non-empty string");
67
66
  }
68
67
  this.#initialActiveBranchId = clientOptions.initialActiveBranchId;
69
- if (clientOptions.initialActiveAccountId !== undefined &&
70
- clientOptions.initialActiveAccountId.length === 0) {
71
- throw new TypeError("initialActiveAccountId must be a non-empty string");
72
- }
73
- this.#initialActiveAccountId = clientOptions.initialActiveAccountId;
74
68
  this.#observationHub = new RemoteObservationHub({
75
69
  openStream: (subscriptions, signal) => this.#requestObserveStream(subscriptions, signal),
70
+ refreshObservation: (subscription) => this.#refreshObservation(subscription),
76
71
  });
77
72
  }
78
73
  async open() {
@@ -80,9 +75,6 @@ class RemoteLixBinding {
80
75
  if (this.#initialActiveBranchId !== undefined) {
81
76
  query.set("activeBranchId", this.#initialActiveBranchId);
82
77
  }
83
- if (this.#initialActiveAccountId !== undefined) {
84
- query.set("activeAccountId", this.#initialActiveAccountId);
85
- }
86
78
  const path = query.size === 0 ? "" : `?${query}`;
87
79
  const handshake = decodeHandshake(await this.#requestJson(path, { method: "GET" }));
88
80
  this.#sessionId = handshake.sessionId;
@@ -119,10 +111,12 @@ class RemoteLixBinding {
119
111
  const snapshot = statements.map((statement) => ({
120
112
  sql: statement.sql,
121
113
  params: snapshotParams(statement.params),
114
+ ...(statement.label === undefined ? {} : { label: statement.label }),
122
115
  }));
123
116
  return this.#enqueue(async () => {
124
117
  const preparedStatements = await Promise.all(snapshot.map(async (statement, statementIndex) => ({
125
118
  sql: statement.sql,
119
+ label: statement.label,
126
120
  prepared: await this.#prepareParams(statement.params, (paramIndex) => requestBlobSlot("batch", statement.sql, paramIndex, statementIndex)),
127
121
  })));
128
122
  const cacheBlobs = preparedStatements.some((statement) => statement.prepared.cacheBlobs);
@@ -133,6 +127,9 @@ class RemoteLixBinding {
133
127
  body: JSON.stringify({
134
128
  statements: preparedStatements.map((statement) => ({
135
129
  sql: statement.sql,
130
+ ...(statement.label === undefined
131
+ ? {}
132
+ : { label: statement.label }),
136
133
  params: full
137
134
  ? statement.prepared.fullParams()
138
135
  : statement.prepared.params,
@@ -145,7 +142,7 @@ class RemoteLixBinding {
145
142
  if (!Array.isArray(value)) {
146
143
  throw protocolError("execute batch response must be an array");
147
144
  }
148
- const results = value.map(decodeExecuteResult);
145
+ const results = value.map(decodeExecuteBatchResult);
149
146
  this.#commitRequestBlobBases(preparedStatements.flatMap((statement) => statement.prepared.cacheUpdates));
150
147
  return results;
151
148
  });
@@ -219,7 +216,7 @@ class RemoteLixBinding {
219
216
  if (this.#activeBranchId === undefined) {
220
217
  const handshake = decodeHandshake(await this.#requestJson("", { method: "GET" }));
221
218
  if (handshake.sessionId !== this.#sessionId) {
222
- throw protocolError("remote handshake changed sessionId");
219
+ throw protocolError("Lix Server Protocol handshake changed sessionId");
223
220
  }
224
221
  this.#activeBranchId = handshake.activeBranchId;
225
222
  }
@@ -232,7 +229,7 @@ class RemoteLixBinding {
232
229
  if (this.#activeAccountId === undefined) {
233
230
  const handshake = decodeHandshake(await this.#requestJson("", { method: "GET" }));
234
231
  if (handshake.sessionId !== this.#sessionId) {
235
- throw protocolError("remote handshake changed sessionId");
232
+ throw protocolError("Lix Server Protocol handshake changed sessionId");
236
233
  }
237
234
  this.#activeAccountId = handshake.activeAccountId;
238
235
  }
@@ -442,6 +439,18 @@ class RemoteLixBinding {
442
439
  throw remoteError("LIX_REMOTE_UNAVAILABLE", "The remote Lix observation stream is unavailable", { details: { cause: errorMessage(cause) } });
443
440
  }
444
441
  }
442
+ async #refreshObservation(subscription) {
443
+ return this.#enqueue(async () => {
444
+ const value = await this.#requestJson("execute", {
445
+ method: "POST",
446
+ body: JSON.stringify({
447
+ sql: subscription.sql,
448
+ params: subscription.params,
449
+ }),
450
+ });
451
+ return decodeExecuteResult(value);
452
+ });
453
+ }
445
454
  async #prepareParams(params, slot) {
446
455
  const prepared = await Promise.all(params.map(async (param, index) => {
447
456
  if (param.kind !== "blob" ||
@@ -661,6 +670,7 @@ function copyArrayBuffer(bytes) {
661
670
  }
662
671
  class RemoteObservationHub {
663
672
  #openStream;
673
+ #refreshObservation;
664
674
  #observations = new Map();
665
675
  #nextObservationId = 0;
666
676
  #controller;
@@ -672,6 +682,7 @@ class RemoteObservationHub {
672
682
  #closed = false;
673
683
  constructor(options) {
674
684
  this.#openStream = options.openStream;
685
+ this.#refreshObservation = options.refreshObservation;
675
686
  }
676
687
  observe(sql, params) {
677
688
  const id = `observe-${++this.#nextObservationId}`;
@@ -743,6 +754,7 @@ class RemoteObservationHub {
743
754
  if (!this.#isCurrent(generation, controller))
744
755
  return;
745
756
  streamOpened = true;
757
+ const initialSubscriptions = new Set(this.#observations.keys());
746
758
  if (!response.ok) {
747
759
  if (isRetryableObserveStatus(response.status)) {
748
760
  void response.body?.cancel();
@@ -781,7 +793,21 @@ class RemoteObservationHub {
781
793
  const transportDelta = payload.delta !== undefined;
782
794
  const event = decodeObserveEvent(payload, transportBases.get(subscriptionId));
783
795
  transportBases.set(subscriptionId, event);
784
- observation.accept(event, transportDelta);
796
+ if (initialSubscriptions.delete(subscriptionId)) {
797
+ // The first frame after opening (including a reconnect) is a
798
+ // synchronization point, not an authoritative snapshot. The
799
+ // remote runtime may have observed the stream before its
800
+ // external-storage watcher caught up. Reconcile through the
801
+ // normal execute endpoint before publishing it to consumers.
802
+ const rows = await this.#refreshObservation(observation.request());
803
+ observation.accept({
804
+ ...event,
805
+ rows,
806
+ }, false);
807
+ }
808
+ else {
809
+ observation.accept(event, transportDelta);
810
+ }
785
811
  this.#retryAttempt = 0;
786
812
  }
787
813
  catch (error) {
@@ -1057,7 +1083,7 @@ function isRetryableObserveError(error) {
1057
1083
  function asObserveProtocolError(error, event) {
1058
1084
  if (error instanceof Error &&
1059
1085
  "code" in error &&
1060
- error.code === "LIX_REMOTE_PROTOCOL_ERROR") {
1086
+ error.code === "LIX_SERVER_PROTOCOL_ERROR") {
1061
1087
  return error;
1062
1088
  }
1063
1089
  return protocolError(`remote observe ${event} event contains invalid data: ${errorMessage(error)}`);
@@ -1084,8 +1110,8 @@ function nativeValuesEqual(left, right) {
1084
1110
  return (right.kind === "blob" &&
1085
1111
  left.blob.length === right.blob.length &&
1086
1112
  left.blob.every((byte, index) => byte === right.blob[index]));
1087
- case "json":
1088
- return right.kind === "json" && jsonValuesEqual(left.value, right.value);
1113
+ case "jsonb":
1114
+ return right.kind === "jsonb" && jsonValuesEqual(left.value, right.value);
1089
1115
  default:
1090
1116
  return left.value === right.value;
1091
1117
  }
@@ -1117,21 +1143,22 @@ function stringArraysEqual(left, right) {
1117
1143
  left.every((value, index) => value === right[index]));
1118
1144
  }
1119
1145
  function protocolBaseUrl(value) {
1120
- let workspaceUrl;
1146
+ let repositoryUrl;
1121
1147
  try {
1122
- workspaceUrl = new URL(value);
1148
+ repositoryUrl = new URL(value);
1123
1149
  }
1124
1150
  catch {
1125
1151
  throw new TypeError("openLix() remote server url must be an absolute URL");
1126
1152
  }
1127
- if (workspaceUrl.protocol !== "http:" && workspaceUrl.protocol !== "https:") {
1153
+ if (repositoryUrl.protocol !== "http:" &&
1154
+ repositoryUrl.protocol !== "https:") {
1128
1155
  throw new TypeError("openLix() remote server url must use http or https");
1129
1156
  }
1130
- if (workspaceUrl.search || workspaceUrl.hash) {
1157
+ if (repositoryUrl.search || repositoryUrl.hash) {
1131
1158
  throw new TypeError("openLix() remote server url must not contain a query or fragment");
1132
1159
  }
1133
- workspaceUrl.pathname = `${workspaceUrl.pathname.replace(/\/$/, "")}${REMOTE_PROTOCOL_PATH}`;
1134
- return workspaceUrl;
1160
+ repositoryUrl.pathname = `${repositoryUrl.pathname.replace(/\/$/, "")}${SERVER_PROTOCOL_PATH}`;
1161
+ return repositoryUrl;
1135
1162
  }
1136
1163
  function unsupportedRemoteOperation(operation) {
1137
1164
  return remoteError("LIX_UNSUPPORTED_REMOTE_OPERATION", `${operation} is not supported in remote mode`, { details: { operation } });