@xnetjs/data 0.0.3 → 0.1.1

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 (151) hide show
  1. package/dist/account-MWNCH4GJ.js +12 -0
  2. package/dist/account-ledger-R2GAFJL6.js +38 -0
  3. package/dist/auth/index.d.ts +11 -4
  4. package/dist/auth/index.js +1 -1
  5. package/dist/budget-NN7P35XP.js +12 -0
  6. package/dist/canvas-6WYBNH53.js +10 -0
  7. package/dist/channel-AT3LQF4A.js +12 -0
  8. package/dist/chat-message-DSVO72B3.js +10 -0
  9. package/dist/{chunk-J6EDPPH4.js → chunk-2ZNJWIQG.js} +2 -2
  10. package/dist/{chunk-5FHJE7RC.js → chunk-3J3HILXO.js} +2 -2
  11. package/dist/{chunk-EZA5UT4Y.js → chunk-3RUDVTMH.js} +6 -6
  12. package/dist/{chunk-YEQ6LLUY.js → chunk-4EYKGTSQ.js} +2 -2
  13. package/dist/{chunk-L66WXPTW.js → chunk-53F4PRNC.js} +2 -2
  14. package/dist/{chunk-LGD72FVD.js → chunk-5EXHNXPL.js} +2 -2
  15. package/dist/{chunk-4U4KBCDN.js → chunk-5HC5NO57.js} +2 -2
  16. package/dist/{chunk-YWN27MLD.js → chunk-6KLGA6IR.js} +2 -2
  17. package/dist/{chunk-37267RSF.js → chunk-6OMG4I7M.js} +2 -2
  18. package/dist/{chunk-DOZJXPQ6.js → chunk-BQLQSWKZ.js} +2 -2
  19. package/dist/{chunk-V6HZ3LF3.js → chunk-BY5O6LNC.js} +1 -1
  20. package/dist/{chunk-64AM6JPM.js → chunk-DCTRX6II.js} +1 -1
  21. package/dist/{chunk-SPFKSTPT.js → chunk-DWEXKD6E.js} +2 -2
  22. package/dist/{chunk-O64EK5FK.js → chunk-EBNMV2VO.js} +11 -2
  23. package/dist/{chunk-QVZXXJSP.js → chunk-EI5CY2FZ.js} +524 -82
  24. package/dist/{chunk-J4IVJA5M.js → chunk-FVEACSKE.js} +2 -2
  25. package/dist/{chunk-XWA3CJQ6.js → chunk-GU6THOAB.js} +2 -2
  26. package/dist/{chunk-XTUGZSYQ.js → chunk-H4GA4BBK.js} +2 -2
  27. package/dist/{chunk-GR3NDMBY.js → chunk-IJ44LR7N.js} +2 -2
  28. package/dist/{chunk-E6S557NC.js → chunk-J4ANIXIV.js} +2 -2
  29. package/dist/{chunk-EIQX7INF.js → chunk-JDFNMFOZ.js} +2 -2
  30. package/dist/{chunk-PSUJEKVV.js → chunk-K7DOZWWT.js} +244 -155
  31. package/dist/{chunk-UDUPQKRB.js → chunk-KQAT4XBL.js} +2 -2
  32. package/dist/chunk-KQUALW4O.js +183 -0
  33. package/dist/{chunk-KPVBKKZS.js → chunk-LYSWLCOI.js} +2 -2
  34. package/dist/{chunk-YXZGYLKV.js → chunk-MBTUO3ZL.js} +2 -2
  35. package/dist/{chunk-7GMJOAPF.js → chunk-NI4FHG2K.js} +2 -2
  36. package/dist/{chunk-6OPZJ7MG.js → chunk-OCMSAKWV.js} +2 -2
  37. package/dist/{chunk-IP4BEVVD.js → chunk-OGJCRNGE.js} +2 -2
  38. package/dist/{chunk-6CN53PON.js → chunk-OPPHF3TF.js} +3 -3
  39. package/dist/{chunk-77UDLVQY.js → chunk-OSAWNZVM.js} +1 -1
  40. package/dist/{chunk-GWYVY2K4.js → chunk-PNESGUH5.js} +2 -2
  41. package/dist/{chunk-RSSZXCLL.js → chunk-Q3IEGH4B.js} +2 -2
  42. package/dist/{chunk-2DSDH2ZH.js → chunk-QJW5LDP4.js} +2 -2
  43. package/dist/{chunk-JESSJ5CZ.js → chunk-RL64OJJ5.js} +13 -3
  44. package/dist/{chunk-S4B5OPZU.js → chunk-S5RP5RKY.js} +1 -0
  45. package/dist/{chunk-4UQTURCD.js → chunk-S6U6TCMN.js} +2 -2
  46. package/dist/{chunk-C5IRSEEZ.js → chunk-SVNGSZZA.js} +2 -2
  47. package/dist/{chunk-ZICIXDEE.js → chunk-T5AZAOG5.js} +1 -1
  48. package/dist/{chunk-IJ675GJR.js → chunk-TCTZW4A6.js} +2 -2
  49. package/dist/{chunk-54Y7QRZN.js → chunk-U64CW73O.js} +2 -2
  50. package/dist/{chunk-3RB2FMMO.js → chunk-UQM3G5A2.js} +2 -2
  51. package/dist/{chunk-ENTHVNOC.js → chunk-VQ7JHB67.js} +2 -2
  52. package/dist/{chunk-JO6Y4E5V.js → chunk-W7EBL7ZU.js} +2 -2
  53. package/dist/{chunk-WUR3WWQM.js → chunk-WEPK7SZF.js} +2 -2
  54. package/dist/{chunk-3JKW6TDE.js → chunk-XMSKJ5PV.js} +2 -2
  55. package/dist/{chunk-WMSCEBFH.js → chunk-XROI44ZP.js} +2 -2
  56. package/dist/{chunk-CHZUSBSB.js → chunk-Y3S5SRVM.js} +2 -2
  57. package/dist/{chunk-KTJLFJEQ.js → chunk-YEFKQYKA.js} +2 -2
  58. package/dist/{chunk-DTPSTMUD.js → chunk-YSUIXD2Y.js} +2 -2
  59. package/dist/{chunk-ZMCO3O2Z.js → chunk-ZCOFZY5M.js} +2 -2
  60. package/dist/{chunk-U6CBYDVZ.js → chunk-ZLU4Y3O2.js} +2 -2
  61. package/dist/{chunk-337E3ZOB.js → chunk-ZPZ7XDA6.js} +2 -2
  62. package/dist/{chunk-6GB7PBCR.js → chunk-ZZ6TWKGS.js} +2 -2
  63. package/dist/{clone-DpbVKLUu.d.ts → clone-C0Jhk2UN.d.ts} +2 -2
  64. package/dist/comment-QSWYAQVS.js +10 -0
  65. package/dist/{crm-AAJXYIJY.js → crm-L5RYAAF3.js} +5 -5
  66. package/dist/dashboard-JMKTTRA7.js +10 -0
  67. package/dist/database/index.d.ts +3 -3
  68. package/dist/database/index.js +11 -11
  69. package/dist/database-GIXSPZJW.js +10 -0
  70. package/dist/database-field-3EPAMZUR.js +10 -0
  71. package/dist/database-row-6Q6TRVE4.js +10 -0
  72. package/dist/database-select-option-BBBJFLLH.js +10 -0
  73. package/dist/database-view-5PLSOBI2.js +10 -0
  74. package/dist/experiment-DOVOZ34Y.js +10 -0
  75. package/dist/{external-item-D654PKLI.js → external-item-P2E5X7BZ.js} +5 -5
  76. package/dist/external-reference-7BSFF6SA.js +8 -0
  77. package/dist/feed-2IXHHP6O.js +12 -0
  78. package/dist/feed-item-DPE6LN7I.js +12 -0
  79. package/dist/{folder-ONKSWHUT.js → folder-6KM3KYD5.js} +3 -3
  80. package/dist/{game-BF7DP5JU.js → game-5AK5XWBR.js} +5 -5
  81. package/dist/grant-WTQSNT5A.js +8 -0
  82. package/dist/{grant-expiration-cleaner-DBiNTWbD.d.ts → grant-expiration-cleaner-CY6BcmlR.d.ts} +2 -2
  83. package/dist/import-batch-IXBJLQPJ.js +12 -0
  84. package/dist/{inbox-state-AN6FPCIY.js → inbox-state-E5NSVOGP.js} +3 -3
  85. package/dist/index.d.ts +7 -7
  86. package/dist/index.js +101 -55
  87. package/dist/map-RG5Y3CPC.js +10 -0
  88. package/dist/media-asset-QTCGLIZA.js +8 -0
  89. package/dist/{memory-NV6EVJBE.js → memory-PNRTMBZO.js} +3 -3
  90. package/dist/metric-65SVOX5C.js +10 -0
  91. package/dist/milestone-XEO6ZGDG.js +12 -0
  92. package/dist/{moderation-QSUVBCNS.js → moderation-FFHETMRB.js} +3 -3
  93. package/dist/observation-2Y3QKUXM.js +10 -0
  94. package/dist/page-2V27JGKJ.js +10 -0
  95. package/dist/posting-GBOSZCMZ.js +12 -0
  96. package/dist/profile-BLLDWIDK.js +8 -0
  97. package/dist/project-CQL5LN26.js +10 -0
  98. package/dist/{query-ast-CWo6_30h.d.ts → query-ast-C3m6kZYv.d.ts} +1 -1
  99. package/dist/reaction-T2DTJBBV.js +10 -0
  100. package/dist/{registry-CQoN_p8U.d.ts → registry-DunPv__d.d.ts} +1 -1
  101. package/dist/saved-view-4R2DBCL3.js +10 -0
  102. package/dist/schema/index.d.ts +320 -13
  103. package/dist/schema/index.js +98 -52
  104. package/dist/{schema-extension-5BUXRDPB.js → schema-extension-LNRF2YRD.js} +5 -5
  105. package/dist/{space-PZ737CIS.js → space-IJU24PBH.js} +6 -6
  106. package/dist/{space-membership-N35NRX6F.js → space-membership-DEUMM7NV.js} +3 -3
  107. package/dist/store/index.d.ts +153 -7
  108. package/dist/store/index.js +5 -5
  109. package/dist/{store-CvgiydME.d.ts → store-DtIDjk7c.d.ts} +18 -1
  110. package/dist/{system-Q5I6STDC.js → system-ILHCZOVO.js} +3 -3
  111. package/dist/{tag-C677GKQW.js → tag-SKCHM3U5.js} +3 -3
  112. package/dist/{task-WQLMPN5U.js → task-7AOBP7WC.js} +5 -5
  113. package/dist/task-view-NUGKELJP.js +10 -0
  114. package/dist/transaction-ZDSEGDIR.js +12 -0
  115. package/dist/transcription-PSCLDAGX.js +12 -0
  116. package/dist/{types-Def-5Nka.d.ts → types-S-BJAKyK.d.ts} +53 -2
  117. package/dist/user-widget-RLY2N46A.js +8 -0
  118. package/package.json +7 -7
  119. package/dist/account-BZHETMC4.js +0 -12
  120. package/dist/budget-WR2Q4YH3.js +0 -12
  121. package/dist/canvas-HFMMVQLD.js +0 -10
  122. package/dist/channel-DXDYJ3ZH.js +0 -12
  123. package/dist/chat-message-QUUQGXEW.js +0 -10
  124. package/dist/comment-Y2Y3QYYI.js +0 -10
  125. package/dist/dashboard-3DC6B4BJ.js +0 -10
  126. package/dist/database-DRGL5UYS.js +0 -10
  127. package/dist/database-field-F47FY3XX.js +0 -10
  128. package/dist/database-row-77OXW2NF.js +0 -10
  129. package/dist/database-select-option-6S3TTA7A.js +0 -10
  130. package/dist/database-view-YW7DQG5X.js +0 -10
  131. package/dist/experiment-TJOAL3OH.js +0 -10
  132. package/dist/external-reference-K5SUJIQN.js +0 -8
  133. package/dist/feed-L23XTNDI.js +0 -12
  134. package/dist/feed-item-GSO242J6.js +0 -12
  135. package/dist/grant-NNKMWXPX.js +0 -8
  136. package/dist/import-batch-JMESZ4VI.js +0 -12
  137. package/dist/map-AUV65YS3.js +0 -10
  138. package/dist/media-asset-4I5YPR4R.js +0 -8
  139. package/dist/metric-X7W55DLC.js +0 -10
  140. package/dist/milestone-JJGOGK4R.js +0 -12
  141. package/dist/observation-UZSXXGBC.js +0 -10
  142. package/dist/page-NI7XODS6.js +0 -10
  143. package/dist/posting-FZOYVGH2.js +0 -12
  144. package/dist/profile-42A43GES.js +0 -8
  145. package/dist/project-G2CTLDXY.js +0 -10
  146. package/dist/reaction-VAQBVDUD.js +0 -10
  147. package/dist/saved-view-AOQHGHNM.js +0 -10
  148. package/dist/task-view-T5S32V4L.js +0 -10
  149. package/dist/transaction-U3ITRGJP.js +0 -12
  150. package/dist/transcription-F3T23LLO.js +0 -12
  151. package/dist/user-widget-4SJJFPG5.js +0 -8
@@ -3,15 +3,16 @@ import {
3
3
  getNodeQuerySearchTokens,
4
4
  withoutNodeQueryMaterializedView,
5
5
  withoutNodeQueryPagination
6
- } from "./chunk-S4B5OPZU.js";
6
+ } from "./chunk-S5RP5RKY.js";
7
7
  import {
8
+ SYSTEM_SCHEMA_BASE_IRIS,
8
9
  isSystemNamespaceResource,
9
10
  isSystemSchemaIri
10
- } from "./chunk-V6HZ3LF3.js";
11
+ } from "./chunk-BY5O6LNC.js";
11
12
  import {
12
13
  createNodeId,
13
14
  getBaseSchemaIRI
14
- } from "./chunk-O64EK5FK.js";
15
+ } from "./chunk-EBNMV2VO.js";
15
16
 
16
17
  // src/store/query-ast.ts
17
18
  var QUERY_AST_VERSION = 1;
@@ -1142,6 +1143,9 @@ var NodeStore = class {
1142
1143
  this.onUnauthorizedRemoteChange = options.onUnauthorizedRemoteChange;
1143
1144
  this.auth = options.auth;
1144
1145
  this.telemetry = options.telemetry;
1146
+ if (this.authEvaluator && this.storage.setNodeReadAuthorizer) {
1147
+ this.storage.setNodeReadAuthorizer((nodes) => this.filterReadableNodes(nodes));
1148
+ }
1145
1149
  }
1146
1150
  /**
1147
1151
  * Initialize the store by loading the last Lamport time from storage.
@@ -1569,6 +1573,18 @@ var NodeStore = class {
1569
1573
  }
1570
1574
  };
1571
1575
  }
1576
+ if (this.storage.queryNodes && !this.nodeContentCipher && this.authEvaluator && !descriptor.nodeId && descriptor.materializedView !== void 0 && this.materializedAuthSupported()) {
1577
+ const authFingerprint = await this.authFingerprint();
1578
+ const result2 = await this.storage.queryNodes({ ...descriptor, authFingerprint });
1579
+ return {
1580
+ nodes: result2.nodes,
1581
+ totalCount: result2.totalCount,
1582
+ plan: {
1583
+ ...result2.plan,
1584
+ durationMs: Date.now() - start
1585
+ }
1586
+ };
1587
+ }
1572
1588
  if (this.storage.queryNodes && !this.nodeContentCipher && this.authEvaluator && !descriptor.nodeId && descriptor.materializedView === void 0) {
1573
1589
  const candidates = await this.storage.queryNodes(withoutNodeQueryPagination(descriptor));
1574
1590
  const readable2 = await this.filterReadableNodes(candidates.nodes);
@@ -2926,6 +2942,31 @@ var NodeStore = class {
2926
2942
  const decisions = await Promise.all(nodes.map((node) => this.canReadNode(node)));
2927
2943
  return nodes.filter((_, index) => decisions[index]);
2928
2944
  }
2945
+ /**
2946
+ * Whether storage can safely materialize a view under read authorization
2947
+ * (exploration 0226): it must both accept the read authorizer (to authorize
2948
+ * the id list at refresh) and expose a reload-stable authorization-state
2949
+ * version (to fingerprint cache validity). When either is missing the store
2950
+ * falls back to the authorize-then-paginate path, which never caches.
2951
+ */
2952
+ materializedAuthSupported() {
2953
+ return Boolean(this.storage.setNodeReadAuthorizer && this.storage.getAuthorizationStateVersion);
2954
+ }
2955
+ /**
2956
+ * A reload-stable fingerprint of the viewer's authorization context, folded
2957
+ * from the subject DID and the control-plane (grant) state version. Stamped
2958
+ * onto a materialized view so any grant change forces an `'authz-changed'`
2959
+ * refresh — the cache can never serve rows a revoked viewer may no longer
2960
+ * read. Returns `undefined` when authorization is off (materialized views run
2961
+ * untouched, as in the trusted single-user case).
2962
+ */
2963
+ async authFingerprint() {
2964
+ if (!this.authEvaluator || !this.storage.getAuthorizationStateVersion) {
2965
+ return void 0;
2966
+ }
2967
+ const version = await this.storage.getAuthorizationStateVersion();
2968
+ return `v1:${this.authorDID}:${version.count}:${version.maxUpdatedAt}`;
2969
+ }
2929
2970
  applyListPagination(nodes, options) {
2930
2971
  const offset = options?.offset ?? 0;
2931
2972
  const limit = options?.limit ?? nodes.length;
@@ -3372,8 +3413,16 @@ var DEFAULT_QUERY_VERIFICATION = {
3372
3413
  logFailures: true
3373
3414
  };
3374
3415
  var QUERY_TELEMETRY_FLUSH_THRESHOLD = 50;
3416
+ var COMPILED_QUERY_DIAGNOSTICS_MEMO_LIMIT = 512;
3375
3417
  var SQLITE_BIND_PARAMETER_BATCH_SIZE = 900;
3376
3418
  var SQLITE_HYDRATE_NODE_BATCH_SIZE = Math.floor(SQLITE_BIND_PARAMETER_BATCH_SIZE / 2);
3419
+ var SQL_HYDRATE_ARITY_BUCKETS = [1, 10, 50, 150, SQLITE_HYDRATE_NODE_BATCH_SIZE];
3420
+ var SQL_IN_ARITY_BUCKETS = [10, 50, 300, SQLITE_BIND_PARAMETER_BATCH_SIZE];
3421
+ function padToArityBucket(items, buckets) {
3422
+ const size = buckets.find((bucket) => bucket >= items.length) ?? items.length;
3423
+ if (size === items.length) return items;
3424
+ return [...items, ...Array(size - items.length).fill(null)];
3425
+ }
3377
3426
  function getMaterializedQueryRefreshReason(input) {
3378
3427
  if (!input.cached) {
3379
3428
  return "missing";
@@ -3384,6 +3433,9 @@ function getMaterializedQueryRefreshReason(input) {
3384
3433
  if (input.cached.descriptor_hash !== input.descriptorHash) {
3385
3434
  return "descriptor-changed";
3386
3435
  }
3436
+ if ((input.cached.auth_fingerprint ?? null) !== input.authFingerprint) {
3437
+ return "authz-changed";
3438
+ }
3387
3439
  if (input.cached.invalidated_at !== null) {
3388
3440
  return "invalidated";
3389
3441
  }
@@ -3407,19 +3459,42 @@ var SQLiteNodeStorageAdapter = class {
3407
3459
  ...options.queryVerification
3408
3460
  };
3409
3461
  this.queryDiagnostics = options.queryDiagnostics ?? false;
3462
+ this.aggregatedHydration = options.aggregatedHydration ?? true;
3463
+ this.scheduleMaintenance = options.scheduleMaintenance;
3410
3464
  }
3411
3465
  // Prepared statement cache for hot paths
3412
3466
  stmtCache = /* @__PURE__ */ new Map();
3413
3467
  adaptiveIndexing;
3414
3468
  queryVerification;
3415
3469
  queryDiagnostics;
3470
+ /** Hydrate one row per node via json_group_object (0264 Wave 2 flag). */
3471
+ aggregatedHydration;
3472
+ /** Idle scheduler for maintenance work (adaptive index creation, 0264). */
3473
+ scheduleMaintenance;
3416
3474
  pendingQueryTelemetry = /* @__PURE__ */ new Map();
3417
3475
  pendingQueryTelemetryHits = 0;
3418
3476
  adaptiveIndexBudgetColumnsReady = false;
3419
3477
  storageCapabilitiesPromise;
3478
+ /**
3479
+ * Plan diagnostics memoized per compiled SQL shape (the string EXPLAINed),
3480
+ * shared across executions AND concurrent callers. Cleared when this adapter
3481
+ * creates/drops an adaptive index, since that changes plans. Debug mode must
3482
+ * not distort what it measures: per-execution EXPLAIN + index-inventory
3483
+ * round-trips convoyed the single serial worker at boot.
3484
+ */
3485
+ compiledQueryDiagnosticsMemo = /* @__PURE__ */ new Map();
3420
3486
  spatialTablesState = "unknown";
3421
3487
  fullTextSearchTablesState = "unknown";
3422
3488
  writeQueue = Promise.resolve();
3489
+ /**
3490
+ * Read-authorization filter applied before persisting a materialized view's
3491
+ * id list (exploration 0226). Wired by `NodeStore` when an auth evaluator is
3492
+ * present; `undefined` means materialized views are computed unauthorized
3493
+ * (the trusted single-user case).
3494
+ */
3495
+ nodeReadAuthorizer;
3496
+ /** One-time guard: ensure the `auth_fingerprint` column exists on upgraded DBs. */
3497
+ materializationColumnsReady = false;
3423
3498
  getSQLiteAdapter() {
3424
3499
  return this.db;
3425
3500
  }
@@ -3574,6 +3649,66 @@ var SQLiteNodeStorageAdapter = class {
3574
3649
  );
3575
3650
  return rows.map((row) => this.deserializeChange(row));
3576
3651
  }
3652
+ /**
3653
+ * Compact the local change log (exploration 0254 / F3): delete only
3654
+ * *superseded* history so the OPFS file shrinks (fast cold open) and the
3655
+ * outbound-resync slice shrinks (cheap first sync), without affecting reads,
3656
+ * outbound sync, hash-chain chaining, or convergence with peers that never
3657
+ * compacted.
3658
+ *
3659
+ * A row is deleted iff ALL of:
3660
+ * - `lamport_time < wsafe` — below the confirmed-durable floor, so the hub
3661
+ * holds it and it is not part of the unconfirmed outbound tail (K1);
3662
+ * - it is NOT a node's hash-chain tip — we keep every row at a node's
3663
+ * `MAX(lamport_time)` (the whole tie group), so `getLastChange()` still
3664
+ * returns a real tip and the next write's `parentHash`/hash/signature are
3665
+ * byte-identical to an uncompacted peer's (K2);
3666
+ * - it backs NO currently-winning LWW value — its `(node_id, lamport_time,
3667
+ * author)` is not the provenance of any live `node_properties` row (K3).
3668
+ *
3669
+ * K3 is the load-bearing safety net: because every live projection value keeps
3670
+ * its backing row, the retained log still materialises to — and can re-push —
3671
+ * the exact current state, so a stranded/rolled-back peer never loses live
3672
+ * data. Reads are unaffected (they read the materialized `nodes`/
3673
+ * `node_properties`, never the log). Only rows are deleted, never rewritten
3674
+ * (the hash + signature are immutable and unforgeable). Runs chunked inside
3675
+ * the write lane, yields between chunks, and never throws.
3676
+ */
3677
+ async pruneSupersededChanges(wsafe, options = {}) {
3678
+ const chunk = Math.max(1, Math.floor(options.chunk ?? 5e3));
3679
+ const maxRows = Math.max(0, Math.floor(options.maxRows ?? 25e4));
3680
+ if (!Number.isFinite(wsafe) || wsafe <= 0 || maxRows === 0) return { deleted: 0 };
3681
+ let deleted = 0;
3682
+ for (; ; ) {
3683
+ const remaining = maxRows - deleted;
3684
+ const limit = Math.min(chunk, remaining);
3685
+ if (limit <= 0) break;
3686
+ const affected = await this.enqueueWrite(async () => {
3687
+ const result = await this.db.run(
3688
+ `DELETE FROM changes WHERE hash IN (
3689
+ SELECT c.hash FROM changes c
3690
+ WHERE c.lamport_time < ?
3691
+ AND c.lamport_time < (
3692
+ SELECT MAX(t.lamport_time) FROM changes t WHERE t.node_id = c.node_id
3693
+ )
3694
+ AND NOT EXISTS (
3695
+ SELECT 1 FROM node_properties p
3696
+ WHERE p.node_id = c.node_id
3697
+ AND p.lamport_time = c.lamport_time
3698
+ AND p.updated_by = c.author
3699
+ )
3700
+ LIMIT ?
3701
+ )`,
3702
+ [wsafe, limit]
3703
+ );
3704
+ return result.changes ?? 0;
3705
+ });
3706
+ deleted += affected;
3707
+ if (affected < limit) break;
3708
+ await new Promise((resolve) => setTimeout(resolve, 0));
3709
+ }
3710
+ return { deleted };
3711
+ }
3577
3712
  async getChangeByHash(hash) {
3578
3713
  const row = await this.db.queryOne(
3579
3714
  `SELECT hash, node_id, payload, lamport_time,
@@ -3599,7 +3734,8 @@ var SQLiteNodeStorageAdapter = class {
3599
3734
  const changes = /* @__PURE__ */ new Map();
3600
3735
  if (uniqueIds.length === 0) return changes;
3601
3736
  for (const batch of chunkItems(uniqueIds, SQLITE_BIND_PARAMETER_BATCH_SIZE)) {
3602
- const placeholders = batch.map(() => "?").join(", ");
3737
+ const padded = padToArityBucket(batch, SQL_IN_ARITY_BUCKETS);
3738
+ const placeholders = padded.map(() => "?").join(", ");
3603
3739
  const rows = await this.db.query(
3604
3740
  `SELECT hash, node_id, payload, lamport_time,
3605
3741
  lamport_peer as lamport_author, wall_time,
@@ -3613,7 +3749,7 @@ var SQLiteNodeStorageAdapter = class {
3613
3749
  ORDER BY c2.lamport_time DESC
3614
3750
  LIMIT 1
3615
3751
  )`,
3616
- batch
3752
+ padded
3617
3753
  );
3618
3754
  rows.forEach((row) => {
3619
3755
  changes.set(row.node_id, this.deserializeChange(row));
@@ -3628,57 +3764,24 @@ var SQLiteNodeStorageAdapter = class {
3628
3764
  }
3629
3765
  // ─── Materialized State Operations ────────────────────────────────────────
3630
3766
  async getNode(id) {
3631
- const nodeRow = await this.db.queryOne(`SELECT * FROM nodes WHERE id = ?`, [id]);
3632
- if (!nodeRow) return null;
3633
- const propRows = await this.db.query(
3634
- `SELECT * FROM node_properties WHERE node_id = ?`,
3635
- [id]
3636
- );
3637
- const properties = {};
3638
- const timestamps = {};
3639
- let updatedBy = nodeRow.created_by;
3640
- for (const prop of propRows) {
3641
- properties[prop.property_key] = this.deserializeValue(prop.value);
3642
- timestamps[prop.property_key] = {
3643
- lamport: prop.lamport_time,
3644
- author: prop.updated_by,
3645
- wallTime: prop.updated_at
3646
- };
3647
- if (prop.updated_at >= nodeRow.updated_at) {
3648
- updatedBy = prop.updated_by;
3649
- }
3650
- }
3651
- return {
3652
- id: nodeRow.id,
3653
- schemaId: nodeRow.schema_id,
3654
- properties,
3655
- timestamps,
3656
- deleted: nodeRow.deleted_at !== null,
3657
- deletedAt: nodeRow.deleted_at ? { lamport: 0, author: "", wallTime: nodeRow.deleted_at } : void 0,
3658
- createdAt: nodeRow.created_at,
3659
- createdBy: nodeRow.created_by,
3660
- updatedAt: nodeRow.updated_at,
3661
- updatedBy
3662
- };
3767
+ const nodes = await this.hydrateNodesByIds([id]);
3768
+ return nodes[0] ?? null;
3663
3769
  }
3664
3770
  async getNodes(ids) {
3665
3771
  const uniqueIds = Array.from(new Set(ids));
3666
3772
  if (uniqueIds.length === 0) return [];
3667
- const nodes = [];
3668
- for (const batch of chunkItems(uniqueIds, SQLITE_HYDRATE_NODE_BATCH_SIZE)) {
3669
- nodes.push(...await this.hydrateNodesByIds(batch));
3670
- }
3671
- return nodes;
3773
+ return this.hydrateNodesByIds(uniqueIds);
3672
3774
  }
3673
3775
  async getExistingNodeIds(ids) {
3674
3776
  const uniqueIds = Array.from(new Set(ids));
3675
3777
  if (uniqueIds.length === 0) return [];
3676
3778
  const existingIds = /* @__PURE__ */ new Set();
3677
3779
  for (const batch of chunkItems(uniqueIds, SQLITE_BIND_PARAMETER_BATCH_SIZE)) {
3678
- const placeholders = batch.map(() => "?").join(", ");
3780
+ const padded = padToArityBucket(batch, SQL_IN_ARITY_BUCKETS);
3781
+ const placeholders = padded.map(() => "?").join(", ");
3679
3782
  const rows = await this.db.query(
3680
3783
  `SELECT id FROM nodes WHERE id IN (${placeholders})`,
3681
- batch
3784
+ padded
3682
3785
  );
3683
3786
  rows.forEach((row) => existingIds.add(row.id));
3684
3787
  }
@@ -3882,15 +3985,27 @@ var SQLiteNodeStorageAdapter = class {
3882
3985
  this.debugQueryPlan(descriptor, result2);
3883
3986
  return result2;
3884
3987
  }
3885
- const [queryDiagnostics, idQuery] = await Promise.all([
3988
+ const [queryDiagnostics, mainQuery] = await Promise.all([
3886
3989
  this.isQueryDiagnosticsEnabled() ? this.collectCompiledQueryDiagnostics(compiled) : Promise.resolve({}),
3887
- timeQuery(this.db, compiled.sql, compiled.params)
3990
+ compiled.fused ? timeQuery(this.db, compiled.fused.sql, compiled.fused.params) : timeQuery(this.db, compiled.sql, compiled.params)
3888
3991
  ]);
3889
- const idRows = idQuery.result;
3890
- const ids = idRows.map((row) => row.id);
3891
- const candidates = await this.hydrateNodesByIds(ids);
3992
+ let candidates;
3993
+ let candidateCount;
3994
+ let fusedExactCount;
3995
+ if (compiled.fused) {
3996
+ const rows = mainQuery.result;
3997
+ candidates = this.aggregatedHydration ? this.hydrateAggregatedRows(rows) : this.hydrateJoinedRows(rows);
3998
+ candidateCount = candidates.length;
3999
+ if (compiled.fused.includesExactCount && rows.length > 0) {
4000
+ fusedExactCount = Number(rows[0].total_count ?? 0);
4001
+ }
4002
+ } else {
4003
+ const ids = mainQuery.result.map((row) => row.id);
4004
+ candidates = await this.hydrateNodesByIds(ids);
4005
+ candidateCount = ids.length;
4006
+ }
3892
4007
  const nodes = applyNodeQueryDescriptor(candidates, compiled.postFilterDescriptor);
3893
- const totalCount = compiled.sqlPagination ? descriptor.count === "exact" ? await this.countCompiledNodeQuery(descriptor, spatialPlan, fullTextSearchPlan) : void 0 : applyNodeQueryDescriptor(
4008
+ const totalCount = compiled.sqlPagination ? descriptor.count === "exact" ? fusedExactCount ?? (candidateCount === 0 && (descriptor.offset ?? 0) === 0 ? 0 : await this.countCompiledNodeQuery(descriptor, spatialPlan, fullTextSearchPlan)) : void 0 : applyNodeQueryDescriptor(
3894
4009
  candidates,
3895
4010
  withoutNodeQueryPagination(compiled.postFilterDescriptor)
3896
4011
  ).length;
@@ -3903,14 +4018,14 @@ var SQLiteNodeStorageAdapter = class {
3903
4018
  totalCount,
3904
4019
  plan: {
3905
4020
  strategy: "storage-query",
3906
- candidateNodeCount: ids.length,
4021
+ candidateNodeCount: candidateCount,
3907
4022
  hydratedNodeCount: candidates.length,
3908
4023
  returnedNodeCount: nodes.length,
3909
4024
  durationMs: Date.now() - start,
3910
- sql: compiled.sql,
3911
- params: compiled.params,
4025
+ sql: compiled.fused ? compiled.fused.sql : compiled.sql,
4026
+ params: compiled.fused ? compiled.fused.params : compiled.params,
3912
4027
  postFilterReason: compiled.postFilterReason,
3913
- candidateQueryDurationMs: idQuery.durationMs,
4028
+ candidateQueryDurationMs: mainQuery.durationMs,
3914
4029
  ...candidateAccelerators.length > 0 ? {
3915
4030
  candidateAccelerators
3916
4031
  } : {},
@@ -3959,6 +4074,21 @@ var SQLiteNodeStorageAdapter = class {
3959
4074
  );
3960
4075
  return row ? parseInt(row.value, 10) || 0 : 0;
3961
4076
  }
4077
+ /**
4078
+ * The lowest confirmed-durable sync cursor across every node-change room
4079
+ * (`MIN` over the persisted `nodeSync:hwm:*` marks), or `null` when no room has
4080
+ * ever confirmed. This is the safe compaction floor (exploration 0254): the
4081
+ * hub durably holds every change at or below it. Returns `null` — never 0 — so
4082
+ * a workspace that has never synced is left untouched.
4083
+ */
4084
+ async getMinConfirmedSyncCursor() {
4085
+ const row = await this.db.queryOne(
4086
+ `SELECT MIN(CAST(value AS INTEGER)) AS min_value
4087
+ FROM sync_state WHERE key LIKE 'nodeSync:hwm:%'`
4088
+ );
4089
+ const value = row?.min_value;
4090
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
4091
+ }
3962
4092
  async setSyncCursor(room, lamport) {
3963
4093
  await this.enqueueWrite(async () => {
3964
4094
  await this.db.run(
@@ -3969,6 +4099,27 @@ var SQLiteNodeStorageAdapter = class {
3969
4099
  );
3970
4100
  });
3971
4101
  }
4102
+ // ─── App State (generic K/V, FK-free) ─────────────────────────────────────
4103
+ /** Namespace app-state keys so they can't collide with sync cursors. */
4104
+ appStateKey(key) {
4105
+ return `app:${key}`;
4106
+ }
4107
+ async getAppState(key) {
4108
+ const row = await this.db.queryOne(
4109
+ `SELECT value FROM sync_state WHERE key = ?`,
4110
+ [this.appStateKey(key)]
4111
+ );
4112
+ return row ? row.value : null;
4113
+ }
4114
+ async setAppState(key, value) {
4115
+ await this.enqueueWrite(async () => {
4116
+ await this.db.run(
4117
+ `INSERT INTO sync_state (key, value) VALUES (?, ?)
4118
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
4119
+ [this.appStateKey(key), value]
4120
+ );
4121
+ });
4122
+ }
3972
4123
  // ─── Document Content (Yjs) ───────────────────────────────────────────────
3973
4124
  async getDocumentContent(nodeId) {
3974
4125
  const row = await this.db.queryOne(
@@ -4697,19 +4848,10 @@ var SQLiteNodeStorageAdapter = class {
4697
4848
  }
4698
4849
  return Array.from(nodeMap.values());
4699
4850
  }
4700
- async hydrateNodesByIds(ids) {
4701
- if (ids.length === 0) {
4702
- return [];
4703
- }
4704
- if (ids.length > SQLITE_HYDRATE_NODE_BATCH_SIZE) {
4705
- const nodes = [];
4706
- for (const batch of chunkItems(ids, SQLITE_HYDRATE_NODE_BATCH_SIZE)) {
4707
- nodes.push(...await this.hydrateNodesByIds(batch));
4708
- }
4709
- return nodes;
4710
- }
4711
- const values = ids.map(() => "(?, ?)").join(", ");
4712
- const params = ids.flatMap((id, ordinal) => [id, ordinal]);
4851
+ buildHydrateChunkQuery(ids) {
4852
+ const padded = padToArityBucket(ids, SQL_HYDRATE_ARITY_BUCKETS);
4853
+ const values = padded.map(() => "(?, ?)").join(", ");
4854
+ const params = padded.flatMap((id, ordinal) => [id, ordinal]);
4713
4855
  const sql = `
4714
4856
  WITH wanted(id, ordinal) AS (
4715
4857
  VALUES ${values}
@@ -4732,14 +4874,123 @@ var SQLiteNodeStorageAdapter = class {
4732
4874
  LEFT JOIN node_properties p ON p.node_id = n.id
4733
4875
  ORDER BY wanted.ordinal ASC, p.property_key ASC
4734
4876
  `;
4735
- const rows = await this.db.query(sql, params);
4736
- return this.hydrateJoinedRows(rows);
4877
+ return { sql, params };
4878
+ }
4879
+ async hydrateNodesByIds(ids) {
4880
+ if (ids.length === 0) {
4881
+ return [];
4882
+ }
4883
+ const aggregated = this.aggregatedHydration;
4884
+ const chunks = ids.length > SQLITE_HYDRATE_NODE_BATCH_SIZE ? chunkItems(ids, SQLITE_HYDRATE_NODE_BATCH_SIZE) : [ids];
4885
+ const reads = chunks.map(
4886
+ (chunk) => aggregated ? this.buildAggregatedHydrateChunkQuery(chunk) : this.buildHydrateChunkQuery(chunk)
4887
+ );
4888
+ const parse = (rows) => aggregated ? this.hydrateAggregatedRows(rows) : this.hydrateJoinedRows(rows);
4889
+ if (reads.length > 1 && typeof this.db.queryBatch === "function") {
4890
+ const results = await this.db.queryBatch(reads);
4891
+ const nodes2 = [];
4892
+ for (const rows of results) {
4893
+ nodes2.push(...parse(rows));
4894
+ }
4895
+ return nodes2;
4896
+ }
4897
+ const nodes = [];
4898
+ for (const read of reads) {
4899
+ const rows = await this.db.query(read.sql, read.params);
4900
+ nodes.push(...parse(rows));
4901
+ }
4902
+ return nodes;
4903
+ }
4904
+ /**
4905
+ * Aggregated hydrate (exploration 0264, Wave 2): collapse the EAV rows to
4906
+ * ONE row per node inside SQL via `json_group_object`, so the boundary
4907
+ * ships N rows instead of N × properties. `value` is stored as JSON text
4908
+ * in a BLOB — `json(CAST(… AS TEXT))` re-emits it as real JSON inside the
4909
+ * aggregate (without the cast/wrap it would double-encode as a string).
4910
+ */
4911
+ buildAggregatedHydrateChunkQuery(ids) {
4912
+ const padded = padToArityBucket(ids, SQL_HYDRATE_ARITY_BUCKETS);
4913
+ const values = padded.map(() => "(?, ?)").join(", ");
4914
+ const params = padded.flatMap((id, ordinal) => [id, ordinal]);
4915
+ const sql = `
4916
+ WITH wanted(id, ordinal) AS (
4917
+ VALUES ${values}
4918
+ )
4919
+ SELECT
4920
+ n.id,
4921
+ n.schema_id,
4922
+ n.created_at,
4923
+ n.updated_at,
4924
+ n.created_by,
4925
+ n.deleted_at,
4926
+ wanted.ordinal,
4927
+ json_group_object(p.property_key, json(CAST(p.value AS TEXT)))
4928
+ FILTER (WHERE p.property_key IS NOT NULL) AS props_json,
4929
+ json_group_object(
4930
+ p.property_key,
4931
+ json_object('l', p.lamport_time, 'b', p.updated_by, 'w', p.updated_at)
4932
+ ) FILTER (WHERE p.property_key IS NOT NULL) AS meta_json
4933
+ FROM wanted
4934
+ JOIN nodes n ON n.id = wanted.id
4935
+ LEFT JOIN node_properties p ON p.node_id = n.id
4936
+ GROUP BY n.id
4937
+ ORDER BY wanted.ordinal ASC
4938
+ `;
4939
+ return { sql, params };
4940
+ }
4941
+ /** Parse one-row-per-node aggregated hydrate results into NodeStates. */
4942
+ hydrateAggregatedRows(rows) {
4943
+ const nodes = [];
4944
+ for (const row of rows) {
4945
+ const properties = row.props_json ? JSON.parse(row.props_json) : {};
4946
+ const meta = row.meta_json ? JSON.parse(row.meta_json) : {};
4947
+ const timestamps = {};
4948
+ let updatedBy = row.created_by;
4949
+ for (const [key, entry] of Object.entries(meta)) {
4950
+ timestamps[key] = {
4951
+ lamport: entry.l ?? 0,
4952
+ author: entry.b ?? "",
4953
+ wallTime: entry.w ?? 0
4954
+ };
4955
+ if ((entry.w ?? 0) >= row.updated_at) {
4956
+ updatedBy = entry.b ?? row.created_by;
4957
+ }
4958
+ }
4959
+ nodes.push({
4960
+ id: row.id,
4961
+ schemaId: row.schema_id,
4962
+ properties,
4963
+ timestamps,
4964
+ deleted: row.deleted_at !== null,
4965
+ deletedAt: row.deleted_at ? { lamport: 0, author: "", wallTime: row.deleted_at } : void 0,
4966
+ createdAt: row.created_at,
4967
+ createdBy: row.created_by,
4968
+ updatedAt: row.updated_at,
4969
+ updatedBy
4970
+ });
4971
+ }
4972
+ return nodes;
4737
4973
  }
4974
+ /**
4975
+ * Read (or refresh) a materialized view.
4976
+ *
4977
+ * A materialized view is a purely LOCAL, DERIVED cache: an ordered list of
4978
+ * node ids (membership + order) plus bookkeeping. It is NEVER part of the
4979
+ * change log and is NEVER synced — peers each derive their own. It therefore
4980
+ * carries no authority and can be rebuilt or dropped at any time (a wiped
4981
+ * cache only costs a recompute, never data). Content is re-hydrated from the
4982
+ * live `nodes`/`node_properties` tables on every read, so the cache exposes
4983
+ * nothing the underlying tables don't already, and a write to the schema
4984
+ * (`invalidated_at`) or a grant change (`auth_fingerprint`) forces a
4985
+ * re-materialization. See exploration 0226.
4986
+ */
4738
4987
  async queryMaterializedView(descriptor, start) {
4739
4988
  const materializedView = descriptor.materializedView;
4740
4989
  if (!materializedView) {
4741
4990
  throw new Error("Materialized view query requires descriptor.materializedView");
4742
4991
  }
4992
+ await this.ensureMaterializationColumns();
4993
+ const authFingerprint = descriptor.authFingerprint ?? null;
4743
4994
  const baseDescriptor = withoutNodeQueryMaterializedView(withoutNodeQueryPagination(descriptor));
4744
4995
  const descriptorJson = this.stringifyStable(baseDescriptor);
4745
4996
  const descriptorHash = this.hashScalarValue(descriptorJson);
@@ -4748,10 +4999,11 @@ var SQLiteNodeStorageAdapter = class {
4748
4999
  const refreshReason = getMaterializedQueryRefreshReason({
4749
5000
  cached,
4750
5001
  descriptorHash,
5002
+ authFingerprint,
4751
5003
  cacheExpired,
4752
5004
  forceRefresh: materializedView.forceRefresh ?? false
4753
5005
  });
4754
- const canUseCache = cached !== null && cached.descriptor_hash === descriptorHash && cached.invalidated_at === null && !cacheExpired && !materializedView.forceRefresh;
5006
+ const canUseCache = cached !== null && cached.descriptor_hash === descriptorHash && (cached.auth_fingerprint ?? null) === authFingerprint && cached.invalidated_at === null && !cacheExpired && !materializedView.forceRefresh;
4755
5007
  const readPlan = canUseCache ? {
4756
5008
  viewId: materializedView.viewId,
4757
5009
  descriptorHash,
@@ -4764,6 +5016,7 @@ var SQLiteNodeStorageAdapter = class {
4764
5016
  descriptor: baseDescriptor,
4765
5017
  descriptorHash,
4766
5018
  descriptorJson,
5019
+ authFingerprint,
4767
5020
  refreshReason: refreshReason ?? "missing",
4768
5021
  invalidatedAt: cached?.invalidated_at ?? null
4769
5022
  });
@@ -4780,14 +5033,63 @@ var SQLiteNodeStorageAdapter = class {
4780
5033
  descriptor_json,
4781
5034
  generated_at,
4782
5035
  invalidated_at,
4783
- row_count
5036
+ row_count,
5037
+ auth_fingerprint
4784
5038
  FROM node_query_materializations
4785
5039
  WHERE view_id = ?`,
4786
5040
  [viewId]
4787
5041
  );
4788
5042
  }
5043
+ /**
5044
+ * Ensure the `auth_fingerprint` column exists. Fresh databases get it from
5045
+ * the DDL; databases upgraded in place (the runtime applies the full DDL with
5046
+ * `CREATE TABLE IF NOT EXISTS`, which cannot add a column) need this guard.
5047
+ * Idempotent and memoized (exploration 0226).
5048
+ */
5049
+ async ensureMaterializationColumns() {
5050
+ if (this.materializationColumnsReady) return;
5051
+ try {
5052
+ const columns = await this.db.query(
5053
+ `PRAGMA table_info(node_query_materializations)`
5054
+ );
5055
+ const hasColumn = columns.some((column) => column.name === "auth_fingerprint");
5056
+ if (!hasColumn) {
5057
+ await this.db.run(
5058
+ "ALTER TABLE node_query_materializations ADD COLUMN auth_fingerprint TEXT"
5059
+ );
5060
+ }
5061
+ } catch {
5062
+ }
5063
+ this.materializationColumnsReady = true;
5064
+ }
5065
+ setNodeReadAuthorizer(authorizer) {
5066
+ this.nodeReadAuthorizer = authorizer;
5067
+ }
5068
+ /**
5069
+ * Reload-stable version of the authorization-relevant control-plane state:
5070
+ * grants (`Grant` schema) and per-subject `/sys/authz/` resources. Ordinary
5071
+ * data writes do not touch these, so a database's materialized views survive
5072
+ * reloads and edits, but any grant change shifts `count`/`maxUpdatedAt` and
5073
+ * forces an `'authz-changed'` refresh (exploration 0226).
5074
+ */
5075
+ async getAuthorizationStateVersion() {
5076
+ const grantIri = SYSTEM_SCHEMA_BASE_IRIS.Grant;
5077
+ const row = await this.db.queryOne(
5078
+ `SELECT COUNT(*) AS count, COALESCE(MAX(updated_at), 0) AS max_updated_at
5079
+ FROM nodes
5080
+ WHERE schema_id = ?
5081
+ OR schema_id LIKE ?
5082
+ OR id LIKE 'xnet://%/sys/authz/%'`,
5083
+ [grantIri, `${grantIri}@%`]
5084
+ );
5085
+ return {
5086
+ count: row?.count ?? 0,
5087
+ maxUpdatedAt: row?.max_updated_at ?? 0
5088
+ };
5089
+ }
4789
5090
  async refreshMaterializedView(input) {
4790
5091
  const refreshed = await this.queryNodes(input.descriptor);
5092
+ const authorizedNodes = this.nodeReadAuthorizer ? await this.nodeReadAuthorizer(refreshed.nodes) : refreshed.nodes;
4791
5093
  const generatedAt = Date.now();
4792
5094
  await this.enqueueWrite(async () => {
4793
5095
  await this.db.beginTransaction();
@@ -4804,26 +5106,29 @@ var SQLiteNodeStorageAdapter = class {
4804
5106
  descriptor_json,
4805
5107
  generated_at,
4806
5108
  invalidated_at,
4807
- row_count
5109
+ row_count,
5110
+ auth_fingerprint
4808
5111
  )
4809
- VALUES (?, ?, ?, ?, ?, NULL, ?)
5112
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?)
4810
5113
  ON CONFLICT(view_id) DO UPDATE SET
4811
5114
  descriptor_hash = excluded.descriptor_hash,
4812
5115
  schema_id = excluded.schema_id,
4813
5116
  descriptor_json = excluded.descriptor_json,
4814
5117
  generated_at = excluded.generated_at,
4815
5118
  invalidated_at = NULL,
4816
- row_count = excluded.row_count`,
5119
+ row_count = excluded.row_count,
5120
+ auth_fingerprint = excluded.auth_fingerprint`,
4817
5121
  [
4818
5122
  input.viewId,
4819
5123
  input.descriptorHash,
4820
5124
  input.descriptor.schemaId,
4821
5125
  input.descriptorJson,
4822
5126
  generatedAt,
4823
- refreshed.nodes.length
5127
+ authorizedNodes.length,
5128
+ input.authFingerprint
4824
5129
  ]
4825
5130
  );
4826
- for (const [ordinal, node] of refreshed.nodes.entries()) {
5131
+ for (const [ordinal, node] of authorizedNodes.entries()) {
4827
5132
  await this.db.run(
4828
5133
  `INSERT INTO node_query_materialized_ids (view_id, ordinal, node_id)
4829
5134
  VALUES (?, ?, ?)`,
@@ -4841,7 +5146,7 @@ var SQLiteNodeStorageAdapter = class {
4841
5146
  descriptorHash: input.descriptorHash,
4842
5147
  generatedAt,
4843
5148
  invalidatedAt: input.invalidatedAt,
4844
- rowCount: refreshed.nodes.length,
5149
+ rowCount: authorizedNodes.length,
4845
5150
  cacheHit: false,
4846
5151
  refreshReason: input.refreshReason
4847
5152
  };
@@ -5359,6 +5664,23 @@ CREATE INDEX IF NOT EXISTS idx_node_spatial_ids_schema
5359
5664
  if (!stats || !this.shouldCreateAdaptiveIndexes(stats)) {
5360
5665
  return { descriptorHash, adaptiveIndexNames: [] };
5361
5666
  }
5667
+ if (this.scheduleMaintenance) {
5668
+ const input = {
5669
+ descriptorHash,
5670
+ schemaId: descriptor.schemaId,
5671
+ hints: adaptiveIndexHints,
5672
+ now
5673
+ };
5674
+ this.scheduleMaintenance(
5675
+ () => this.ensureAdaptiveIndexes(input).then(
5676
+ () => void 0,
5677
+ (err) => {
5678
+ console.warn("[SQLiteNodeStorageAdapter] deferred adaptive index skipped:", err);
5679
+ }
5680
+ )
5681
+ );
5682
+ return { descriptorHash, adaptiveIndexNames: [] };
5683
+ }
5362
5684
  const adaptiveIndexNames = await this.ensureAdaptiveIndexes({
5363
5685
  descriptorHash,
5364
5686
  schemaId: descriptor.schemaId,
@@ -5428,7 +5750,34 @@ CREATE INDEX IF NOT EXISTS idx_node_spatial_ids_schema
5428
5750
  isQueryDiagnosticsEnabled() {
5429
5751
  return this.queryDiagnostics || this.isQueryDebugEnabled();
5430
5752
  }
5431
- async collectCompiledQueryDiagnostics(compiled) {
5753
+ /**
5754
+ * Throttled: one collection per unique compiled SQL shape per session.
5755
+ * EXPLAIN plans depend on the statement, not the bound values, so keyset
5756
+ * pages of the same query share one entry. Concurrent callers share the
5757
+ * in-flight promise; failed collections are not memoized.
5758
+ */
5759
+ collectCompiledQueryDiagnostics(compiled) {
5760
+ const memoKey = compiled.sql;
5761
+ const memoized = this.compiledQueryDiagnosticsMemo.get(memoKey);
5762
+ if (memoized) {
5763
+ return memoized;
5764
+ }
5765
+ const pending = this.computeCompiledQueryDiagnostics(compiled).then((diagnostics) => {
5766
+ if (diagnostics.diagnosticsError) {
5767
+ this.compiledQueryDiagnosticsMemo.delete(memoKey);
5768
+ }
5769
+ return diagnostics;
5770
+ });
5771
+ if (this.compiledQueryDiagnosticsMemo.size >= COMPILED_QUERY_DIAGNOSTICS_MEMO_LIMIT) {
5772
+ const oldest = this.compiledQueryDiagnosticsMemo.keys().next().value;
5773
+ if (oldest !== void 0) {
5774
+ this.compiledQueryDiagnosticsMemo.delete(oldest);
5775
+ }
5776
+ }
5777
+ this.compiledQueryDiagnosticsMemo.set(memoKey, pending);
5778
+ return pending;
5779
+ }
5780
+ async computeCompiledQueryDiagnostics(compiled) {
5432
5781
  try {
5433
5782
  const [analysis, indexes, storageCapabilities] = await Promise.all([
5434
5783
  analyzeQuery(this.db, compiled.sql, compiled.params),
@@ -5484,6 +5833,9 @@ CREATE INDEX IF NOT EXISTS idx_node_spatial_ids_schema
5484
5833
  if (!this.queryVerification.enabled) {
5485
5834
  return { strategy: "skipped", reason: "disabled" };
5486
5835
  }
5836
+ if (this.nodeReadAuthorizer) {
5837
+ return { strategy: "skipped", reason: "authorized-materialization" };
5838
+ }
5487
5839
  const candidateScopeCount = descriptor.nodeId ? 1 : await this.countNodes({
5488
5840
  schemaId: descriptor.schemaId,
5489
5841
  includeDeleted: descriptor.includeDeleted
@@ -5649,6 +6001,7 @@ CREATE INDEX IF NOT EXISTS idx_node_spatial_ids_schema
5649
6001
  if (createdIndex) {
5650
6002
  await runAnalyze(this.db, "node_property_scalars");
5651
6003
  await this.db.exec("PRAGMA optimize");
6004
+ this.compiledQueryDiagnosticsMemo.clear();
5652
6005
  }
5653
6006
  return touchedIndexNames;
5654
6007
  }
@@ -5797,6 +6150,7 @@ CREATE INDEX IF NOT EXISTS idx_node_spatial_ids_schema
5797
6150
  async dropAdaptiveIndex(indexName, reason) {
5798
6151
  await this.db.exec(`DROP INDEX IF EXISTS ${this.quoteSqlIdentifier(indexName)}`);
5799
6152
  await this.db.run("DELETE FROM query_index_candidates WHERE index_name = ?", [indexName]);
6153
+ this.compiledQueryDiagnosticsMemo.clear();
5800
6154
  this.debugAdaptiveIndex("drop", { indexName, reason });
5801
6155
  }
5802
6156
  debugAdaptiveIndex(action, details) {
@@ -5880,17 +6234,37 @@ WHERE schema_id = ${this.quoteSqlLiteral(schemaId)}
5880
6234
  const hasPropertySort = Object.keys(descriptor.orderBy ?? {}).some(
5881
6235
  (key) => key !== "createdAt" && key !== "updatedAt"
5882
6236
  );
6237
+ const propertySort = this.resolvePropertySortPushdown(descriptor, hasPropertySort);
5883
6238
  const hasSqlCandidateBenefit = scalarWhere.length > 0 || spatialPlan !== null || fullTextSearchPlan !== null || !descriptor.spatial || this.hasOnlySystemOrdering(descriptor.orderBy);
5884
- if (!hasSqlCandidateBenefit) {
6239
+ if (!hasSqlCandidateBenefit && !propertySort) {
5885
6240
  return null;
5886
6241
  }
5887
6242
  return this.compileSqlQuery(descriptor, {
5888
6243
  whereEntries: scalarWhere,
5889
- canUseSqlPagination: !hasPropertySort && !descriptor.spatial && !descriptor.search,
6244
+ canUseSqlPagination: (!hasPropertySort || propertySort !== null) && !descriptor.spatial && !descriptor.search,
5890
6245
  spatialPlan,
5891
- fullTextSearchPlan
6246
+ fullTextSearchPlan,
6247
+ propertySort
5892
6248
  });
5893
6249
  }
6250
+ /**
6251
+ * A custom-property sort can push down when it is the descriptor's ONLY
6252
+ * order key and no cursor/spatial/search accelerator is in play. Gated on
6253
+ * the adaptive-indexing flag: the typed partial indexes on
6254
+ * `node_property_scalars` make the join+order cheap, and the flag keeps
6255
+ * the behavioural change opt-in while it soaks (exploration 0264).
6256
+ */
6257
+ resolvePropertySortPushdown(descriptor, hasPropertySort) {
6258
+ if (!hasPropertySort || !this.adaptiveIndexing.enabled) return null;
6259
+ if (descriptor.spatial || descriptor.search || descriptor.after !== void 0) return null;
6260
+ const entries = Object.entries(descriptor.orderBy ?? {}).filter(
6261
+ (entry) => entry[1] === "asc" || entry[1] === "desc"
6262
+ );
6263
+ if (entries.length !== 1) return null;
6264
+ const [key, direction] = entries[0];
6265
+ if (key === "createdAt" || key === "updatedAt") return null;
6266
+ return { key, direction };
6267
+ }
5894
6268
  compileSqlQuery(descriptor, options) {
5895
6269
  const joins = [];
5896
6270
  const where = ["n.schema_id = ?"];
@@ -5945,7 +6319,17 @@ WHERE schema_id = ${this.quoteSqlLiteral(schemaId)}
5945
6319
  options.spatialPlan.bounds.maxY
5946
6320
  );
5947
6321
  }
5948
- const orderBy = this.buildSqlOrderBy(descriptor.orderBy);
6322
+ if (options.propertySort) {
6323
+ const schemaId = this.quoteSqlLiteral(descriptor.schemaId);
6324
+ const propertyKey = this.quoteSqlLiteral(options.propertySort.key);
6325
+ joins.push(
6326
+ `LEFT JOIN node_property_scalars sortp
6327
+ ON sortp.node_id = n.id
6328
+ AND sortp.schema_id = ${schemaId}
6329
+ AND sortp.property_key = ${propertyKey}`
6330
+ );
6331
+ }
6332
+ const orderBy = options.propertySort ? this.buildPropertySortOrderBy(options.propertySort.direction) : this.buildSqlOrderBy(descriptor.orderBy);
5949
6333
  const useSqlPagination = options.canUseSqlPagination && descriptor.after === void 0 && (descriptor.limit !== void 0 || (descriptor.offset ?? 0) > 0);
5950
6334
  let sql = `
5951
6335
  SELECT n.id
@@ -5954,13 +6338,59 @@ WHERE schema_id = ${this.quoteSqlLiteral(schemaId)}
5954
6338
  WHERE ${where.join(" AND ")}
5955
6339
  ORDER BY ${orderBy}
5956
6340
  `;
6341
+ let fused;
5957
6342
  if (useSqlPagination) {
5958
6343
  sql += "\nLIMIT ? OFFSET ?";
5959
6344
  whereParams.push(descriptor.limit ?? -1, descriptor.offset ?? 0);
6345
+ const includesExactCount = descriptor.count === "exact";
6346
+ const countColumn = includesExactCount ? ",\n COUNT(*) OVER () AS total_count" : "";
6347
+ const countSelect = includesExactCount ? "c.total_count," : "";
6348
+ const candidatesCte = `
6349
+ WITH candidates AS (
6350
+ SELECT
6351
+ n.id, n.schema_id, n.created_at, n.updated_at, n.created_by, n.deleted_at,
6352
+ ROW_NUMBER() OVER (ORDER BY ${orderBy}) AS ordinal${countColumn}
6353
+ FROM nodes n
6354
+ ${joins.join("\n")}
6355
+ WHERE ${where.join(" AND ")}
6356
+ ORDER BY ${orderBy}
6357
+ LIMIT ? OFFSET ?
6358
+ )`;
6359
+ const fusedSql = this.aggregatedHydration ? `${candidatesCte}
6360
+ SELECT
6361
+ c.id, c.schema_id, c.created_at, c.updated_at, c.created_by, c.deleted_at,
6362
+ ${countSelect}
6363
+ c.ordinal,
6364
+ json_group_object(p.property_key, json(CAST(p.value AS TEXT)))
6365
+ FILTER (WHERE p.property_key IS NOT NULL) AS props_json,
6366
+ json_group_object(
6367
+ p.property_key,
6368
+ json_object('l', p.lamport_time, 'b', p.updated_by, 'w', p.updated_at)
6369
+ ) FILTER (WHERE p.property_key IS NOT NULL) AS meta_json
6370
+ FROM candidates c
6371
+ LEFT JOIN node_properties p ON p.node_id = c.id
6372
+ GROUP BY c.id
6373
+ ORDER BY c.ordinal ASC
6374
+ ` : `${candidatesCte}
6375
+ SELECT
6376
+ c.id, c.schema_id, c.created_at, c.updated_at, c.created_by, c.deleted_at,
6377
+ ${countSelect}
6378
+ p.property_key, p.value, p.lamport_time, p.updated_by, p.updated_at AS prop_updated_at,
6379
+ c.ordinal
6380
+ FROM candidates c
6381
+ LEFT JOIN node_properties p ON p.node_id = c.id
6382
+ ORDER BY c.ordinal ASC, p.property_key ASC
6383
+ `;
6384
+ fused = {
6385
+ sql: fusedSql,
6386
+ params: [...whereParams],
6387
+ includesExactCount
6388
+ };
5960
6389
  }
5961
6390
  return {
5962
6391
  sql,
5963
6392
  params: whereParams,
6393
+ fused,
5964
6394
  postFilterDescriptor: useSqlPagination ? withoutNodeQueryPagination(descriptor) : descriptor,
5965
6395
  postFilterReason: this.getCompiledPostFilterReason({
5966
6396
  useSqlPagination,
@@ -6009,6 +6439,18 @@ WHERE schema_id = ${this.quoteSqlLiteral(schemaId)}
6009
6439
  return;
6010
6440
  }
6011
6441
  }
6442
+ /**
6443
+ * ORDER BY for a pushed-down custom-property sort (0264). Mirrors the JS
6444
+ * comparator in `applyNodeQueryDescriptor`: missing properties sort last
6445
+ * ascending / first descending; typed value columns carry the order (for a
6446
+ * homogeneous property exactly one is non-NULL). `n.id` breaks ties so the
6447
+ * page boundary is a total order.
6448
+ */
6449
+ buildPropertySortOrderBy(direction) {
6450
+ const dir = direction.toUpperCase();
6451
+ const nullsDir = direction === "asc" ? "ASC" : "DESC";
6452
+ return `(sortp.node_id IS NULL) ${nullsDir}, sortp.value_number ${dir}, sortp.value_boolean ${dir}, sortp.value_text ${dir}, n.id ASC`;
6453
+ }
6012
6454
  buildSqlOrderBy(orderBy) {
6013
6455
  const entries = Object.entries(orderBy ?? {}).filter(
6014
6456
  (entry) => entry[1] === "asc" || entry[1] === "desc"