@xnetjs/data 0.0.3 → 0.1.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 (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-J4IVJA5M.js → chunk-FVEACSKE.js} +2 -2
  24. package/dist/{chunk-XWA3CJQ6.js → chunk-GU6THOAB.js} +2 -2
  25. package/dist/{chunk-XTUGZSYQ.js → chunk-H4GA4BBK.js} +2 -2
  26. package/dist/{chunk-GR3NDMBY.js → chunk-IJ44LR7N.js} +2 -2
  27. package/dist/{chunk-E6S557NC.js → chunk-J4ANIXIV.js} +2 -2
  28. package/dist/{chunk-EIQX7INF.js → chunk-JDFNMFOZ.js} +2 -2
  29. package/dist/{chunk-PSUJEKVV.js → chunk-K7DOZWWT.js} +244 -155
  30. package/dist/{chunk-UDUPQKRB.js → chunk-KQAT4XBL.js} +2 -2
  31. package/dist/chunk-KQUALW4O.js +183 -0
  32. package/dist/{chunk-KPVBKKZS.js → chunk-LYSWLCOI.js} +2 -2
  33. package/dist/{chunk-YXZGYLKV.js → chunk-MBTUO3ZL.js} +2 -2
  34. package/dist/{chunk-7GMJOAPF.js → chunk-NI4FHG2K.js} +2 -2
  35. package/dist/{chunk-6OPZJ7MG.js → chunk-OCMSAKWV.js} +2 -2
  36. package/dist/{chunk-IP4BEVVD.js → chunk-OGJCRNGE.js} +2 -2
  37. package/dist/{chunk-6CN53PON.js → chunk-OPPHF3TF.js} +3 -3
  38. package/dist/{chunk-77UDLVQY.js → chunk-OSAWNZVM.js} +1 -1
  39. package/dist/{chunk-GWYVY2K4.js → chunk-PNESGUH5.js} +2 -2
  40. package/dist/{chunk-RSSZXCLL.js → chunk-Q3IEGH4B.js} +2 -2
  41. package/dist/{chunk-2DSDH2ZH.js → chunk-QJW5LDP4.js} +2 -2
  42. package/dist/{chunk-JESSJ5CZ.js → chunk-RL64OJJ5.js} +13 -3
  43. package/dist/{chunk-S4B5OPZU.js → chunk-S5RP5RKY.js} +1 -0
  44. package/dist/{chunk-4UQTURCD.js → chunk-S6U6TCMN.js} +2 -2
  45. package/dist/{chunk-C5IRSEEZ.js → chunk-SVNGSZZA.js} +2 -2
  46. package/dist/{chunk-ZICIXDEE.js → chunk-T5AZAOG5.js} +1 -1
  47. package/dist/{chunk-IJ675GJR.js → chunk-TCTZW4A6.js} +2 -2
  48. package/dist/{chunk-54Y7QRZN.js → chunk-U64CW73O.js} +2 -2
  49. package/dist/{chunk-QVZXXJSP.js → chunk-UDWKWTAX.js} +485 -81
  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 +131 -4
  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;
@@ -3374,6 +3415,13 @@ var DEFAULT_QUERY_VERIFICATION = {
3374
3415
  var QUERY_TELEMETRY_FLUSH_THRESHOLD = 50;
3375
3416
  var SQLITE_BIND_PARAMETER_BATCH_SIZE = 900;
3376
3417
  var SQLITE_HYDRATE_NODE_BATCH_SIZE = Math.floor(SQLITE_BIND_PARAMETER_BATCH_SIZE / 2);
3418
+ var SQL_HYDRATE_ARITY_BUCKETS = [1, 10, 50, 150, SQLITE_HYDRATE_NODE_BATCH_SIZE];
3419
+ var SQL_IN_ARITY_BUCKETS = [10, 50, 300, SQLITE_BIND_PARAMETER_BATCH_SIZE];
3420
+ function padToArityBucket(items, buckets) {
3421
+ const size = buckets.find((bucket) => bucket >= items.length) ?? items.length;
3422
+ if (size === items.length) return items;
3423
+ return [...items, ...Array(size - items.length).fill(null)];
3424
+ }
3377
3425
  function getMaterializedQueryRefreshReason(input) {
3378
3426
  if (!input.cached) {
3379
3427
  return "missing";
@@ -3384,6 +3432,9 @@ function getMaterializedQueryRefreshReason(input) {
3384
3432
  if (input.cached.descriptor_hash !== input.descriptorHash) {
3385
3433
  return "descriptor-changed";
3386
3434
  }
3435
+ if ((input.cached.auth_fingerprint ?? null) !== input.authFingerprint) {
3436
+ return "authz-changed";
3437
+ }
3387
3438
  if (input.cached.invalidated_at !== null) {
3388
3439
  return "invalidated";
3389
3440
  }
@@ -3407,12 +3458,18 @@ var SQLiteNodeStorageAdapter = class {
3407
3458
  ...options.queryVerification
3408
3459
  };
3409
3460
  this.queryDiagnostics = options.queryDiagnostics ?? false;
3461
+ this.aggregatedHydration = options.aggregatedHydration ?? true;
3462
+ this.scheduleMaintenance = options.scheduleMaintenance;
3410
3463
  }
3411
3464
  // Prepared statement cache for hot paths
3412
3465
  stmtCache = /* @__PURE__ */ new Map();
3413
3466
  adaptiveIndexing;
3414
3467
  queryVerification;
3415
3468
  queryDiagnostics;
3469
+ /** Hydrate one row per node via json_group_object (0264 Wave 2 flag). */
3470
+ aggregatedHydration;
3471
+ /** Idle scheduler for maintenance work (adaptive index creation, 0264). */
3472
+ scheduleMaintenance;
3416
3473
  pendingQueryTelemetry = /* @__PURE__ */ new Map();
3417
3474
  pendingQueryTelemetryHits = 0;
3418
3475
  adaptiveIndexBudgetColumnsReady = false;
@@ -3420,6 +3477,15 @@ var SQLiteNodeStorageAdapter = class {
3420
3477
  spatialTablesState = "unknown";
3421
3478
  fullTextSearchTablesState = "unknown";
3422
3479
  writeQueue = Promise.resolve();
3480
+ /**
3481
+ * Read-authorization filter applied before persisting a materialized view's
3482
+ * id list (exploration 0226). Wired by `NodeStore` when an auth evaluator is
3483
+ * present; `undefined` means materialized views are computed unauthorized
3484
+ * (the trusted single-user case).
3485
+ */
3486
+ nodeReadAuthorizer;
3487
+ /** One-time guard: ensure the `auth_fingerprint` column exists on upgraded DBs. */
3488
+ materializationColumnsReady = false;
3423
3489
  getSQLiteAdapter() {
3424
3490
  return this.db;
3425
3491
  }
@@ -3574,6 +3640,66 @@ var SQLiteNodeStorageAdapter = class {
3574
3640
  );
3575
3641
  return rows.map((row) => this.deserializeChange(row));
3576
3642
  }
3643
+ /**
3644
+ * Compact the local change log (exploration 0254 / F3): delete only
3645
+ * *superseded* history so the OPFS file shrinks (fast cold open) and the
3646
+ * outbound-resync slice shrinks (cheap first sync), without affecting reads,
3647
+ * outbound sync, hash-chain chaining, or convergence with peers that never
3648
+ * compacted.
3649
+ *
3650
+ * A row is deleted iff ALL of:
3651
+ * - `lamport_time < wsafe` — below the confirmed-durable floor, so the hub
3652
+ * holds it and it is not part of the unconfirmed outbound tail (K1);
3653
+ * - it is NOT a node's hash-chain tip — we keep every row at a node's
3654
+ * `MAX(lamport_time)` (the whole tie group), so `getLastChange()` still
3655
+ * returns a real tip and the next write's `parentHash`/hash/signature are
3656
+ * byte-identical to an uncompacted peer's (K2);
3657
+ * - it backs NO currently-winning LWW value — its `(node_id, lamport_time,
3658
+ * author)` is not the provenance of any live `node_properties` row (K3).
3659
+ *
3660
+ * K3 is the load-bearing safety net: because every live projection value keeps
3661
+ * its backing row, the retained log still materialises to — and can re-push —
3662
+ * the exact current state, so a stranded/rolled-back peer never loses live
3663
+ * data. Reads are unaffected (they read the materialized `nodes`/
3664
+ * `node_properties`, never the log). Only rows are deleted, never rewritten
3665
+ * (the hash + signature are immutable and unforgeable). Runs chunked inside
3666
+ * the write lane, yields between chunks, and never throws.
3667
+ */
3668
+ async pruneSupersededChanges(wsafe, options = {}) {
3669
+ const chunk = Math.max(1, Math.floor(options.chunk ?? 5e3));
3670
+ const maxRows = Math.max(0, Math.floor(options.maxRows ?? 25e4));
3671
+ if (!Number.isFinite(wsafe) || wsafe <= 0 || maxRows === 0) return { deleted: 0 };
3672
+ let deleted = 0;
3673
+ for (; ; ) {
3674
+ const remaining = maxRows - deleted;
3675
+ const limit = Math.min(chunk, remaining);
3676
+ if (limit <= 0) break;
3677
+ const affected = await this.enqueueWrite(async () => {
3678
+ const result = await this.db.run(
3679
+ `DELETE FROM changes WHERE hash IN (
3680
+ SELECT c.hash FROM changes c
3681
+ WHERE c.lamport_time < ?
3682
+ AND c.lamport_time < (
3683
+ SELECT MAX(t.lamport_time) FROM changes t WHERE t.node_id = c.node_id
3684
+ )
3685
+ AND NOT EXISTS (
3686
+ SELECT 1 FROM node_properties p
3687
+ WHERE p.node_id = c.node_id
3688
+ AND p.lamport_time = c.lamport_time
3689
+ AND p.updated_by = c.author
3690
+ )
3691
+ LIMIT ?
3692
+ )`,
3693
+ [wsafe, limit]
3694
+ );
3695
+ return result.changes ?? 0;
3696
+ });
3697
+ deleted += affected;
3698
+ if (affected < limit) break;
3699
+ await new Promise((resolve) => setTimeout(resolve, 0));
3700
+ }
3701
+ return { deleted };
3702
+ }
3577
3703
  async getChangeByHash(hash) {
3578
3704
  const row = await this.db.queryOne(
3579
3705
  `SELECT hash, node_id, payload, lamport_time,
@@ -3599,7 +3725,8 @@ var SQLiteNodeStorageAdapter = class {
3599
3725
  const changes = /* @__PURE__ */ new Map();
3600
3726
  if (uniqueIds.length === 0) return changes;
3601
3727
  for (const batch of chunkItems(uniqueIds, SQLITE_BIND_PARAMETER_BATCH_SIZE)) {
3602
- const placeholders = batch.map(() => "?").join(", ");
3728
+ const padded = padToArityBucket(batch, SQL_IN_ARITY_BUCKETS);
3729
+ const placeholders = padded.map(() => "?").join(", ");
3603
3730
  const rows = await this.db.query(
3604
3731
  `SELECT hash, node_id, payload, lamport_time,
3605
3732
  lamport_peer as lamport_author, wall_time,
@@ -3613,7 +3740,7 @@ var SQLiteNodeStorageAdapter = class {
3613
3740
  ORDER BY c2.lamport_time DESC
3614
3741
  LIMIT 1
3615
3742
  )`,
3616
- batch
3743
+ padded
3617
3744
  );
3618
3745
  rows.forEach((row) => {
3619
3746
  changes.set(row.node_id, this.deserializeChange(row));
@@ -3628,57 +3755,24 @@ var SQLiteNodeStorageAdapter = class {
3628
3755
  }
3629
3756
  // ─── Materialized State Operations ────────────────────────────────────────
3630
3757
  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
- };
3758
+ const nodes = await this.hydrateNodesByIds([id]);
3759
+ return nodes[0] ?? null;
3663
3760
  }
3664
3761
  async getNodes(ids) {
3665
3762
  const uniqueIds = Array.from(new Set(ids));
3666
3763
  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;
3764
+ return this.hydrateNodesByIds(uniqueIds);
3672
3765
  }
3673
3766
  async getExistingNodeIds(ids) {
3674
3767
  const uniqueIds = Array.from(new Set(ids));
3675
3768
  if (uniqueIds.length === 0) return [];
3676
3769
  const existingIds = /* @__PURE__ */ new Set();
3677
3770
  for (const batch of chunkItems(uniqueIds, SQLITE_BIND_PARAMETER_BATCH_SIZE)) {
3678
- const placeholders = batch.map(() => "?").join(", ");
3771
+ const padded = padToArityBucket(batch, SQL_IN_ARITY_BUCKETS);
3772
+ const placeholders = padded.map(() => "?").join(", ");
3679
3773
  const rows = await this.db.query(
3680
3774
  `SELECT id FROM nodes WHERE id IN (${placeholders})`,
3681
- batch
3775
+ padded
3682
3776
  );
3683
3777
  rows.forEach((row) => existingIds.add(row.id));
3684
3778
  }
@@ -3882,15 +3976,27 @@ var SQLiteNodeStorageAdapter = class {
3882
3976
  this.debugQueryPlan(descriptor, result2);
3883
3977
  return result2;
3884
3978
  }
3885
- const [queryDiagnostics, idQuery] = await Promise.all([
3979
+ const [queryDiagnostics, mainQuery] = await Promise.all([
3886
3980
  this.isQueryDiagnosticsEnabled() ? this.collectCompiledQueryDiagnostics(compiled) : Promise.resolve({}),
3887
- timeQuery(this.db, compiled.sql, compiled.params)
3981
+ compiled.fused ? timeQuery(this.db, compiled.fused.sql, compiled.fused.params) : timeQuery(this.db, compiled.sql, compiled.params)
3888
3982
  ]);
3889
- const idRows = idQuery.result;
3890
- const ids = idRows.map((row) => row.id);
3891
- const candidates = await this.hydrateNodesByIds(ids);
3983
+ let candidates;
3984
+ let candidateCount;
3985
+ let fusedExactCount;
3986
+ if (compiled.fused) {
3987
+ const rows = mainQuery.result;
3988
+ candidates = this.aggregatedHydration ? this.hydrateAggregatedRows(rows) : this.hydrateJoinedRows(rows);
3989
+ candidateCount = candidates.length;
3990
+ if (compiled.fused.includesExactCount && rows.length > 0) {
3991
+ fusedExactCount = Number(rows[0].total_count ?? 0);
3992
+ }
3993
+ } else {
3994
+ const ids = mainQuery.result.map((row) => row.id);
3995
+ candidates = await this.hydrateNodesByIds(ids);
3996
+ candidateCount = ids.length;
3997
+ }
3892
3998
  const nodes = applyNodeQueryDescriptor(candidates, compiled.postFilterDescriptor);
3893
- const totalCount = compiled.sqlPagination ? descriptor.count === "exact" ? await this.countCompiledNodeQuery(descriptor, spatialPlan, fullTextSearchPlan) : void 0 : applyNodeQueryDescriptor(
3999
+ 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
4000
  candidates,
3895
4001
  withoutNodeQueryPagination(compiled.postFilterDescriptor)
3896
4002
  ).length;
@@ -3903,14 +4009,14 @@ var SQLiteNodeStorageAdapter = class {
3903
4009
  totalCount,
3904
4010
  plan: {
3905
4011
  strategy: "storage-query",
3906
- candidateNodeCount: ids.length,
4012
+ candidateNodeCount: candidateCount,
3907
4013
  hydratedNodeCount: candidates.length,
3908
4014
  returnedNodeCount: nodes.length,
3909
4015
  durationMs: Date.now() - start,
3910
- sql: compiled.sql,
3911
- params: compiled.params,
4016
+ sql: compiled.fused ? compiled.fused.sql : compiled.sql,
4017
+ params: compiled.fused ? compiled.fused.params : compiled.params,
3912
4018
  postFilterReason: compiled.postFilterReason,
3913
- candidateQueryDurationMs: idQuery.durationMs,
4019
+ candidateQueryDurationMs: mainQuery.durationMs,
3914
4020
  ...candidateAccelerators.length > 0 ? {
3915
4021
  candidateAccelerators
3916
4022
  } : {},
@@ -3959,6 +4065,21 @@ var SQLiteNodeStorageAdapter = class {
3959
4065
  );
3960
4066
  return row ? parseInt(row.value, 10) || 0 : 0;
3961
4067
  }
4068
+ /**
4069
+ * The lowest confirmed-durable sync cursor across every node-change room
4070
+ * (`MIN` over the persisted `nodeSync:hwm:*` marks), or `null` when no room has
4071
+ * ever confirmed. This is the safe compaction floor (exploration 0254): the
4072
+ * hub durably holds every change at or below it. Returns `null` — never 0 — so
4073
+ * a workspace that has never synced is left untouched.
4074
+ */
4075
+ async getMinConfirmedSyncCursor() {
4076
+ const row = await this.db.queryOne(
4077
+ `SELECT MIN(CAST(value AS INTEGER)) AS min_value
4078
+ FROM sync_state WHERE key LIKE 'nodeSync:hwm:%'`
4079
+ );
4080
+ const value = row?.min_value;
4081
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
4082
+ }
3962
4083
  async setSyncCursor(room, lamport) {
3963
4084
  await this.enqueueWrite(async () => {
3964
4085
  await this.db.run(
@@ -3969,6 +4090,27 @@ var SQLiteNodeStorageAdapter = class {
3969
4090
  );
3970
4091
  });
3971
4092
  }
4093
+ // ─── App State (generic K/V, FK-free) ─────────────────────────────────────
4094
+ /** Namespace app-state keys so they can't collide with sync cursors. */
4095
+ appStateKey(key) {
4096
+ return `app:${key}`;
4097
+ }
4098
+ async getAppState(key) {
4099
+ const row = await this.db.queryOne(
4100
+ `SELECT value FROM sync_state WHERE key = ?`,
4101
+ [this.appStateKey(key)]
4102
+ );
4103
+ return row ? row.value : null;
4104
+ }
4105
+ async setAppState(key, value) {
4106
+ await this.enqueueWrite(async () => {
4107
+ await this.db.run(
4108
+ `INSERT INTO sync_state (key, value) VALUES (?, ?)
4109
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
4110
+ [this.appStateKey(key), value]
4111
+ );
4112
+ });
4113
+ }
3972
4114
  // ─── Document Content (Yjs) ───────────────────────────────────────────────
3973
4115
  async getDocumentContent(nodeId) {
3974
4116
  const row = await this.db.queryOne(
@@ -4697,19 +4839,10 @@ var SQLiteNodeStorageAdapter = class {
4697
4839
  }
4698
4840
  return Array.from(nodeMap.values());
4699
4841
  }
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]);
4842
+ buildHydrateChunkQuery(ids) {
4843
+ const padded = padToArityBucket(ids, SQL_HYDRATE_ARITY_BUCKETS);
4844
+ const values = padded.map(() => "(?, ?)").join(", ");
4845
+ const params = padded.flatMap((id, ordinal) => [id, ordinal]);
4713
4846
  const sql = `
4714
4847
  WITH wanted(id, ordinal) AS (
4715
4848
  VALUES ${values}
@@ -4732,14 +4865,123 @@ var SQLiteNodeStorageAdapter = class {
4732
4865
  LEFT JOIN node_properties p ON p.node_id = n.id
4733
4866
  ORDER BY wanted.ordinal ASC, p.property_key ASC
4734
4867
  `;
4735
- const rows = await this.db.query(sql, params);
4736
- return this.hydrateJoinedRows(rows);
4868
+ return { sql, params };
4737
4869
  }
4870
+ async hydrateNodesByIds(ids) {
4871
+ if (ids.length === 0) {
4872
+ return [];
4873
+ }
4874
+ const aggregated = this.aggregatedHydration;
4875
+ const chunks = ids.length > SQLITE_HYDRATE_NODE_BATCH_SIZE ? chunkItems(ids, SQLITE_HYDRATE_NODE_BATCH_SIZE) : [ids];
4876
+ const reads = chunks.map(
4877
+ (chunk) => aggregated ? this.buildAggregatedHydrateChunkQuery(chunk) : this.buildHydrateChunkQuery(chunk)
4878
+ );
4879
+ const parse = (rows) => aggregated ? this.hydrateAggregatedRows(rows) : this.hydrateJoinedRows(rows);
4880
+ if (reads.length > 1 && typeof this.db.queryBatch === "function") {
4881
+ const results = await this.db.queryBatch(reads);
4882
+ const nodes2 = [];
4883
+ for (const rows of results) {
4884
+ nodes2.push(...parse(rows));
4885
+ }
4886
+ return nodes2;
4887
+ }
4888
+ const nodes = [];
4889
+ for (const read of reads) {
4890
+ const rows = await this.db.query(read.sql, read.params);
4891
+ nodes.push(...parse(rows));
4892
+ }
4893
+ return nodes;
4894
+ }
4895
+ /**
4896
+ * Aggregated hydrate (exploration 0264, Wave 2): collapse the EAV rows to
4897
+ * ONE row per node inside SQL via `json_group_object`, so the boundary
4898
+ * ships N rows instead of N × properties. `value` is stored as JSON text
4899
+ * in a BLOB — `json(CAST(… AS TEXT))` re-emits it as real JSON inside the
4900
+ * aggregate (without the cast/wrap it would double-encode as a string).
4901
+ */
4902
+ buildAggregatedHydrateChunkQuery(ids) {
4903
+ const padded = padToArityBucket(ids, SQL_HYDRATE_ARITY_BUCKETS);
4904
+ const values = padded.map(() => "(?, ?)").join(", ");
4905
+ const params = padded.flatMap((id, ordinal) => [id, ordinal]);
4906
+ const sql = `
4907
+ WITH wanted(id, ordinal) AS (
4908
+ VALUES ${values}
4909
+ )
4910
+ SELECT
4911
+ n.id,
4912
+ n.schema_id,
4913
+ n.created_at,
4914
+ n.updated_at,
4915
+ n.created_by,
4916
+ n.deleted_at,
4917
+ wanted.ordinal,
4918
+ json_group_object(p.property_key, json(CAST(p.value AS TEXT)))
4919
+ FILTER (WHERE p.property_key IS NOT NULL) AS props_json,
4920
+ json_group_object(
4921
+ p.property_key,
4922
+ json_object('l', p.lamport_time, 'b', p.updated_by, 'w', p.updated_at)
4923
+ ) FILTER (WHERE p.property_key IS NOT NULL) AS meta_json
4924
+ FROM wanted
4925
+ JOIN nodes n ON n.id = wanted.id
4926
+ LEFT JOIN node_properties p ON p.node_id = n.id
4927
+ GROUP BY n.id
4928
+ ORDER BY wanted.ordinal ASC
4929
+ `;
4930
+ return { sql, params };
4931
+ }
4932
+ /** Parse one-row-per-node aggregated hydrate results into NodeStates. */
4933
+ hydrateAggregatedRows(rows) {
4934
+ const nodes = [];
4935
+ for (const row of rows) {
4936
+ const properties = row.props_json ? JSON.parse(row.props_json) : {};
4937
+ const meta = row.meta_json ? JSON.parse(row.meta_json) : {};
4938
+ const timestamps = {};
4939
+ let updatedBy = row.created_by;
4940
+ for (const [key, entry] of Object.entries(meta)) {
4941
+ timestamps[key] = {
4942
+ lamport: entry.l ?? 0,
4943
+ author: entry.b ?? "",
4944
+ wallTime: entry.w ?? 0
4945
+ };
4946
+ if ((entry.w ?? 0) >= row.updated_at) {
4947
+ updatedBy = entry.b ?? row.created_by;
4948
+ }
4949
+ }
4950
+ nodes.push({
4951
+ id: row.id,
4952
+ schemaId: row.schema_id,
4953
+ properties,
4954
+ timestamps,
4955
+ deleted: row.deleted_at !== null,
4956
+ deletedAt: row.deleted_at ? { lamport: 0, author: "", wallTime: row.deleted_at } : void 0,
4957
+ createdAt: row.created_at,
4958
+ createdBy: row.created_by,
4959
+ updatedAt: row.updated_at,
4960
+ updatedBy
4961
+ });
4962
+ }
4963
+ return nodes;
4964
+ }
4965
+ /**
4966
+ * Read (or refresh) a materialized view.
4967
+ *
4968
+ * A materialized view is a purely LOCAL, DERIVED cache: an ordered list of
4969
+ * node ids (membership + order) plus bookkeeping. It is NEVER part of the
4970
+ * change log and is NEVER synced — peers each derive their own. It therefore
4971
+ * carries no authority and can be rebuilt or dropped at any time (a wiped
4972
+ * cache only costs a recompute, never data). Content is re-hydrated from the
4973
+ * live `nodes`/`node_properties` tables on every read, so the cache exposes
4974
+ * nothing the underlying tables don't already, and a write to the schema
4975
+ * (`invalidated_at`) or a grant change (`auth_fingerprint`) forces a
4976
+ * re-materialization. See exploration 0226.
4977
+ */
4738
4978
  async queryMaterializedView(descriptor, start) {
4739
4979
  const materializedView = descriptor.materializedView;
4740
4980
  if (!materializedView) {
4741
4981
  throw new Error("Materialized view query requires descriptor.materializedView");
4742
4982
  }
4983
+ await this.ensureMaterializationColumns();
4984
+ const authFingerprint = descriptor.authFingerprint ?? null;
4743
4985
  const baseDescriptor = withoutNodeQueryMaterializedView(withoutNodeQueryPagination(descriptor));
4744
4986
  const descriptorJson = this.stringifyStable(baseDescriptor);
4745
4987
  const descriptorHash = this.hashScalarValue(descriptorJson);
@@ -4748,10 +4990,11 @@ var SQLiteNodeStorageAdapter = class {
4748
4990
  const refreshReason = getMaterializedQueryRefreshReason({
4749
4991
  cached,
4750
4992
  descriptorHash,
4993
+ authFingerprint,
4751
4994
  cacheExpired,
4752
4995
  forceRefresh: materializedView.forceRefresh ?? false
4753
4996
  });
4754
- const canUseCache = cached !== null && cached.descriptor_hash === descriptorHash && cached.invalidated_at === null && !cacheExpired && !materializedView.forceRefresh;
4997
+ const canUseCache = cached !== null && cached.descriptor_hash === descriptorHash && (cached.auth_fingerprint ?? null) === authFingerprint && cached.invalidated_at === null && !cacheExpired && !materializedView.forceRefresh;
4755
4998
  const readPlan = canUseCache ? {
4756
4999
  viewId: materializedView.viewId,
4757
5000
  descriptorHash,
@@ -4764,6 +5007,7 @@ var SQLiteNodeStorageAdapter = class {
4764
5007
  descriptor: baseDescriptor,
4765
5008
  descriptorHash,
4766
5009
  descriptorJson,
5010
+ authFingerprint,
4767
5011
  refreshReason: refreshReason ?? "missing",
4768
5012
  invalidatedAt: cached?.invalidated_at ?? null
4769
5013
  });
@@ -4780,14 +5024,63 @@ var SQLiteNodeStorageAdapter = class {
4780
5024
  descriptor_json,
4781
5025
  generated_at,
4782
5026
  invalidated_at,
4783
- row_count
5027
+ row_count,
5028
+ auth_fingerprint
4784
5029
  FROM node_query_materializations
4785
5030
  WHERE view_id = ?`,
4786
5031
  [viewId]
4787
5032
  );
4788
5033
  }
5034
+ /**
5035
+ * Ensure the `auth_fingerprint` column exists. Fresh databases get it from
5036
+ * the DDL; databases upgraded in place (the runtime applies the full DDL with
5037
+ * `CREATE TABLE IF NOT EXISTS`, which cannot add a column) need this guard.
5038
+ * Idempotent and memoized (exploration 0226).
5039
+ */
5040
+ async ensureMaterializationColumns() {
5041
+ if (this.materializationColumnsReady) return;
5042
+ try {
5043
+ const columns = await this.db.query(
5044
+ `PRAGMA table_info(node_query_materializations)`
5045
+ );
5046
+ const hasColumn = columns.some((column) => column.name === "auth_fingerprint");
5047
+ if (!hasColumn) {
5048
+ await this.db.run(
5049
+ "ALTER TABLE node_query_materializations ADD COLUMN auth_fingerprint TEXT"
5050
+ );
5051
+ }
5052
+ } catch {
5053
+ }
5054
+ this.materializationColumnsReady = true;
5055
+ }
5056
+ setNodeReadAuthorizer(authorizer) {
5057
+ this.nodeReadAuthorizer = authorizer;
5058
+ }
5059
+ /**
5060
+ * Reload-stable version of the authorization-relevant control-plane state:
5061
+ * grants (`Grant` schema) and per-subject `/sys/authz/` resources. Ordinary
5062
+ * data writes do not touch these, so a database's materialized views survive
5063
+ * reloads and edits, but any grant change shifts `count`/`maxUpdatedAt` and
5064
+ * forces an `'authz-changed'` refresh (exploration 0226).
5065
+ */
5066
+ async getAuthorizationStateVersion() {
5067
+ const grantIri = SYSTEM_SCHEMA_BASE_IRIS.Grant;
5068
+ const row = await this.db.queryOne(
5069
+ `SELECT COUNT(*) AS count, COALESCE(MAX(updated_at), 0) AS max_updated_at
5070
+ FROM nodes
5071
+ WHERE schema_id = ?
5072
+ OR schema_id LIKE ?
5073
+ OR id LIKE 'xnet://%/sys/authz/%'`,
5074
+ [grantIri, `${grantIri}@%`]
5075
+ );
5076
+ return {
5077
+ count: row?.count ?? 0,
5078
+ maxUpdatedAt: row?.max_updated_at ?? 0
5079
+ };
5080
+ }
4789
5081
  async refreshMaterializedView(input) {
4790
5082
  const refreshed = await this.queryNodes(input.descriptor);
5083
+ const authorizedNodes = this.nodeReadAuthorizer ? await this.nodeReadAuthorizer(refreshed.nodes) : refreshed.nodes;
4791
5084
  const generatedAt = Date.now();
4792
5085
  await this.enqueueWrite(async () => {
4793
5086
  await this.db.beginTransaction();
@@ -4804,26 +5097,29 @@ var SQLiteNodeStorageAdapter = class {
4804
5097
  descriptor_json,
4805
5098
  generated_at,
4806
5099
  invalidated_at,
4807
- row_count
5100
+ row_count,
5101
+ auth_fingerprint
4808
5102
  )
4809
- VALUES (?, ?, ?, ?, ?, NULL, ?)
5103
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?)
4810
5104
  ON CONFLICT(view_id) DO UPDATE SET
4811
5105
  descriptor_hash = excluded.descriptor_hash,
4812
5106
  schema_id = excluded.schema_id,
4813
5107
  descriptor_json = excluded.descriptor_json,
4814
5108
  generated_at = excluded.generated_at,
4815
5109
  invalidated_at = NULL,
4816
- row_count = excluded.row_count`,
5110
+ row_count = excluded.row_count,
5111
+ auth_fingerprint = excluded.auth_fingerprint`,
4817
5112
  [
4818
5113
  input.viewId,
4819
5114
  input.descriptorHash,
4820
5115
  input.descriptor.schemaId,
4821
5116
  input.descriptorJson,
4822
5117
  generatedAt,
4823
- refreshed.nodes.length
5118
+ authorizedNodes.length,
5119
+ input.authFingerprint
4824
5120
  ]
4825
5121
  );
4826
- for (const [ordinal, node] of refreshed.nodes.entries()) {
5122
+ for (const [ordinal, node] of authorizedNodes.entries()) {
4827
5123
  await this.db.run(
4828
5124
  `INSERT INTO node_query_materialized_ids (view_id, ordinal, node_id)
4829
5125
  VALUES (?, ?, ?)`,
@@ -4841,7 +5137,7 @@ var SQLiteNodeStorageAdapter = class {
4841
5137
  descriptorHash: input.descriptorHash,
4842
5138
  generatedAt,
4843
5139
  invalidatedAt: input.invalidatedAt,
4844
- rowCount: refreshed.nodes.length,
5140
+ rowCount: authorizedNodes.length,
4845
5141
  cacheHit: false,
4846
5142
  refreshReason: input.refreshReason
4847
5143
  };
@@ -5359,6 +5655,23 @@ CREATE INDEX IF NOT EXISTS idx_node_spatial_ids_schema
5359
5655
  if (!stats || !this.shouldCreateAdaptiveIndexes(stats)) {
5360
5656
  return { descriptorHash, adaptiveIndexNames: [] };
5361
5657
  }
5658
+ if (this.scheduleMaintenance) {
5659
+ const input = {
5660
+ descriptorHash,
5661
+ schemaId: descriptor.schemaId,
5662
+ hints: adaptiveIndexHints,
5663
+ now
5664
+ };
5665
+ this.scheduleMaintenance(
5666
+ () => this.ensureAdaptiveIndexes(input).then(
5667
+ () => void 0,
5668
+ (err) => {
5669
+ console.warn("[SQLiteNodeStorageAdapter] deferred adaptive index skipped:", err);
5670
+ }
5671
+ )
5672
+ );
5673
+ return { descriptorHash, adaptiveIndexNames: [] };
5674
+ }
5362
5675
  const adaptiveIndexNames = await this.ensureAdaptiveIndexes({
5363
5676
  descriptorHash,
5364
5677
  schemaId: descriptor.schemaId,
@@ -5484,6 +5797,9 @@ CREATE INDEX IF NOT EXISTS idx_node_spatial_ids_schema
5484
5797
  if (!this.queryVerification.enabled) {
5485
5798
  return { strategy: "skipped", reason: "disabled" };
5486
5799
  }
5800
+ if (this.nodeReadAuthorizer) {
5801
+ return { strategy: "skipped", reason: "authorized-materialization" };
5802
+ }
5487
5803
  const candidateScopeCount = descriptor.nodeId ? 1 : await this.countNodes({
5488
5804
  schemaId: descriptor.schemaId,
5489
5805
  includeDeleted: descriptor.includeDeleted
@@ -5880,17 +6196,37 @@ WHERE schema_id = ${this.quoteSqlLiteral(schemaId)}
5880
6196
  const hasPropertySort = Object.keys(descriptor.orderBy ?? {}).some(
5881
6197
  (key) => key !== "createdAt" && key !== "updatedAt"
5882
6198
  );
6199
+ const propertySort = this.resolvePropertySortPushdown(descriptor, hasPropertySort);
5883
6200
  const hasSqlCandidateBenefit = scalarWhere.length > 0 || spatialPlan !== null || fullTextSearchPlan !== null || !descriptor.spatial || this.hasOnlySystemOrdering(descriptor.orderBy);
5884
- if (!hasSqlCandidateBenefit) {
6201
+ if (!hasSqlCandidateBenefit && !propertySort) {
5885
6202
  return null;
5886
6203
  }
5887
6204
  return this.compileSqlQuery(descriptor, {
5888
6205
  whereEntries: scalarWhere,
5889
- canUseSqlPagination: !hasPropertySort && !descriptor.spatial && !descriptor.search,
6206
+ canUseSqlPagination: (!hasPropertySort || propertySort !== null) && !descriptor.spatial && !descriptor.search,
5890
6207
  spatialPlan,
5891
- fullTextSearchPlan
6208
+ fullTextSearchPlan,
6209
+ propertySort
5892
6210
  });
5893
6211
  }
6212
+ /**
6213
+ * A custom-property sort can push down when it is the descriptor's ONLY
6214
+ * order key and no cursor/spatial/search accelerator is in play. Gated on
6215
+ * the adaptive-indexing flag: the typed partial indexes on
6216
+ * `node_property_scalars` make the join+order cheap, and the flag keeps
6217
+ * the behavioural change opt-in while it soaks (exploration 0264).
6218
+ */
6219
+ resolvePropertySortPushdown(descriptor, hasPropertySort) {
6220
+ if (!hasPropertySort || !this.adaptiveIndexing.enabled) return null;
6221
+ if (descriptor.spatial || descriptor.search || descriptor.after !== void 0) return null;
6222
+ const entries = Object.entries(descriptor.orderBy ?? {}).filter(
6223
+ (entry) => entry[1] === "asc" || entry[1] === "desc"
6224
+ );
6225
+ if (entries.length !== 1) return null;
6226
+ const [key, direction] = entries[0];
6227
+ if (key === "createdAt" || key === "updatedAt") return null;
6228
+ return { key, direction };
6229
+ }
5894
6230
  compileSqlQuery(descriptor, options) {
5895
6231
  const joins = [];
5896
6232
  const where = ["n.schema_id = ?"];
@@ -5945,7 +6281,17 @@ WHERE schema_id = ${this.quoteSqlLiteral(schemaId)}
5945
6281
  options.spatialPlan.bounds.maxY
5946
6282
  );
5947
6283
  }
5948
- const orderBy = this.buildSqlOrderBy(descriptor.orderBy);
6284
+ if (options.propertySort) {
6285
+ const schemaId = this.quoteSqlLiteral(descriptor.schemaId);
6286
+ const propertyKey = this.quoteSqlLiteral(options.propertySort.key);
6287
+ joins.push(
6288
+ `LEFT JOIN node_property_scalars sortp
6289
+ ON sortp.node_id = n.id
6290
+ AND sortp.schema_id = ${schemaId}
6291
+ AND sortp.property_key = ${propertyKey}`
6292
+ );
6293
+ }
6294
+ const orderBy = options.propertySort ? this.buildPropertySortOrderBy(options.propertySort.direction) : this.buildSqlOrderBy(descriptor.orderBy);
5949
6295
  const useSqlPagination = options.canUseSqlPagination && descriptor.after === void 0 && (descriptor.limit !== void 0 || (descriptor.offset ?? 0) > 0);
5950
6296
  let sql = `
5951
6297
  SELECT n.id
@@ -5954,13 +6300,59 @@ WHERE schema_id = ${this.quoteSqlLiteral(schemaId)}
5954
6300
  WHERE ${where.join(" AND ")}
5955
6301
  ORDER BY ${orderBy}
5956
6302
  `;
6303
+ let fused;
5957
6304
  if (useSqlPagination) {
5958
6305
  sql += "\nLIMIT ? OFFSET ?";
5959
6306
  whereParams.push(descriptor.limit ?? -1, descriptor.offset ?? 0);
6307
+ const includesExactCount = descriptor.count === "exact";
6308
+ const countColumn = includesExactCount ? ",\n COUNT(*) OVER () AS total_count" : "";
6309
+ const countSelect = includesExactCount ? "c.total_count," : "";
6310
+ const candidatesCte = `
6311
+ WITH candidates AS (
6312
+ SELECT
6313
+ n.id, n.schema_id, n.created_at, n.updated_at, n.created_by, n.deleted_at,
6314
+ ROW_NUMBER() OVER (ORDER BY ${orderBy}) AS ordinal${countColumn}
6315
+ FROM nodes n
6316
+ ${joins.join("\n")}
6317
+ WHERE ${where.join(" AND ")}
6318
+ ORDER BY ${orderBy}
6319
+ LIMIT ? OFFSET ?
6320
+ )`;
6321
+ const fusedSql = this.aggregatedHydration ? `${candidatesCte}
6322
+ SELECT
6323
+ c.id, c.schema_id, c.created_at, c.updated_at, c.created_by, c.deleted_at,
6324
+ ${countSelect}
6325
+ c.ordinal,
6326
+ json_group_object(p.property_key, json(CAST(p.value AS TEXT)))
6327
+ FILTER (WHERE p.property_key IS NOT NULL) AS props_json,
6328
+ json_group_object(
6329
+ p.property_key,
6330
+ json_object('l', p.lamport_time, 'b', p.updated_by, 'w', p.updated_at)
6331
+ ) FILTER (WHERE p.property_key IS NOT NULL) AS meta_json
6332
+ FROM candidates c
6333
+ LEFT JOIN node_properties p ON p.node_id = c.id
6334
+ GROUP BY c.id
6335
+ ORDER BY c.ordinal ASC
6336
+ ` : `${candidatesCte}
6337
+ SELECT
6338
+ c.id, c.schema_id, c.created_at, c.updated_at, c.created_by, c.deleted_at,
6339
+ ${countSelect}
6340
+ p.property_key, p.value, p.lamport_time, p.updated_by, p.updated_at AS prop_updated_at,
6341
+ c.ordinal
6342
+ FROM candidates c
6343
+ LEFT JOIN node_properties p ON p.node_id = c.id
6344
+ ORDER BY c.ordinal ASC, p.property_key ASC
6345
+ `;
6346
+ fused = {
6347
+ sql: fusedSql,
6348
+ params: [...whereParams],
6349
+ includesExactCount
6350
+ };
5960
6351
  }
5961
6352
  return {
5962
6353
  sql,
5963
6354
  params: whereParams,
6355
+ fused,
5964
6356
  postFilterDescriptor: useSqlPagination ? withoutNodeQueryPagination(descriptor) : descriptor,
5965
6357
  postFilterReason: this.getCompiledPostFilterReason({
5966
6358
  useSqlPagination,
@@ -6009,6 +6401,18 @@ WHERE schema_id = ${this.quoteSqlLiteral(schemaId)}
6009
6401
  return;
6010
6402
  }
6011
6403
  }
6404
+ /**
6405
+ * ORDER BY for a pushed-down custom-property sort (0264). Mirrors the JS
6406
+ * comparator in `applyNodeQueryDescriptor`: missing properties sort last
6407
+ * ascending / first descending; typed value columns carry the order (for a
6408
+ * homogeneous property exactly one is non-NULL). `n.id` breaks ties so the
6409
+ * page boundary is a total order.
6410
+ */
6411
+ buildPropertySortOrderBy(direction) {
6412
+ const dir = direction.toUpperCase();
6413
+ const nullsDir = direction === "asc" ? "ASC" : "DESC";
6414
+ return `(sortp.node_id IS NULL) ${nullsDir}, sortp.value_number ${dir}, sortp.value_boolean ${dir}, sortp.value_text ${dir}, n.id ASC`;
6415
+ }
6012
6416
  buildSqlOrderBy(orderBy) {
6013
6417
  const entries = Object.entries(orderBy ?? {}).filter(
6014
6418
  (entry) => entry[1] === "asc" || entry[1] === "desc"