@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.
- package/dist/account-MWNCH4GJ.js +12 -0
- package/dist/account-ledger-R2GAFJL6.js +38 -0
- package/dist/auth/index.d.ts +11 -4
- package/dist/auth/index.js +1 -1
- package/dist/budget-NN7P35XP.js +12 -0
- package/dist/canvas-6WYBNH53.js +10 -0
- package/dist/channel-AT3LQF4A.js +12 -0
- package/dist/chat-message-DSVO72B3.js +10 -0
- package/dist/{chunk-J6EDPPH4.js → chunk-2ZNJWIQG.js} +2 -2
- package/dist/{chunk-5FHJE7RC.js → chunk-3J3HILXO.js} +2 -2
- package/dist/{chunk-EZA5UT4Y.js → chunk-3RUDVTMH.js} +6 -6
- package/dist/{chunk-YEQ6LLUY.js → chunk-4EYKGTSQ.js} +2 -2
- package/dist/{chunk-L66WXPTW.js → chunk-53F4PRNC.js} +2 -2
- package/dist/{chunk-LGD72FVD.js → chunk-5EXHNXPL.js} +2 -2
- package/dist/{chunk-4U4KBCDN.js → chunk-5HC5NO57.js} +2 -2
- package/dist/{chunk-YWN27MLD.js → chunk-6KLGA6IR.js} +2 -2
- package/dist/{chunk-37267RSF.js → chunk-6OMG4I7M.js} +2 -2
- package/dist/{chunk-DOZJXPQ6.js → chunk-BQLQSWKZ.js} +2 -2
- package/dist/{chunk-V6HZ3LF3.js → chunk-BY5O6LNC.js} +1 -1
- package/dist/{chunk-64AM6JPM.js → chunk-DCTRX6II.js} +1 -1
- package/dist/{chunk-SPFKSTPT.js → chunk-DWEXKD6E.js} +2 -2
- package/dist/{chunk-O64EK5FK.js → chunk-EBNMV2VO.js} +11 -2
- package/dist/{chunk-QVZXXJSP.js → chunk-EI5CY2FZ.js} +524 -82
- package/dist/{chunk-J4IVJA5M.js → chunk-FVEACSKE.js} +2 -2
- package/dist/{chunk-XWA3CJQ6.js → chunk-GU6THOAB.js} +2 -2
- package/dist/{chunk-XTUGZSYQ.js → chunk-H4GA4BBK.js} +2 -2
- package/dist/{chunk-GR3NDMBY.js → chunk-IJ44LR7N.js} +2 -2
- package/dist/{chunk-E6S557NC.js → chunk-J4ANIXIV.js} +2 -2
- package/dist/{chunk-EIQX7INF.js → chunk-JDFNMFOZ.js} +2 -2
- package/dist/{chunk-PSUJEKVV.js → chunk-K7DOZWWT.js} +244 -155
- package/dist/{chunk-UDUPQKRB.js → chunk-KQAT4XBL.js} +2 -2
- package/dist/chunk-KQUALW4O.js +183 -0
- package/dist/{chunk-KPVBKKZS.js → chunk-LYSWLCOI.js} +2 -2
- package/dist/{chunk-YXZGYLKV.js → chunk-MBTUO3ZL.js} +2 -2
- package/dist/{chunk-7GMJOAPF.js → chunk-NI4FHG2K.js} +2 -2
- package/dist/{chunk-6OPZJ7MG.js → chunk-OCMSAKWV.js} +2 -2
- package/dist/{chunk-IP4BEVVD.js → chunk-OGJCRNGE.js} +2 -2
- package/dist/{chunk-6CN53PON.js → chunk-OPPHF3TF.js} +3 -3
- package/dist/{chunk-77UDLVQY.js → chunk-OSAWNZVM.js} +1 -1
- package/dist/{chunk-GWYVY2K4.js → chunk-PNESGUH5.js} +2 -2
- package/dist/{chunk-RSSZXCLL.js → chunk-Q3IEGH4B.js} +2 -2
- package/dist/{chunk-2DSDH2ZH.js → chunk-QJW5LDP4.js} +2 -2
- package/dist/{chunk-JESSJ5CZ.js → chunk-RL64OJJ5.js} +13 -3
- package/dist/{chunk-S4B5OPZU.js → chunk-S5RP5RKY.js} +1 -0
- package/dist/{chunk-4UQTURCD.js → chunk-S6U6TCMN.js} +2 -2
- package/dist/{chunk-C5IRSEEZ.js → chunk-SVNGSZZA.js} +2 -2
- package/dist/{chunk-ZICIXDEE.js → chunk-T5AZAOG5.js} +1 -1
- package/dist/{chunk-IJ675GJR.js → chunk-TCTZW4A6.js} +2 -2
- package/dist/{chunk-54Y7QRZN.js → chunk-U64CW73O.js} +2 -2
- package/dist/{chunk-3RB2FMMO.js → chunk-UQM3G5A2.js} +2 -2
- package/dist/{chunk-ENTHVNOC.js → chunk-VQ7JHB67.js} +2 -2
- package/dist/{chunk-JO6Y4E5V.js → chunk-W7EBL7ZU.js} +2 -2
- package/dist/{chunk-WUR3WWQM.js → chunk-WEPK7SZF.js} +2 -2
- package/dist/{chunk-3JKW6TDE.js → chunk-XMSKJ5PV.js} +2 -2
- package/dist/{chunk-WMSCEBFH.js → chunk-XROI44ZP.js} +2 -2
- package/dist/{chunk-CHZUSBSB.js → chunk-Y3S5SRVM.js} +2 -2
- package/dist/{chunk-KTJLFJEQ.js → chunk-YEFKQYKA.js} +2 -2
- package/dist/{chunk-DTPSTMUD.js → chunk-YSUIXD2Y.js} +2 -2
- package/dist/{chunk-ZMCO3O2Z.js → chunk-ZCOFZY5M.js} +2 -2
- package/dist/{chunk-U6CBYDVZ.js → chunk-ZLU4Y3O2.js} +2 -2
- package/dist/{chunk-337E3ZOB.js → chunk-ZPZ7XDA6.js} +2 -2
- package/dist/{chunk-6GB7PBCR.js → chunk-ZZ6TWKGS.js} +2 -2
- package/dist/{clone-DpbVKLUu.d.ts → clone-C0Jhk2UN.d.ts} +2 -2
- package/dist/comment-QSWYAQVS.js +10 -0
- package/dist/{crm-AAJXYIJY.js → crm-L5RYAAF3.js} +5 -5
- package/dist/dashboard-JMKTTRA7.js +10 -0
- package/dist/database/index.d.ts +3 -3
- package/dist/database/index.js +11 -11
- package/dist/database-GIXSPZJW.js +10 -0
- package/dist/database-field-3EPAMZUR.js +10 -0
- package/dist/database-row-6Q6TRVE4.js +10 -0
- package/dist/database-select-option-BBBJFLLH.js +10 -0
- package/dist/database-view-5PLSOBI2.js +10 -0
- package/dist/experiment-DOVOZ34Y.js +10 -0
- package/dist/{external-item-D654PKLI.js → external-item-P2E5X7BZ.js} +5 -5
- package/dist/external-reference-7BSFF6SA.js +8 -0
- package/dist/feed-2IXHHP6O.js +12 -0
- package/dist/feed-item-DPE6LN7I.js +12 -0
- package/dist/{folder-ONKSWHUT.js → folder-6KM3KYD5.js} +3 -3
- package/dist/{game-BF7DP5JU.js → game-5AK5XWBR.js} +5 -5
- package/dist/grant-WTQSNT5A.js +8 -0
- package/dist/{grant-expiration-cleaner-DBiNTWbD.d.ts → grant-expiration-cleaner-CY6BcmlR.d.ts} +2 -2
- package/dist/import-batch-IXBJLQPJ.js +12 -0
- package/dist/{inbox-state-AN6FPCIY.js → inbox-state-E5NSVOGP.js} +3 -3
- package/dist/index.d.ts +7 -7
- package/dist/index.js +101 -55
- package/dist/map-RG5Y3CPC.js +10 -0
- package/dist/media-asset-QTCGLIZA.js +8 -0
- package/dist/{memory-NV6EVJBE.js → memory-PNRTMBZO.js} +3 -3
- package/dist/metric-65SVOX5C.js +10 -0
- package/dist/milestone-XEO6ZGDG.js +12 -0
- package/dist/{moderation-QSUVBCNS.js → moderation-FFHETMRB.js} +3 -3
- package/dist/observation-2Y3QKUXM.js +10 -0
- package/dist/page-2V27JGKJ.js +10 -0
- package/dist/posting-GBOSZCMZ.js +12 -0
- package/dist/profile-BLLDWIDK.js +8 -0
- package/dist/project-CQL5LN26.js +10 -0
- package/dist/{query-ast-CWo6_30h.d.ts → query-ast-C3m6kZYv.d.ts} +1 -1
- package/dist/reaction-T2DTJBBV.js +10 -0
- package/dist/{registry-CQoN_p8U.d.ts → registry-DunPv__d.d.ts} +1 -1
- package/dist/saved-view-4R2DBCL3.js +10 -0
- package/dist/schema/index.d.ts +320 -13
- package/dist/schema/index.js +98 -52
- package/dist/{schema-extension-5BUXRDPB.js → schema-extension-LNRF2YRD.js} +5 -5
- package/dist/{space-PZ737CIS.js → space-IJU24PBH.js} +6 -6
- package/dist/{space-membership-N35NRX6F.js → space-membership-DEUMM7NV.js} +3 -3
- package/dist/store/index.d.ts +153 -7
- package/dist/store/index.js +5 -5
- package/dist/{store-CvgiydME.d.ts → store-DtIDjk7c.d.ts} +18 -1
- package/dist/{system-Q5I6STDC.js → system-ILHCZOVO.js} +3 -3
- package/dist/{tag-C677GKQW.js → tag-SKCHM3U5.js} +3 -3
- package/dist/{task-WQLMPN5U.js → task-7AOBP7WC.js} +5 -5
- package/dist/task-view-NUGKELJP.js +10 -0
- package/dist/transaction-ZDSEGDIR.js +12 -0
- package/dist/transcription-PSCLDAGX.js +12 -0
- package/dist/{types-Def-5Nka.d.ts → types-S-BJAKyK.d.ts} +53 -2
- package/dist/user-widget-RLY2N46A.js +8 -0
- package/package.json +7 -7
- package/dist/account-BZHETMC4.js +0 -12
- package/dist/budget-WR2Q4YH3.js +0 -12
- package/dist/canvas-HFMMVQLD.js +0 -10
- package/dist/channel-DXDYJ3ZH.js +0 -12
- package/dist/chat-message-QUUQGXEW.js +0 -10
- package/dist/comment-Y2Y3QYYI.js +0 -10
- package/dist/dashboard-3DC6B4BJ.js +0 -10
- package/dist/database-DRGL5UYS.js +0 -10
- package/dist/database-field-F47FY3XX.js +0 -10
- package/dist/database-row-77OXW2NF.js +0 -10
- package/dist/database-select-option-6S3TTA7A.js +0 -10
- package/dist/database-view-YW7DQG5X.js +0 -10
- package/dist/experiment-TJOAL3OH.js +0 -10
- package/dist/external-reference-K5SUJIQN.js +0 -8
- package/dist/feed-L23XTNDI.js +0 -12
- package/dist/feed-item-GSO242J6.js +0 -12
- package/dist/grant-NNKMWXPX.js +0 -8
- package/dist/import-batch-JMESZ4VI.js +0 -12
- package/dist/map-AUV65YS3.js +0 -10
- package/dist/media-asset-4I5YPR4R.js +0 -8
- package/dist/metric-X7W55DLC.js +0 -10
- package/dist/milestone-JJGOGK4R.js +0 -12
- package/dist/observation-UZSXXGBC.js +0 -10
- package/dist/page-NI7XODS6.js +0 -10
- package/dist/posting-FZOYVGH2.js +0 -12
- package/dist/profile-42A43GES.js +0 -8
- package/dist/project-G2CTLDXY.js +0 -10
- package/dist/reaction-VAQBVDUD.js +0 -10
- package/dist/saved-view-AOQHGHNM.js +0 -10
- package/dist/task-view-T5S32V4L.js +0 -10
- package/dist/transaction-U3ITRGJP.js +0 -12
- package/dist/transcription-F3T23LLO.js +0 -12
- 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-
|
|
6
|
+
} from "./chunk-S5RP5RKY.js";
|
|
7
7
|
import {
|
|
8
|
+
SYSTEM_SCHEMA_BASE_IRIS,
|
|
8
9
|
isSystemNamespaceResource,
|
|
9
10
|
isSystemSchemaIri
|
|
10
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-BY5O6LNC.js";
|
|
11
12
|
import {
|
|
12
13
|
createNodeId,
|
|
13
14
|
getBaseSchemaIRI
|
|
14
|
-
} from "./chunk-
|
|
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
|
|
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
|
-
|
|
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
|
|
3632
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
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
|
-
|
|
4736
|
-
|
|
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
|
-
|
|
5127
|
+
authorizedNodes.length,
|
|
5128
|
+
input.authFingerprint
|
|
4824
5129
|
]
|
|
4825
5130
|
);
|
|
4826
|
-
for (const [ordinal, node] of
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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"
|