@xnetjs/data 0.1.0 → 0.1.2

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.
@@ -3413,6 +3413,8 @@ var DEFAULT_QUERY_VERIFICATION = {
3413
3413
  logFailures: true
3414
3414
  };
3415
3415
  var QUERY_TELEMETRY_FLUSH_THRESHOLD = 50;
3416
+ var CHANGE_ENVELOPE_KEY = "__xnetChangeEnvelopeV1";
3417
+ var COMPILED_QUERY_DIAGNOSTICS_MEMO_LIMIT = 512;
3416
3418
  var SQLITE_BIND_PARAMETER_BATCH_SIZE = 900;
3417
3419
  var SQLITE_HYDRATE_NODE_BATCH_SIZE = Math.floor(SQLITE_BIND_PARAMETER_BATCH_SIZE / 2);
3418
3420
  var SQL_HYDRATE_ARITY_BUCKETS = [1, 10, 50, 150, SQLITE_HYDRATE_NODE_BATCH_SIZE];
@@ -3474,6 +3476,14 @@ var SQLiteNodeStorageAdapter = class {
3474
3476
  pendingQueryTelemetryHits = 0;
3475
3477
  adaptiveIndexBudgetColumnsReady = false;
3476
3478
  storageCapabilitiesPromise;
3479
+ /**
3480
+ * Plan diagnostics memoized per compiled SQL shape (the string EXPLAINed),
3481
+ * shared across executions AND concurrent callers. Cleared when this adapter
3482
+ * creates/drops an adaptive index, since that changes plans. Debug mode must
3483
+ * not distort what it measures: per-execution EXPLAIN + index-inventory
3484
+ * round-trips convoyed the single serial worker at boot.
3485
+ */
3486
+ compiledQueryDiagnosticsMemo = /* @__PURE__ */ new Map();
3477
3487
  spatialTablesState = "unknown";
3478
3488
  fullTextSearchTablesState = "unknown";
3479
3489
  writeQueue = Promise.resolve();
@@ -3572,7 +3582,7 @@ var SQLiteNodeStorageAdapter = class {
3572
3582
  await this.enqueueWrite(() => this.appendChangeInternal(change));
3573
3583
  }
3574
3584
  async appendChangeInternal(change) {
3575
- const payload = this.serializePayload(change.payload);
3585
+ const payload = this.serializeChangeRecord(change);
3576
3586
  await this.db.run(
3577
3587
  `INSERT OR IGNORE INTO changes
3578
3588
  (hash, node_id, payload, lamport_time, lamport_peer, wall_time, author, parent_hash, batch_id, signature)
@@ -3833,7 +3843,11 @@ var SQLiteNodeStorageAdapter = class {
3833
3843
  lamport_time = excluded.lamport_time,
3834
3844
  updated_by = excluded.updated_by,
3835
3845
  updated_at = excluded.updated_at
3836
- WHERE excluded.lamport_time > node_properties.lamport_time`,
3846
+ WHERE excluded.lamport_time > node_properties.lamport_time
3847
+ OR (excluded.lamport_time = node_properties.lamport_time
3848
+ AND (excluded.updated_at > node_properties.updated_at
3849
+ OR (excluded.updated_at = node_properties.updated_at
3850
+ AND excluded.updated_by > node_properties.updated_by)))`,
3837
3851
  [
3838
3852
  node.id,
3839
3853
  key,
@@ -4326,7 +4340,7 @@ var SQLiteNodeStorageAdapter = class {
4326
4340
  changes: input.changes.map((change) => ({
4327
4341
  hash: change.hash,
4328
4342
  nodeId: change.payload.nodeId,
4329
- payload: this.serializePayload(change.payload),
4343
+ payload: this.serializeChangeRecord(change),
4330
4344
  lamportTime: change.lamport,
4331
4345
  lamportPeer: change.authorDID,
4332
4346
  wallTime: change.wallTime,
@@ -4479,6 +4493,8 @@ var SQLiteNodeStorageAdapter = class {
4479
4493
  const timestamp = node.timestamps[key];
4480
4494
  if (!timestamp) continue;
4481
4495
  operations.push({
4496
+ // Full LWW ordering triple — keep in lockstep with the setNode
4497
+ // upsert above and `shouldReplace` in ./store.ts (0272).
4482
4498
  sql: `INSERT INTO node_properties
4483
4499
  (node_id, property_key, value, lamport_time, updated_by, updated_at)
4484
4500
  VALUES (?, ?, ?, ?, ?, ?)
@@ -4487,7 +4503,11 @@ var SQLiteNodeStorageAdapter = class {
4487
4503
  lamport_time = excluded.lamport_time,
4488
4504
  updated_by = excluded.updated_by,
4489
4505
  updated_at = excluded.updated_at
4490
- WHERE excluded.lamport_time > node_properties.lamport_time`,
4506
+ WHERE excluded.lamport_time > node_properties.lamport_time
4507
+ OR (excluded.lamport_time = node_properties.lamport_time
4508
+ AND (excluded.updated_at > node_properties.updated_at
4509
+ OR (excluded.updated_at = node_properties.updated_at
4510
+ AND excluded.updated_by > node_properties.updated_by)))`,
4491
4511
  params: [
4492
4512
  node.id,
4493
4513
  key,
@@ -4520,7 +4540,7 @@ var SQLiteNodeStorageAdapter = class {
4520
4540
  params: [
4521
4541
  change.hash,
4522
4542
  change.payload.nodeId,
4523
- this.serializePayload(change.payload),
4543
+ this.serializeChangeRecord(change),
4524
4544
  change.lamport,
4525
4545
  change.authorDID,
4526
4546
  change.wallTime,
@@ -5741,7 +5761,34 @@ CREATE INDEX IF NOT EXISTS idx_node_spatial_ids_schema
5741
5761
  isQueryDiagnosticsEnabled() {
5742
5762
  return this.queryDiagnostics || this.isQueryDebugEnabled();
5743
5763
  }
5744
- async collectCompiledQueryDiagnostics(compiled) {
5764
+ /**
5765
+ * Throttled: one collection per unique compiled SQL shape per session.
5766
+ * EXPLAIN plans depend on the statement, not the bound values, so keyset
5767
+ * pages of the same query share one entry. Concurrent callers share the
5768
+ * in-flight promise; failed collections are not memoized.
5769
+ */
5770
+ collectCompiledQueryDiagnostics(compiled) {
5771
+ const memoKey = compiled.sql;
5772
+ const memoized = this.compiledQueryDiagnosticsMemo.get(memoKey);
5773
+ if (memoized) {
5774
+ return memoized;
5775
+ }
5776
+ const pending = this.computeCompiledQueryDiagnostics(compiled).then((diagnostics) => {
5777
+ if (diagnostics.diagnosticsError) {
5778
+ this.compiledQueryDiagnosticsMemo.delete(memoKey);
5779
+ }
5780
+ return diagnostics;
5781
+ });
5782
+ if (this.compiledQueryDiagnosticsMemo.size >= COMPILED_QUERY_DIAGNOSTICS_MEMO_LIMIT) {
5783
+ const oldest = this.compiledQueryDiagnosticsMemo.keys().next().value;
5784
+ if (oldest !== void 0) {
5785
+ this.compiledQueryDiagnosticsMemo.delete(oldest);
5786
+ }
5787
+ }
5788
+ this.compiledQueryDiagnosticsMemo.set(memoKey, pending);
5789
+ return pending;
5790
+ }
5791
+ async computeCompiledQueryDiagnostics(compiled) {
5745
5792
  try {
5746
5793
  const [analysis, indexes, storageCapabilities] = await Promise.all([
5747
5794
  analyzeQuery(this.db, compiled.sql, compiled.params),
@@ -5965,6 +6012,7 @@ CREATE INDEX IF NOT EXISTS idx_node_spatial_ids_schema
5965
6012
  if (createdIndex) {
5966
6013
  await runAnalyze(this.db, "node_property_scalars");
5967
6014
  await this.db.exec("PRAGMA optimize");
6015
+ this.compiledQueryDiagnosticsMemo.clear();
5968
6016
  }
5969
6017
  return touchedIndexNames;
5970
6018
  }
@@ -6113,6 +6161,7 @@ CREATE INDEX IF NOT EXISTS idx_node_spatial_ids_schema
6113
6161
  async dropAdaptiveIndex(indexName, reason) {
6114
6162
  await this.db.exec(`DROP INDEX IF EXISTS ${this.quoteSqlIdentifier(indexName)}`);
6115
6163
  await this.db.run("DELETE FROM query_index_candidates WHERE index_name = ?", [indexName]);
6164
+ this.compiledQueryDiagnosticsMemo.clear();
6116
6165
  this.debugAdaptiveIndex("drop", { indexName, reason });
6117
6166
  }
6118
6167
  debugAdaptiveIndex(action, details) {
@@ -6432,8 +6481,28 @@ WHERE schema_id = ${this.quoteSqlLiteral(schemaId)}
6432
6481
  serializePayload(payload) {
6433
6482
  return new TextEncoder().encode(JSON.stringify(payload));
6434
6483
  }
6435
- deserializePayload(data) {
6436
- return JSON.parse(new TextDecoder().decode(data));
6484
+ /**
6485
+ * Serialize a change for the `changes` table (exploration 0272).
6486
+ *
6487
+ * The table has no columns for `id`, `type`, `protocolVersion`,
6488
+ * `batchIndex`, or `batchSize`, yet all of them are part of the signed
6489
+ * content hash — a change re-read without them can never pass
6490
+ * `verifyChangeHash`, so the reload-resync push (getChangesSince → hub)
6491
+ * was structurally rejected as INVALID_HASH and tripped the 0224 breaker,
6492
+ * stranding offline edits. The hub's own `node_changes` table persists
6493
+ * every one of these fields; the client log now does too, by wrapping the
6494
+ * payload BLOB in an envelope instead of a schema migration (applySchema
6495
+ * re-runs idempotent DDL and never executes ALTERs, so new columns would
6496
+ * not reach existing databases).
6497
+ */
6498
+ serializeChangeRecord(change) {
6499
+ const meta = { id: change.id, type: change.type };
6500
+ if (change.protocolVersion !== void 0) meta.protocolVersion = change.protocolVersion;
6501
+ if (change.batchIndex !== void 0) meta.batchIndex = change.batchIndex;
6502
+ if (change.batchSize !== void 0) meta.batchSize = change.batchSize;
6503
+ return new TextEncoder().encode(
6504
+ JSON.stringify({ [CHANGE_ENVELOPE_KEY]: meta, payload: change.payload })
6505
+ );
6437
6506
  }
6438
6507
  serializeValue(value) {
6439
6508
  return new TextEncoder().encode(JSON.stringify(value));
@@ -6443,13 +6512,16 @@ WHERE schema_id = ${this.quoteSqlLiteral(schemaId)}
6443
6512
  return JSON.parse(new TextDecoder().decode(data));
6444
6513
  }
6445
6514
  deserializeChange(row) {
6446
- const payload = this.deserializePayload(row.payload);
6447
- return {
6448
- // Required Change<T> fields
6449
- id: row.hash,
6450
- // Use hash as ID since we don't store separate id
6451
- type: "node",
6452
- // All node changes have type 'node'
6515
+ const parsed = JSON.parse(new TextDecoder().decode(row.payload));
6516
+ const envelope = parsed !== null && typeof parsed === "object" && CHANGE_ENVELOPE_KEY in parsed ? parsed[CHANGE_ENVELOPE_KEY] : null;
6517
+ const payload = envelope ? parsed.payload : parsed;
6518
+ const change = {
6519
+ // Envelope rows (exploration 0272) round-trip the hashed identity
6520
+ // fields exactly, so verifyChangeHash holds after a re-read. Legacy
6521
+ // rows predate the envelope: their id/type/protocolVersion are gone
6522
+ // for good, so we keep the historical fabricated values.
6523
+ id: envelope?.id ?? row.hash,
6524
+ type: envelope?.type ?? "node",
6453
6525
  hash: row.hash,
6454
6526
  payload,
6455
6527
  lamport: row.lamport_time,
@@ -6459,6 +6531,10 @@ WHERE schema_id = ${this.quoteSqlLiteral(schemaId)}
6459
6531
  batchId: row.batch_id ?? void 0,
6460
6532
  signature: row.signature
6461
6533
  };
6534
+ if (envelope?.protocolVersion !== void 0) change.protocolVersion = envelope.protocolVersion;
6535
+ if (envelope?.batchIndex !== void 0) change.batchIndex = envelope.batchIndex;
6536
+ if (envelope?.batchSize !== void 0) change.batchSize = envelope.batchSize;
6537
+ return change;
6462
6538
  }
6463
6539
  };
6464
6540
  function chunkItems(items, size) {
package/dist/index.js CHANGED
@@ -605,7 +605,7 @@ import {
605
605
  sum,
606
606
  validateQueryAST,
607
607
  validateSavedViewDescriptor
608
- } from "./chunk-UDWKWTAX.js";
608
+ } from "./chunk-2UPMDBWD.js";
609
609
  import {
610
610
  applyNodeQueryDescriptor,
611
611
  createNodeQueryDescriptor,
@@ -120,9 +120,13 @@ interface SQLiteNodeStorageAdapterOptions {
120
120
  adaptiveIndexing?: SQLiteAdaptiveIndexingOptions;
121
121
  queryVerification?: SQLiteQueryVerificationOptions;
122
122
  /**
123
- * Collect EXPLAIN QUERY PLAN + index inventory per query. Costs extra
124
- * round trips per query, so it is off unless explicitly enabled or the
125
- * `xnet:query:debug` localStorage flag is set.
123
+ * Collect EXPLAIN QUERY PLAN + index inventory for queries. Costs extra
124
+ * round trips, so it is off unless explicitly enabled or the
125
+ * `xnet:query:debug` localStorage flag is set. Diagnostics are collected
126
+ * once per unique compiled SQL shape per session (invalidated when the
127
+ * adapter itself runs DDL) — per-execution collection convoyed the serial
128
+ * worker and delayed the very queries being measured by 18-20s at boot
129
+ * (2026-07-05 capture).
126
130
  */
127
131
  queryDiagnostics?: boolean;
128
132
  /**
@@ -175,6 +179,14 @@ declare class SQLiteNodeStorageAdapter implements NodeStorageAdapter {
175
179
  private pendingQueryTelemetryHits;
176
180
  private adaptiveIndexBudgetColumnsReady;
177
181
  private storageCapabilitiesPromise?;
182
+ /**
183
+ * Plan diagnostics memoized per compiled SQL shape (the string EXPLAINed),
184
+ * shared across executions AND concurrent callers. Cleared when this adapter
185
+ * creates/drops an adaptive index, since that changes plans. Debug mode must
186
+ * not distort what it measures: per-execution EXPLAIN + index-inventory
187
+ * round-trips convoyed the single serial worker at boot.
188
+ */
189
+ private compiledQueryDiagnosticsMemo;
178
190
  private spatialTablesState;
179
191
  private fullTextSearchTablesState;
180
192
  private writeQueue;
@@ -449,7 +461,14 @@ declare class SQLiteNodeStorageAdapter implements NodeStorageAdapter {
449
461
  flushQueryTelemetry(): Promise<void>;
450
462
  private writeQueryTelemetryRow;
451
463
  private isQueryDiagnosticsEnabled;
464
+ /**
465
+ * Throttled: one collection per unique compiled SQL shape per session.
466
+ * EXPLAIN plans depend on the statement, not the bound values, so keyset
467
+ * pages of the same query share one entry. Concurrent callers share the
468
+ * in-flight promise; failed collections are not memoized.
469
+ */
452
470
  private collectCompiledQueryDiagnostics;
471
+ private computeCompiledQueryDiagnostics;
453
472
  private getStorageCapabilities;
454
473
  private debugQueryPlan;
455
474
  private isQueryDebugEnabled;
@@ -495,7 +514,21 @@ declare class SQLiteNodeStorageAdapter implements NodeStorageAdapter {
495
514
  private buildSqlOrderBy;
496
515
  private hasOnlySystemOrdering;
497
516
  private serializePayload;
498
- private deserializePayload;
517
+ /**
518
+ * Serialize a change for the `changes` table (exploration 0272).
519
+ *
520
+ * The table has no columns for `id`, `type`, `protocolVersion`,
521
+ * `batchIndex`, or `batchSize`, yet all of them are part of the signed
522
+ * content hash — a change re-read without them can never pass
523
+ * `verifyChangeHash`, so the reload-resync push (getChangesSince → hub)
524
+ * was structurally rejected as INVALID_HASH and tripped the 0224 breaker,
525
+ * stranding offline edits. The hub's own `node_changes` table persists
526
+ * every one of these fields; the client log now does too, by wrapping the
527
+ * payload BLOB in an envelope instead of a schema migration (applySchema
528
+ * re-runs idempotent DDL and never executes ALTERs, so new columns would
529
+ * not reach existing databases).
530
+ */
531
+ private serializeChangeRecord;
499
532
  private serializeValue;
500
533
  private deserializeValue;
501
534
  private deserializeChange;
@@ -49,7 +49,7 @@ import {
49
49
  sum,
50
50
  validateQueryAST,
51
51
  validateSavedViewDescriptor
52
- } from "../chunk-UDWKWTAX.js";
52
+ } from "../chunk-2UPMDBWD.js";
53
53
  import {
54
54
  applyNodeQueryDescriptor,
55
55
  createNodeQueryDescriptor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xnetjs/data",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -57,12 +57,12 @@
57
57
  "nanoid": "^5.1.6",
58
58
  "y-protocols": "^1.0.6",
59
59
  "yjs": "^13.6.24",
60
- "@xnetjs/core": "0.1.0",
61
- "@xnetjs/crypto": "0.1.0",
62
- "@xnetjs/identity": "0.1.0",
63
- "@xnetjs/sqlite": "0.1.0",
64
- "@xnetjs/storage": "0.1.0",
65
- "@xnetjs/sync": "0.1.0"
60
+ "@xnetjs/core": "0.1.2",
61
+ "@xnetjs/crypto": "0.1.2",
62
+ "@xnetjs/identity": "0.1.2",
63
+ "@xnetjs/sqlite": "0.1.2",
64
+ "@xnetjs/storage": "0.1.2",
65
+ "@xnetjs/sync": "0.1.2"
66
66
  },
67
67
  "devDependencies": {
68
68
  "tsup": "^8.0.0",