dsh-plugin-dbgraph-store 0.0.0-stage → 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.
- package/lib/aliases.d.ts +34 -0
- package/lib/aliases.js +92 -0
- package/lib/connections.d.ts +42 -0
- package/lib/connections.js +172 -0
- package/lib/database.d.ts +61 -0
- package/lib/database.js +176 -0
- package/lib/index.d.ts +222 -0
- package/lib/index.js +414 -0
- package/lib/invariant.d.ts +16 -0
- package/lib/invariant.js +23 -0
- package/lib/normalize.d.ts +59 -0
- package/lib/normalize.js +158 -0
- package/lib/paths.d.ts +83 -0
- package/lib/paths.js +130 -0
- package/lib/reader.d.ts +207 -0
- package/lib/reader.js +713 -0
- package/lib/resolve.d.ts +70 -0
- package/lib/resolve.js +762 -0
- package/lib/rust-engine.d.ts +108 -0
- package/lib/rust-engine.js +148 -0
- package/lib/status.d.ts +64 -0
- package/lib/status.js +141 -0
- package/lib/terms.d.ts +93 -0
- package/lib/terms.js +146 -0
- package/lib/writer-js.d.ts +42 -0
- package/lib/writer-js.js +269 -0
- package/package.json +64 -3
- package/README.md +0 -3
package/lib/aliases.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alias-table matching, shared by the writer (which expands a column's name and comment into sibling
|
|
3
|
+
* words) and the resolver (which expands a user's word back into the columns it could mean).
|
|
4
|
+
*
|
|
5
|
+
* The table itself lives in `dsh-plugin-dbgraph-service` (`ZH_ALIASES`) and crosses into the native
|
|
6
|
+
* writer as JSON, so this module only ever *applies* a caller-supplied table — it never owns a copy of
|
|
7
|
+
* the vocabulary.
|
|
8
|
+
* @module dsh-plugin-dbgraph-store/aliases
|
|
9
|
+
*/
|
|
10
|
+
import type { AliasGroup } from 'dsh-plugin-dbgraph-service';
|
|
11
|
+
/**
|
|
12
|
+
* The alias groups whose vocabulary appears in a text.
|
|
13
|
+
* @param parts - name and comment, in any order; undefined and empty entries are ignored.
|
|
14
|
+
* @param aliases - the alias table.
|
|
15
|
+
* @returns matched groups, in table order; empty when the text names nothing from the table.
|
|
16
|
+
*/
|
|
17
|
+
export declare function aliasGroupsIn(parts: readonly (string | undefined)[], aliases: readonly AliasGroup[]): readonly AliasGroup[];
|
|
18
|
+
/**
|
|
19
|
+
* The tokens a group contributes once it matched: every sibling word plus the canonical meaning.
|
|
20
|
+
* Tokens — not raw words — because the index stores the tokenizer's output, and a word the tokenizer
|
|
21
|
+
* would split (`real_name`) has to be looked up by its parts.
|
|
22
|
+
* @param group - the matched alias group.
|
|
23
|
+
* @returns deduplicated tokens, in first-seen order.
|
|
24
|
+
*/
|
|
25
|
+
export declare function aliasGroupTokens(group: AliasGroup): readonly string[];
|
|
26
|
+
/**
|
|
27
|
+
* Whether a user's word is one of a group's words (exact, case-insensitive, separator-insensitive).
|
|
28
|
+
* The resolver uses this to decide which groups a free-text entity mentions.
|
|
29
|
+
* @param word - the user's word.
|
|
30
|
+
* @param group - the candidate group.
|
|
31
|
+
* @returns true when the word names the group.
|
|
32
|
+
*/
|
|
33
|
+
export declare function wordNamesGroup(word: string, group: AliasGroup): boolean;
|
|
34
|
+
//# sourceMappingURL=aliases.d.ts.map
|
package/lib/aliases.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alias-table matching, shared by the writer (which expands a column's name and comment into sibling
|
|
3
|
+
* words) and the resolver (which expands a user's word back into the columns it could mean).
|
|
4
|
+
*
|
|
5
|
+
* The table itself lives in `dsh-plugin-dbgraph-service` (`ZH_ALIASES`) and crosses into the native
|
|
6
|
+
* writer as JSON, so this module only ever *applies* a caller-supplied table — it never owns a copy of
|
|
7
|
+
* the vocabulary.
|
|
8
|
+
* @module dsh-plugin-dbgraph-store/aliases
|
|
9
|
+
*/
|
|
10
|
+
import { tokenizeForSearch } from 'dsh-plugin-dbgraph-service';
|
|
11
|
+
import { hasCjk, isAsciiToken, normalizeIdentifier } from "./normalize.js";
|
|
12
|
+
/**
|
|
13
|
+
* Whether one alias word occurs in a text.
|
|
14
|
+
*
|
|
15
|
+
* Three shapes, because one rule cannot fit all three:
|
|
16
|
+
* - a single ASCII token matches an exact token (`user` in `sys_user`), never a substring — otherwise
|
|
17
|
+
* `order` would match `border_id`;
|
|
18
|
+
* - a CJK word matches as a substring (`用户` in `系统用户`) because Chinese has no separators;
|
|
19
|
+
* - a multi-token ASCII word (`real_name`) matches its folded form, which keeps `real_name` and
|
|
20
|
+
* `realname` interchangeable.
|
|
21
|
+
* @param word - one word from an alias group.
|
|
22
|
+
* @param text - the name or comment being tested.
|
|
23
|
+
* @param tokens - `tokenizeForSearch(text)`, already computed by the caller.
|
|
24
|
+
* @param folded - `normalizeIdentifier(text)`, already computed by the caller.
|
|
25
|
+
* @returns true when the word is present.
|
|
26
|
+
*/
|
|
27
|
+
function wordOccursIn(word, text, tokens, folded) {
|
|
28
|
+
const normalizedWord = word.normalize('NFKC').toLowerCase().trim();
|
|
29
|
+
if (normalizedWord === '')
|
|
30
|
+
return false;
|
|
31
|
+
const wordTokens = tokenizeForSearch(normalizedWord);
|
|
32
|
+
if (wordTokens.length === 0)
|
|
33
|
+
return false;
|
|
34
|
+
if (hasCjk(normalizedWord))
|
|
35
|
+
return normalizedWord !== '' && text.toLowerCase().includes(normalizedWord);
|
|
36
|
+
if (wordTokens.length === 1 && isAsciiToken(wordTokens[0]))
|
|
37
|
+
return tokens.has(wordTokens[0]);
|
|
38
|
+
return folded.includes(normalizeIdentifier(normalizedWord));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The alias groups whose vocabulary appears in a text.
|
|
42
|
+
* @param parts - name and comment, in any order; undefined and empty entries are ignored.
|
|
43
|
+
* @param aliases - the alias table.
|
|
44
|
+
* @returns matched groups, in table order; empty when the text names nothing from the table.
|
|
45
|
+
*/
|
|
46
|
+
export function aliasGroupsIn(parts, aliases) {
|
|
47
|
+
const text = parts.filter((part) => part !== undefined && part !== '').join(' ');
|
|
48
|
+
if (text === '')
|
|
49
|
+
return [];
|
|
50
|
+
const tokens = new Set(tokenizeForSearch(text));
|
|
51
|
+
const folded = normalizeIdentifier(text);
|
|
52
|
+
const matched = [];
|
|
53
|
+
for (const group of aliases) {
|
|
54
|
+
if (group.words.some(word => wordOccursIn(word, text, tokens, folded)))
|
|
55
|
+
matched.push(group);
|
|
56
|
+
}
|
|
57
|
+
return matched;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The tokens a group contributes once it matched: every sibling word plus the canonical meaning.
|
|
61
|
+
* Tokens — not raw words — because the index stores the tokenizer's output, and a word the tokenizer
|
|
62
|
+
* would split (`real_name`) has to be looked up by its parts.
|
|
63
|
+
* @param group - the matched alias group.
|
|
64
|
+
* @returns deduplicated tokens, in first-seen order.
|
|
65
|
+
*/
|
|
66
|
+
export function aliasGroupTokens(group) {
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const tokens = [];
|
|
69
|
+
for (const word of [...group.words, group.meaning]) {
|
|
70
|
+
for (const token of tokenizeForSearch(word)) {
|
|
71
|
+
if (seen.has(token))
|
|
72
|
+
continue;
|
|
73
|
+
seen.add(token);
|
|
74
|
+
tokens.push(token);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return tokens;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether a user's word is one of a group's words (exact, case-insensitive, separator-insensitive).
|
|
81
|
+
* The resolver uses this to decide which groups a free-text entity mentions.
|
|
82
|
+
* @param word - the user's word.
|
|
83
|
+
* @param group - the candidate group.
|
|
84
|
+
* @returns true when the word names the group.
|
|
85
|
+
*/
|
|
86
|
+
export function wordNamesGroup(word, group) {
|
|
87
|
+
const folded = normalizeIdentifier(word);
|
|
88
|
+
if (folded === '')
|
|
89
|
+
return false;
|
|
90
|
+
return group.words.some(candidate => normalizeIdentifier(candidate) === folded);
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=aliases.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `connections.json` — the configured connection list.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately boring, for a security reason: this file never holds a password, only the name of the
|
|
5
|
+
* credential-seam entry that does (`credentialRef`). It is the one place a connection id arrives from
|
|
6
|
+
* outside the process, and that id becomes a directory name, so **every** entry is validated against
|
|
7
|
+
* `isValidConnectionId` before it may be used. A malformed file fails loudly rather than yielding a
|
|
8
|
+
* list that a later path join would silently turn into a path traversal.
|
|
9
|
+
*
|
|
10
|
+
* The file is written atomically (staging file + rename) and with `0600`, because it lists hosts,
|
|
11
|
+
* users, and database names even though it holds no secret.
|
|
12
|
+
* @module dsh-plugin-dbgraph-store/connections
|
|
13
|
+
*/
|
|
14
|
+
import type { StoredConnection } from 'dsh-plugin-dbgraph-service';
|
|
15
|
+
/** Format version this build writes and the only one it understands. */
|
|
16
|
+
export declare const CONNECTIONS_FORMAT_VERSION = 1;
|
|
17
|
+
/**
|
|
18
|
+
* Read the connection list.
|
|
19
|
+
*
|
|
20
|
+
* A missing file is an empty list, not an error: a fresh installation has no connections yet, and
|
|
21
|
+
* every read path must keep working while the user is still configuring their first one.
|
|
22
|
+
* @param root - index root.
|
|
23
|
+
* @returns the configured connections, in file order.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readConnections(root: string): readonly StoredConnection[];
|
|
26
|
+
/**
|
|
27
|
+
* Replace the connection list atomically.
|
|
28
|
+
*
|
|
29
|
+
* Round-tripping through the file (read → modify → write) is the supported way to add or remove one
|
|
30
|
+
* connection; preserving order is the caller's business, not this function's.
|
|
31
|
+
* @param connections - the full list to persist.
|
|
32
|
+
* @param root - index root.
|
|
33
|
+
*/
|
|
34
|
+
export declare function writeConnections(connections: readonly StoredConnection[], root: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* The configured connection with this id, when there is one.
|
|
37
|
+
* @param id - connection identity.
|
|
38
|
+
* @param root - index root.
|
|
39
|
+
* @returns the entry, or undefined.
|
|
40
|
+
*/
|
|
41
|
+
export declare function findConnection(id: string, root: string): StoredConnection | undefined;
|
|
42
|
+
//# sourceMappingURL=connections.d.ts.map
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `connections.json` — the configured connection list.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately boring, for a security reason: this file never holds a password, only the name of the
|
|
5
|
+
* credential-seam entry that does (`credentialRef`). It is the one place a connection id arrives from
|
|
6
|
+
* outside the process, and that id becomes a directory name, so **every** entry is validated against
|
|
7
|
+
* `isValidConnectionId` before it may be used. A malformed file fails loudly rather than yielding a
|
|
8
|
+
* list that a later path join would silently turn into a path traversal.
|
|
9
|
+
*
|
|
10
|
+
* The file is written atomically (staging file + rename) and with `0600`, because it lists hosts,
|
|
11
|
+
* users, and database names even though it holds no secret.
|
|
12
|
+
* @module dsh-plugin-dbgraph-store/connections
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
|
+
import { DbgraphError, isValidConnectionId } from 'dsh-plugin-dbgraph-service';
|
|
17
|
+
import { connectionsPath } from "./paths.js";
|
|
18
|
+
/** Format version this build writes and the only one it understands. */
|
|
19
|
+
export const CONNECTIONS_FORMAT_VERSION = 1;
|
|
20
|
+
/**
|
|
21
|
+
* One thrown value's message.
|
|
22
|
+
* @param error - the thrown value.
|
|
23
|
+
* @returns a non-empty message.
|
|
24
|
+
*/
|
|
25
|
+
function messageOf(error) {
|
|
26
|
+
if (error instanceof Error)
|
|
27
|
+
return error.message;
|
|
28
|
+
if (typeof error === 'string')
|
|
29
|
+
return error;
|
|
30
|
+
return JSON.stringify(error) ?? 'unknown error';
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Read one entry out of a parsed document, validating everything that could later become a path or a
|
|
34
|
+
* credential reference.
|
|
35
|
+
* @param value - one entry of the parsed `connections` array.
|
|
36
|
+
* @param position - its index, for the error message.
|
|
37
|
+
* @returns the validated entry.
|
|
38
|
+
*/
|
|
39
|
+
function parseConnection(value, position) {
|
|
40
|
+
if (typeof value !== 'object' || value === null) {
|
|
41
|
+
throw new DbgraphError(`connections.json entry ${position} is not an object`, 'DBGRAPH_MALFORMED_INDEX');
|
|
42
|
+
}
|
|
43
|
+
const record = value;
|
|
44
|
+
const id = record.id;
|
|
45
|
+
if (typeof id !== 'string' || !isValidConnectionId(id)) {
|
|
46
|
+
throw new DbgraphError(`connections.json entry ${position} has an unusable id: ${JSON.stringify(id)}`, 'DBGRAPH_MALFORMED_INDEX');
|
|
47
|
+
}
|
|
48
|
+
const engine = record.engine;
|
|
49
|
+
if (typeof engine !== 'string' || engine.trim() === '') {
|
|
50
|
+
throw new DbgraphError(`connections.json entry "${id}" has no engine`, 'DBGRAPH_MALFORMED_INDEX');
|
|
51
|
+
}
|
|
52
|
+
const patterns = record.sensitivePatterns;
|
|
53
|
+
const sensitivePatterns = Array.isArray(patterns)
|
|
54
|
+
? patterns.filter((pattern) => typeof pattern === 'string')
|
|
55
|
+
: undefined;
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
engine,
|
|
59
|
+
...(typeof record.label === 'string' ? { label: record.label } : {}),
|
|
60
|
+
...(typeof record.host === 'string' ? { host: record.host } : {}),
|
|
61
|
+
...(typeof record.port === 'number' ? { port: record.port } : {}),
|
|
62
|
+
...(typeof record.database === 'string' ? { database: record.database } : {}),
|
|
63
|
+
...(typeof record.user === 'string' ? { user: record.user } : {}),
|
|
64
|
+
...(typeof record.credentialRef === 'string' ? { credentialRef: record.credentialRef } : {}),
|
|
65
|
+
...(typeof record.sensitiveColumns === 'string' ? { sensitiveColumns: record.sensitiveColumns } : {}),
|
|
66
|
+
...(sensitivePatterns === undefined ? {} : { sensitivePatterns }),
|
|
67
|
+
...(typeof record.addedAt === 'number' ? { addedAt: record.addedAt } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Read the connection list.
|
|
72
|
+
*
|
|
73
|
+
* A missing file is an empty list, not an error: a fresh installation has no connections yet, and
|
|
74
|
+
* every read path must keep working while the user is still configuring their first one.
|
|
75
|
+
* @param root - index root.
|
|
76
|
+
* @returns the configured connections, in file order.
|
|
77
|
+
*/
|
|
78
|
+
export function readConnections(root) {
|
|
79
|
+
const path = connectionsPath(root);
|
|
80
|
+
if (!existsSync(path))
|
|
81
|
+
return [];
|
|
82
|
+
let text;
|
|
83
|
+
try {
|
|
84
|
+
text = readFileSync(path, 'utf8');
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new DbgraphError(`connections.json is unreadable: ${messageOf(error)}`, 'DBGRAPH_MALFORMED_INDEX', {
|
|
88
|
+
cause: error,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(text);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
throw new DbgraphError(`connections.json is not valid JSON: ${messageOf(error)}`, 'DBGRAPH_MALFORMED_INDEX', {
|
|
97
|
+
cause: error,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
101
|
+
throw new DbgraphError('connections.json is not an object', 'DBGRAPH_MALFORMED_INDEX');
|
|
102
|
+
}
|
|
103
|
+
const document = parsed;
|
|
104
|
+
if (document.version !== CONNECTIONS_FORMAT_VERSION) {
|
|
105
|
+
throw new DbgraphError(`connections.json is version ${JSON.stringify(document.version)}; this build writes and reads`
|
|
106
|
+
+ ` version ${CONNECTIONS_FORMAT_VERSION}`, 'DBGRAPH_MALFORMED_INDEX');
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(document.connections)) {
|
|
109
|
+
throw new DbgraphError('connections.json has no connections array', 'DBGRAPH_MALFORMED_INDEX');
|
|
110
|
+
}
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const connections = [];
|
|
113
|
+
for (let index = 0; index < document.connections.length; index += 1) {
|
|
114
|
+
const entry = parseConnection(document.connections[index], index);
|
|
115
|
+
if (seen.has(entry.id)) {
|
|
116
|
+
throw new DbgraphError(`connections.json lists "${entry.id}" twice`, 'DBGRAPH_MALFORMED_INDEX');
|
|
117
|
+
}
|
|
118
|
+
seen.add(entry.id);
|
|
119
|
+
connections.push(entry);
|
|
120
|
+
}
|
|
121
|
+
return connections;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Replace the connection list atomically.
|
|
125
|
+
*
|
|
126
|
+
* Round-tripping through the file (read → modify → write) is the supported way to add or remove one
|
|
127
|
+
* connection; preserving order is the caller's business, not this function's.
|
|
128
|
+
* @param connections - the full list to persist.
|
|
129
|
+
* @param root - index root.
|
|
130
|
+
*/
|
|
131
|
+
export function writeConnections(connections, root) {
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
const document = {
|
|
134
|
+
version: CONNECTIONS_FORMAT_VERSION,
|
|
135
|
+
connections: connections.map((connection, index) => {
|
|
136
|
+
if (!isValidConnectionId(connection.id)) {
|
|
137
|
+
throw new DbgraphError(`connection entry ${index} has an unusable id: "${connection.id}"`, 'DBGRAPH_MALFORMED_INDEX');
|
|
138
|
+
}
|
|
139
|
+
if (connection.engine.trim() === '') {
|
|
140
|
+
throw new DbgraphError(`connection "${connection.id}" has no engine`, 'DBGRAPH_MALFORMED_INDEX');
|
|
141
|
+
}
|
|
142
|
+
if (seen.has(connection.id)) {
|
|
143
|
+
throw new DbgraphError(`connection "${connection.id}" is listed twice`, 'DBGRAPH_MALFORMED_INDEX');
|
|
144
|
+
}
|
|
145
|
+
seen.add(connection.id);
|
|
146
|
+
return { ...connection };
|
|
147
|
+
}),
|
|
148
|
+
};
|
|
149
|
+
const target = connectionsPath(root);
|
|
150
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
151
|
+
const staging = `${target}.tmp`;
|
|
152
|
+
writeFileSync(staging, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
153
|
+
try {
|
|
154
|
+
renameSync(staging, target);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
rmSync(staging, { force: true });
|
|
158
|
+
throw new DbgraphError(`writing connections.json failed: ${messageOf(error)}`, 'DBGRAPH_WRITE_FAILED', {
|
|
159
|
+
cause: error,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The configured connection with this id, when there is one.
|
|
165
|
+
* @param id - connection identity.
|
|
166
|
+
* @param root - index root.
|
|
167
|
+
* @returns the entry, or undefined.
|
|
168
|
+
*/
|
|
169
|
+
export function findConnection(id, root) {
|
|
170
|
+
return readConnections(root).find(connection => connection.id === id);
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=connections.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening an index file, verifying its format version, and keeping a bounded number of connections
|
|
3
|
+
* warm.
|
|
4
|
+
*
|
|
5
|
+
* Databases open READ-ONLY. A writer replaces the file wholesale (a per-run staging file → rename) while this
|
|
6
|
+
* reader may be serving a query, so the store never takes a write lock and never repairs a file it does
|
|
7
|
+
* not own. The pool therefore keys a cached handle on the path's *on-disk identity* (`dev:ino`): after
|
|
8
|
+
* a rename the path names a new inode, the cached handle is stale, and the next read reopens instead of
|
|
9
|
+
* serving the previous index forever.
|
|
10
|
+
* @module dsh-plugin-dbgraph-store/database
|
|
11
|
+
*/
|
|
12
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
13
|
+
/** An open index and the `meta` rows read from it. */
|
|
14
|
+
export interface OpenedIndex {
|
|
15
|
+
/** The live, read-only connection. */
|
|
16
|
+
readonly db: DatabaseSync;
|
|
17
|
+
/** Every `meta` key/value the file carries. */
|
|
18
|
+
readonly meta: ReadonlyMap<string, string>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Open one index read-only and refuse a file this build does not understand.
|
|
22
|
+
* @param path - absolute path of the index file.
|
|
23
|
+
* @returns the live connection and its meta rows.
|
|
24
|
+
*/
|
|
25
|
+
export declare function openIndex(path: string): OpenedIndex;
|
|
26
|
+
/**
|
|
27
|
+
* A bounded LRU pool of read-only index connections.
|
|
28
|
+
*
|
|
29
|
+
* A stat per acquire is deliberate: it is the cheapest way to notice that a writer replaced the file,
|
|
30
|
+
* and the alternative — trusting a handle until the process restarts — is exactly the bug an
|
|
31
|
+
* atomically-replaced index would otherwise create.
|
|
32
|
+
*/
|
|
33
|
+
export declare class IndexPool {
|
|
34
|
+
private readonly entries;
|
|
35
|
+
private readonly maxOpen;
|
|
36
|
+
private clock;
|
|
37
|
+
private disposed;
|
|
38
|
+
/**
|
|
39
|
+
* @param maxOpen - how many index files to keep open at once (default 4).
|
|
40
|
+
*/
|
|
41
|
+
constructor(maxOpen?: number);
|
|
42
|
+
/**
|
|
43
|
+
* Acquire the live connection for one index, reopening when the file on disk was replaced.
|
|
44
|
+
* @param path - absolute path of the index file.
|
|
45
|
+
* @returns the open index.
|
|
46
|
+
*/
|
|
47
|
+
acquire(path: string): OpenedIndex;
|
|
48
|
+
/**
|
|
49
|
+
* Close every pooled connection. Idempotent.
|
|
50
|
+
*/
|
|
51
|
+
close(): void;
|
|
52
|
+
/**
|
|
53
|
+
* The `dev:ino` identity of a file, as a cache key for "is this still the same file".
|
|
54
|
+
* @param path - absolute path.
|
|
55
|
+
* @returns the identity string.
|
|
56
|
+
*/
|
|
57
|
+
private identityOf;
|
|
58
|
+
private closeEntry;
|
|
59
|
+
private evictIfNeeded;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=database.d.ts.map
|
package/lib/database.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening an index file, verifying its format version, and keeping a bounded number of connections
|
|
3
|
+
* warm.
|
|
4
|
+
*
|
|
5
|
+
* Databases open READ-ONLY. A writer replaces the file wholesale (a per-run staging file → rename) while this
|
|
6
|
+
* reader may be serving a query, so the store never takes a write lock and never repairs a file it does
|
|
7
|
+
* not own. The pool therefore keys a cached handle on the path's *on-disk identity* (`dev:ino`): after
|
|
8
|
+
* a rename the path names a new inode, the cached handle is stale, and the next read reopens instead of
|
|
9
|
+
* serving the previous index forever.
|
|
10
|
+
* @module dsh-plugin-dbgraph-store/database
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, statSync } from 'node:fs';
|
|
13
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
14
|
+
import { DbgraphError, INDEX_META_KEYS, INDEX_SCHEMA_VERSION, } from 'dsh-plugin-dbgraph-service';
|
|
15
|
+
/**
|
|
16
|
+
* One thrown value's message.
|
|
17
|
+
* @param error - the thrown value.
|
|
18
|
+
* @returns a non-empty message.
|
|
19
|
+
*/
|
|
20
|
+
function messageOf(error) {
|
|
21
|
+
if (error instanceof Error)
|
|
22
|
+
return error.message;
|
|
23
|
+
if (typeof error === 'string')
|
|
24
|
+
return error;
|
|
25
|
+
return JSON.stringify(error) ?? 'unknown error';
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read `meta` into a map.
|
|
29
|
+
* @param db - an open index.
|
|
30
|
+
* @returns every key/value pair.
|
|
31
|
+
*/
|
|
32
|
+
function readMeta(db) {
|
|
33
|
+
const rows = db.prepare('SELECT key, value FROM meta').all();
|
|
34
|
+
const meta = new Map();
|
|
35
|
+
for (const row of rows) {
|
|
36
|
+
const key = row.key;
|
|
37
|
+
const value = row.value;
|
|
38
|
+
if (typeof key === 'string' && typeof value === 'string')
|
|
39
|
+
meta.set(key, value);
|
|
40
|
+
}
|
|
41
|
+
return meta;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Open one index read-only and refuse a file this build does not understand.
|
|
45
|
+
* @param path - absolute path of the index file.
|
|
46
|
+
* @returns the live connection and its meta rows.
|
|
47
|
+
*/
|
|
48
|
+
export function openIndex(path) {
|
|
49
|
+
if (!existsSync(path)) {
|
|
50
|
+
throw new DbgraphError(`no database-graph index exists at ${path}`, 'DBGRAPH_UNAVAILABLE');
|
|
51
|
+
}
|
|
52
|
+
let db;
|
|
53
|
+
try {
|
|
54
|
+
db = new DatabaseSync(path, { readOnly: true });
|
|
55
|
+
const meta = readMeta(db);
|
|
56
|
+
const version = meta.get(INDEX_META_KEYS.schemaVersion);
|
|
57
|
+
if (version === undefined) {
|
|
58
|
+
throw new DbgraphError(`the index at ${path} records no schema version`, 'DBGRAPH_MALFORMED_INDEX');
|
|
59
|
+
}
|
|
60
|
+
if (Number(version) !== INDEX_SCHEMA_VERSION) {
|
|
61
|
+
throw new DbgraphError(`the index at ${path} uses schema version ${version}; this build understands ${INDEX_SCHEMA_VERSION}`, 'DBGRAPH_MALFORMED_INDEX');
|
|
62
|
+
}
|
|
63
|
+
return { db, meta };
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
try {
|
|
67
|
+
db?.close();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Closing a handle that failed to open is best-effort; the original error is the signal.
|
|
71
|
+
}
|
|
72
|
+
if (error instanceof DbgraphError)
|
|
73
|
+
throw error;
|
|
74
|
+
throw new DbgraphError(`the index at ${path} is not readable: ${messageOf(error)}`, 'DBGRAPH_MALFORMED_INDEX', { cause: error });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A bounded LRU pool of read-only index connections.
|
|
79
|
+
*
|
|
80
|
+
* A stat per acquire is deliberate: it is the cheapest way to notice that a writer replaced the file,
|
|
81
|
+
* and the alternative — trusting a handle until the process restarts — is exactly the bug an
|
|
82
|
+
* atomically-replaced index would otherwise create.
|
|
83
|
+
*/
|
|
84
|
+
export class IndexPool {
|
|
85
|
+
entries = new Map();
|
|
86
|
+
maxOpen;
|
|
87
|
+
clock = 0;
|
|
88
|
+
disposed = false;
|
|
89
|
+
/**
|
|
90
|
+
* @param maxOpen - how many index files to keep open at once (default 4).
|
|
91
|
+
*/
|
|
92
|
+
constructor(maxOpen = 4) {
|
|
93
|
+
if (!Number.isInteger(maxOpen) || maxOpen < 1) {
|
|
94
|
+
throw new Error('dbgraph-store: maxOpenIndexes must be a positive integer');
|
|
95
|
+
}
|
|
96
|
+
this.maxOpen = maxOpen;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Acquire the live connection for one index, reopening when the file on disk was replaced.
|
|
100
|
+
* @param path - absolute path of the index file.
|
|
101
|
+
* @returns the open index.
|
|
102
|
+
*/
|
|
103
|
+
acquire(path) {
|
|
104
|
+
if (this.disposed)
|
|
105
|
+
throw new DbgraphError('the dbgraph store is disposed', 'DBGRAPH_UNAVAILABLE');
|
|
106
|
+
const identity = this.identityOf(path);
|
|
107
|
+
const cached = this.entries.get(path);
|
|
108
|
+
if (cached !== undefined && cached.identity !== identity) {
|
|
109
|
+
this.closeEntry(cached);
|
|
110
|
+
this.entries.delete(path);
|
|
111
|
+
}
|
|
112
|
+
const existing = this.entries.get(path);
|
|
113
|
+
if (existing !== undefined) {
|
|
114
|
+
existing.lastUsed = ++this.clock;
|
|
115
|
+
return existing.opened;
|
|
116
|
+
}
|
|
117
|
+
const opened = openIndex(path);
|
|
118
|
+
// Re-stat after opening: a rename between the first stat and the open would otherwise record the
|
|
119
|
+
// previous file's identity against the new handle, and the next acquire would trust a stale map.
|
|
120
|
+
const openedIdentity = this.identityOf(path);
|
|
121
|
+
const entry = { opened, identity: openedIdentity, lastUsed: ++this.clock };
|
|
122
|
+
this.entries.set(path, entry);
|
|
123
|
+
this.evictIfNeeded();
|
|
124
|
+
return opened;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Close every pooled connection. Idempotent.
|
|
128
|
+
*/
|
|
129
|
+
close() {
|
|
130
|
+
this.disposed = true;
|
|
131
|
+
for (const entry of this.entries.values())
|
|
132
|
+
this.closeEntry(entry);
|
|
133
|
+
this.entries.clear();
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The `dev:ino` identity of a file, as a cache key for "is this still the same file".
|
|
137
|
+
* @param path - absolute path.
|
|
138
|
+
* @returns the identity string.
|
|
139
|
+
*/
|
|
140
|
+
identityOf(path) {
|
|
141
|
+
try {
|
|
142
|
+
const stats = statSync(path);
|
|
143
|
+
return `${stats.dev}:${stats.ino}`;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
throw new DbgraphError(`no database-graph index exists at ${path}: ${messageOf(error)}`, 'DBGRAPH_UNAVAILABLE', { cause: error });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
closeEntry(entry) {
|
|
150
|
+
try {
|
|
151
|
+
entry.opened.db.close();
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
// A handle that already died needs no second close, but a failure here can leak a descriptor:
|
|
155
|
+
// one line is enough to notice it.
|
|
156
|
+
console.error('[dbgraph-store] closing a cached index handle failed:', messageOf(error));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
evictIfNeeded() {
|
|
160
|
+
if (this.entries.size <= this.maxOpen)
|
|
161
|
+
return;
|
|
162
|
+
let oldestPath;
|
|
163
|
+
let oldest;
|
|
164
|
+
for (const [path, entry] of this.entries) {
|
|
165
|
+
if (oldest === undefined || entry.lastUsed < oldest.lastUsed) {
|
|
166
|
+
oldest = entry;
|
|
167
|
+
oldestPath = path;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (oldest !== undefined && oldestPath !== undefined) {
|
|
171
|
+
this.closeEntry(oldest);
|
|
172
|
+
this.entries.delete(oldestPath);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=database.js.map
|