@frockbot/plugin-search 0.0.0 → 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/frockbot.json +24 -0
- package/package.json +42 -6
- package/src/backend.test.ts +210 -0
- package/src/backend.ts +216 -0
- package/src/bot.test.ts +105 -0
- package/src/bot.ts +125 -0
- package/src/client/SearchBox.vue +21 -0
- package/src/client/SearchOverlay.vue +165 -0
- package/src/client/index.ts +192 -0
- package/src/client/state.ts +44 -0
- package/src/client/styles.css +225 -0
- package/src/env.d.ts +5 -0
- package/src/groups.test.ts +130 -0
- package/src/groups.ts +78 -0
- package/src/index-store.test.ts +206 -0
- package/src/index-store.ts +420 -0
- package/src/index.ts +17 -0
- package/src/manifest.ts +3 -0
- package/src/shared.test.ts +215 -0
- package/src/shared.ts +569 -0
- package/src/testing.ts +213 -0
- package/src/user.ts +116 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/testing.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// A fake `SearchSqlV1` for unit tests.
|
|
2
|
+
//
|
|
3
|
+
// It is not an SQL engine. It recognises exactly the statements
|
|
4
|
+
// `SearchIndexV1` issues and answers them from JavaScript maps, with a
|
|
5
|
+
// deliberately naive substring matcher standing in for FTS5. That is enough to
|
|
6
|
+
// hold the module's real logic to account — idempotency on
|
|
7
|
+
// `(botId, runId, seq)`, quota eviction and its durable marker, purge, kind
|
|
8
|
+
// and archive filtering, paging — while the workerd test proves the same
|
|
9
|
+
// module against the real FTS5 table on `ctx.storage.sql`.
|
|
10
|
+
import type {
|
|
11
|
+
SearchSqlCursorV1,
|
|
12
|
+
SearchSqlV1,
|
|
13
|
+
SearchSqlValueV1,
|
|
14
|
+
} from "./index-store.js";
|
|
15
|
+
|
|
16
|
+
interface FakeRow {
|
|
17
|
+
body: string;
|
|
18
|
+
bot_id: string;
|
|
19
|
+
run_id: string;
|
|
20
|
+
seq: number;
|
|
21
|
+
kind: string;
|
|
22
|
+
at: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cursor<Row extends Record<string, SearchSqlValueV1>>(
|
|
26
|
+
rows: Row[],
|
|
27
|
+
): SearchSqlCursorV1<Row> {
|
|
28
|
+
return { toArray: () => rows };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Mirrors `searchMatchExpressionV1`'s output closely enough to match on. */
|
|
32
|
+
function matches(body: string, expression: string): boolean {
|
|
33
|
+
const tokens = [...expression.matchAll(/"((?:[^"]|"")*)"(\*?)/g)].map(
|
|
34
|
+
(match) => ({
|
|
35
|
+
token: match[1]!.replaceAll('""', '"').toLowerCase(),
|
|
36
|
+
prefix: match[2] === "*",
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
if (tokens.length === 0) return false;
|
|
40
|
+
const words = body
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.split(/[^\p{L}\p{N}_]+/u)
|
|
43
|
+
.filter(Boolean);
|
|
44
|
+
return tokens.every(({ token, prefix }) =>
|
|
45
|
+
words.some((word) => (prefix ? word.startsWith(token) : word === token)),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class FakeSearchSql implements SearchSqlV1 {
|
|
50
|
+
private rows: FakeRow[] = [];
|
|
51
|
+
private meta = new Map<string, string>();
|
|
52
|
+
/** Every statement the module issued, for tests that assert on shape. */
|
|
53
|
+
readonly statements: string[] = [];
|
|
54
|
+
|
|
55
|
+
exec<Row extends Record<string, SearchSqlValueV1>>(
|
|
56
|
+
query: string,
|
|
57
|
+
...bindings: unknown[]
|
|
58
|
+
): SearchSqlCursorV1<Row> {
|
|
59
|
+
this.statements.push(query);
|
|
60
|
+
const sql = query.replace(/\s+/g, " ").trim();
|
|
61
|
+
const answer = (rows: unknown[]) => cursor(rows as Row[]);
|
|
62
|
+
|
|
63
|
+
if (sql.startsWith("CREATE") || sql.startsWith("DROP")) return answer([]);
|
|
64
|
+
|
|
65
|
+
if (sql.startsWith("SELECT value FROM search_meta")) {
|
|
66
|
+
const value = this.meta.get(String(bindings[0]));
|
|
67
|
+
return answer(value === undefined ? [] : [{ value }]);
|
|
68
|
+
}
|
|
69
|
+
if (sql.startsWith("INSERT INTO search_meta")) {
|
|
70
|
+
this.meta.set(String(bindings[0]), String(bindings[1]));
|
|
71
|
+
return answer([]);
|
|
72
|
+
}
|
|
73
|
+
if (sql.startsWith("DELETE FROM search_meta")) {
|
|
74
|
+
this.meta.delete(String(bindings[0]));
|
|
75
|
+
return answer([]);
|
|
76
|
+
}
|
|
77
|
+
if (
|
|
78
|
+
sql.startsWith(
|
|
79
|
+
"SELECT count(*) AS n FROM search_keys WHERE bot_id = ? AND run_id = ? AND seq = ?",
|
|
80
|
+
)
|
|
81
|
+
) {
|
|
82
|
+
return answer([{ n: this.key(bindings) ? 1 : 0 }]);
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
sql.startsWith("SELECT count(*) AS n FROM search_keys WHERE bot_id = ?")
|
|
86
|
+
) {
|
|
87
|
+
return answer([
|
|
88
|
+
{ n: this.rows.filter((row) => row.bot_id === bindings[0]).length },
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
91
|
+
if (sql.startsWith("SELECT count(*) AS n FROM search_keys")) {
|
|
92
|
+
return answer([{ n: this.rows.length }]);
|
|
93
|
+
}
|
|
94
|
+
if (sql.startsWith("INSERT INTO search_rows")) {
|
|
95
|
+
this.rows.push({
|
|
96
|
+
body: String(bindings[0]),
|
|
97
|
+
bot_id: String(bindings[1]),
|
|
98
|
+
run_id: String(bindings[2]),
|
|
99
|
+
seq: Number(bindings[3]),
|
|
100
|
+
kind: String(bindings[4]),
|
|
101
|
+
at: String(bindings[5]),
|
|
102
|
+
});
|
|
103
|
+
return answer([]);
|
|
104
|
+
}
|
|
105
|
+
if (sql.startsWith("INSERT INTO search_keys")) return answer([]);
|
|
106
|
+
if (
|
|
107
|
+
sql.startsWith("SELECT bot_id, run_id, seq FROM search_keys ORDER BY at")
|
|
108
|
+
) {
|
|
109
|
+
const limit = Number(bindings[0]);
|
|
110
|
+
return answer(
|
|
111
|
+
[...this.rows]
|
|
112
|
+
.sort(
|
|
113
|
+
(left, right) =>
|
|
114
|
+
left.at.localeCompare(right.at) ||
|
|
115
|
+
left.bot_id.localeCompare(right.bot_id) ||
|
|
116
|
+
left.run_id.localeCompare(right.run_id) ||
|
|
117
|
+
left.seq - right.seq,
|
|
118
|
+
)
|
|
119
|
+
.slice(0, limit)
|
|
120
|
+
.map((row) => ({
|
|
121
|
+
bot_id: row.bot_id,
|
|
122
|
+
run_id: row.run_id,
|
|
123
|
+
seq: row.seq,
|
|
124
|
+
})),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
sql.startsWith("DELETE FROM search_rows WHERE bot_id = ? AND run_id") ||
|
|
129
|
+
sql.startsWith("DELETE FROM search_keys WHERE bot_id = ? AND run_id")
|
|
130
|
+
) {
|
|
131
|
+
this.rows = this.rows.filter(
|
|
132
|
+
(row) =>
|
|
133
|
+
!(
|
|
134
|
+
row.bot_id === bindings[0] &&
|
|
135
|
+
row.run_id === bindings[1] &&
|
|
136
|
+
row.seq === Number(bindings[2])
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
return answer([]);
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
sql.startsWith("DELETE FROM search_rows WHERE bot_id = ?") ||
|
|
143
|
+
sql.startsWith("DELETE FROM search_keys WHERE bot_id = ?")
|
|
144
|
+
) {
|
|
145
|
+
this.rows = this.rows.filter((row) => row.bot_id !== bindings[0]);
|
|
146
|
+
return answer([]);
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
sql === "DELETE FROM search_rows" ||
|
|
150
|
+
sql === "DELETE FROM search_keys"
|
|
151
|
+
) {
|
|
152
|
+
this.rows = [];
|
|
153
|
+
return answer([]);
|
|
154
|
+
}
|
|
155
|
+
if (sql.startsWith("SELECT bot_id, run_id, seq, kind, at,")) {
|
|
156
|
+
return answer(this.select(sql, bindings));
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`FakeSearchSql does not recognise: ${sql}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private key(bindings: unknown[]): FakeRow | undefined {
|
|
162
|
+
return this.rows.find(
|
|
163
|
+
(row) =>
|
|
164
|
+
row.bot_id === bindings[0] &&
|
|
165
|
+
row.run_id === bindings[1] &&
|
|
166
|
+
row.seq === Number(bindings[2]),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private select(sql: string, bindings: unknown[]): unknown[] {
|
|
171
|
+
const values = [...bindings];
|
|
172
|
+
const expression = String(values.shift());
|
|
173
|
+
const kindCount = (sql.match(/kind IN \(([^)]*)\)/)?.[1] ?? "").split(
|
|
174
|
+
",",
|
|
175
|
+
).length;
|
|
176
|
+
const kinds = values.splice(0, kindCount).map(String);
|
|
177
|
+
const botId = sql.includes("AND bot_id = ?")
|
|
178
|
+
? String(values.shift())
|
|
179
|
+
: undefined;
|
|
180
|
+
const excludedCount = Number(
|
|
181
|
+
(sql.match(/bot_id NOT IN \(([^)]*)\)/)?.[1] ?? "")
|
|
182
|
+
.split(",")
|
|
183
|
+
.filter((part) => part.trim() === "?").length,
|
|
184
|
+
);
|
|
185
|
+
const excluded = values.splice(0, excludedCount).map(String);
|
|
186
|
+
const offset = Number(values.pop());
|
|
187
|
+
const limit = Number(values.pop());
|
|
188
|
+
return this.rows
|
|
189
|
+
.filter(
|
|
190
|
+
(row) =>
|
|
191
|
+
matches(row.body, expression) &&
|
|
192
|
+
kinds.includes(row.kind) &&
|
|
193
|
+
(botId === undefined || row.bot_id === botId) &&
|
|
194
|
+
!excluded.includes(row.bot_id),
|
|
195
|
+
)
|
|
196
|
+
.sort(
|
|
197
|
+
(left, right) =>
|
|
198
|
+
left.at.localeCompare(right.at) ||
|
|
199
|
+
left.bot_id.localeCompare(right.bot_id) ||
|
|
200
|
+
left.run_id.localeCompare(right.run_id) ||
|
|
201
|
+
left.seq - right.seq,
|
|
202
|
+
)
|
|
203
|
+
.slice(offset, offset + limit)
|
|
204
|
+
.map((row) => ({
|
|
205
|
+
bot_id: row.bot_id,
|
|
206
|
+
run_id: row.run_id,
|
|
207
|
+
seq: row.seq,
|
|
208
|
+
kind: row.kind,
|
|
209
|
+
at: row.at,
|
|
210
|
+
snippet: row.body.slice(0, 120),
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/user.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The User backend Contribution: the one place a User's transcript index lives.
|
|
2
|
+
//
|
|
3
|
+
// It is mounted into the User Durable Object's Cordis root beside Settings,
|
|
4
|
+
// Credentials, Flock, and the Package Publisher, and it owns exactly one thing
|
|
5
|
+
// — a `SearchIndexV1` over that object's own SQL storage.
|
|
6
|
+
//
|
|
7
|
+
// Two seams it does not own, and takes as host functions instead:
|
|
8
|
+
//
|
|
9
|
+
// * the Bot directory, because the Flock Contribution is the authority for
|
|
10
|
+
// which Bots exist and which are archived, and a lifecycle answer copied
|
|
11
|
+
// into the index would go stale the moment a Bot is archived;
|
|
12
|
+
// * the row source, because the rows are projections of runs the *Bot*
|
|
13
|
+
// Durable Object holds, and a rebuild must read them from that authority
|
|
14
|
+
// rather than from anything this object remembers.
|
|
15
|
+
import type { Plugin } from "cordis";
|
|
16
|
+
import {
|
|
17
|
+
SEARCH_REBUILD_PAGE_V1,
|
|
18
|
+
SearchIndexV1,
|
|
19
|
+
type SearchRebuildOutcomeV1,
|
|
20
|
+
type SearchRowSourceV1,
|
|
21
|
+
type SearchSqlV1,
|
|
22
|
+
} from "./index-store.js";
|
|
23
|
+
import {
|
|
24
|
+
decodeSearchQueryV1,
|
|
25
|
+
decodeSearchRowPageV1,
|
|
26
|
+
decodeSearchRowV1,
|
|
27
|
+
type SearchIndexResultsV1,
|
|
28
|
+
type SearchIndexStateV1,
|
|
29
|
+
type SearchRowV1,
|
|
30
|
+
} from "./shared.js";
|
|
31
|
+
|
|
32
|
+
export interface SearchUserBackendHost {
|
|
33
|
+
/** The User Durable Object's own SQL storage. */
|
|
34
|
+
sql: SearchSqlV1;
|
|
35
|
+
/** Every Bot this User has, with the archived ones named. */
|
|
36
|
+
readDirectory(): Promise<{
|
|
37
|
+
botIds: readonly string[];
|
|
38
|
+
archivedBotIds: readonly string[];
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* One page of a Bot's projected rows, read from that Bot's Durable Object.
|
|
42
|
+
* The answer is decoded here: it is inbound from another runtime.
|
|
43
|
+
*/
|
|
44
|
+
projectBotRows(botId: string, cursor?: string): Promise<unknown>;
|
|
45
|
+
/** Overridable so a test can drive quota eviction. */
|
|
46
|
+
maxRows?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class SearchUserBackendContribution {
|
|
50
|
+
readonly packageId = "search";
|
|
51
|
+
private readonly index: SearchIndexV1;
|
|
52
|
+
|
|
53
|
+
constructor(private readonly host: SearchUserBackendHost) {
|
|
54
|
+
this.index = new SearchIndexV1({
|
|
55
|
+
sql: host.sql,
|
|
56
|
+
...(host.maxRows === undefined ? {} : { maxRows: host.maxRows }),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Idempotent on `(botId, runId, seq)`; a re-projected Turn adds nothing. */
|
|
61
|
+
async indexRows(input: unknown): Promise<{ indexed: number }> {
|
|
62
|
+
if (!Array.isArray(input)) throw new Error("search rows must be an array");
|
|
63
|
+
if (input.length > 512) throw new Error("search rows exceed their bound");
|
|
64
|
+
const rows: SearchRowV1[] = input.map(decodeSearchRowV1);
|
|
65
|
+
return { indexed: this.index.insert(rows) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async search(input: unknown): Promise<SearchIndexResultsV1> {
|
|
69
|
+
const query = decodeSearchQueryV1(input);
|
|
70
|
+
const directory = await this.host.readDirectory();
|
|
71
|
+
return this.index.query(query, {
|
|
72
|
+
archivedBotIds: directory.archivedBotIds,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Every row of one Bot leaves the index. The archive saga calls this. */
|
|
77
|
+
purge(botId: string): { removed: number } {
|
|
78
|
+
return { removed: this.index.purge(botId) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
state(): SearchIndexStateV1 {
|
|
82
|
+
return this.index.state();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Reconstructs the whole index from the Bots' own stored runs.
|
|
87
|
+
*
|
|
88
|
+
* This is what makes the index disposable rather than authoritative: it can
|
|
89
|
+
* be thrown away and rebuilt, and the rebuilt table is the same table.
|
|
90
|
+
*/
|
|
91
|
+
async rebuild(): Promise<SearchRebuildOutcomeV1> {
|
|
92
|
+
const directory = await this.host.readDirectory();
|
|
93
|
+
const sources: SearchRowSourceV1[] = directory.botIds.map((botId) => ({
|
|
94
|
+
botId,
|
|
95
|
+
page: async (cursor) => {
|
|
96
|
+
const page = decodeSearchRowPageV1(
|
|
97
|
+
await this.host.projectBotRows(botId, cursor),
|
|
98
|
+
);
|
|
99
|
+
return {
|
|
100
|
+
rows: page.rows.slice(0, SEARCH_REBUILD_PAGE_V1 * 8),
|
|
101
|
+
...(page.nextCursor === undefined
|
|
102
|
+
? {}
|
|
103
|
+
: { nextCursor: page.nextCursor }),
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
}));
|
|
107
|
+
return this.index.rebuild(sources);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createSearchUserBackendPlugin(
|
|
112
|
+
host: SearchUserBackendHost,
|
|
113
|
+
lifecycle: { mount(value: SearchUserBackendContribution): () => void },
|
|
114
|
+
): Plugin {
|
|
115
|
+
return () => lifecycle.mount(new SearchUserBackendContribution(host));
|
|
116
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
12
|
+
"types": ["bun", "vite/client"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts", "src/**/*.vue"]
|
|
15
|
+
}
|
package/README.md
DELETED