@frockbot/plugin-search 0.0.0 → 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/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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/* The Search surface and its sidebar control. */
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The shell gives the search control the flexible half of the sidebar's top
|
|
5
|
+
* row; the Flock's create button occupies the fixed half.
|
|
6
|
+
*/
|
|
7
|
+
.search-box {
|
|
8
|
+
display: flex;
|
|
9
|
+
width: 100%;
|
|
10
|
+
height: var(--frock-control-md);
|
|
11
|
+
min-width: 0;
|
|
12
|
+
-webkit-app-region: no-drag;
|
|
13
|
+
gap: 8px;
|
|
14
|
+
align-items: center;
|
|
15
|
+
padding: 0 10px;
|
|
16
|
+
color: var(--frock-text-muted);
|
|
17
|
+
font: inherit;
|
|
18
|
+
text-align: left;
|
|
19
|
+
cursor: pointer;
|
|
20
|
+
background: var(--frock-surface-subtle);
|
|
21
|
+
border: 1px solid var(--frock-border);
|
|
22
|
+
border-radius: var(--frock-radius-control);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.search-box:hover {
|
|
26
|
+
color: var(--frock-text);
|
|
27
|
+
background: var(--frock-fill-hover);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.search-box:focus-visible {
|
|
31
|
+
outline: var(--frock-focus-ring);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.search-box-label {
|
|
35
|
+
overflow: hidden;
|
|
36
|
+
flex: 1 1 auto;
|
|
37
|
+
font-size: var(--frock-text-sm);
|
|
38
|
+
white-space: nowrap;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/*
|
|
42
|
+
* On a phone the control is the magnifier and nothing else. The word and the
|
|
43
|
+
* shortcut cost about a third of the topbar, which is the room the Bot's name
|
|
44
|
+
* needs — and a keyboard shortcut is a promise a touch device cannot keep. The
|
|
45
|
+
* button keeps its accessible name either way.
|
|
46
|
+
*/
|
|
47
|
+
@media (max-width: 640px) {
|
|
48
|
+
.search-box {
|
|
49
|
+
padding: 6px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.search-box-label,
|
|
53
|
+
.search-box-shortcut {
|
|
54
|
+
display: none;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.search-surface {
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
gap: 12px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.search-input-row {
|
|
65
|
+
display: flex;
|
|
66
|
+
gap: 8px;
|
|
67
|
+
align-items: center;
|
|
68
|
+
padding: 8px 10px;
|
|
69
|
+
color: var(--frock-text-muted);
|
|
70
|
+
background: var(--frock-surface-subtle);
|
|
71
|
+
border: 1px solid var(--frock-border-strong);
|
|
72
|
+
border-radius: var(--frock-radius-control);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.search-input {
|
|
76
|
+
flex: 1 1 auto;
|
|
77
|
+
min-width: 0;
|
|
78
|
+
font-size: var(--frock-text-base);
|
|
79
|
+
color: var(--frock-text);
|
|
80
|
+
background: transparent;
|
|
81
|
+
border: 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.search-input:focus {
|
|
85
|
+
outline: none;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.search-filters {
|
|
89
|
+
display: flex;
|
|
90
|
+
gap: 12px;
|
|
91
|
+
align-items: center;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.search-filter {
|
|
95
|
+
display: inline-flex;
|
|
96
|
+
gap: 6px;
|
|
97
|
+
align-items: center;
|
|
98
|
+
font-size: var(--frock-text-sm);
|
|
99
|
+
color: var(--frock-text-muted);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.search-rebuild {
|
|
103
|
+
margin-left: auto;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.search-note {
|
|
107
|
+
padding: 8px 10px;
|
|
108
|
+
font-size: var(--frock-text-sm);
|
|
109
|
+
color: var(--frock-text-muted);
|
|
110
|
+
background: var(--frock-surface-subtle);
|
|
111
|
+
border-radius: var(--frock-radius-control);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.search-error {
|
|
115
|
+
padding: 8px 10px;
|
|
116
|
+
font-size: var(--frock-text-sm);
|
|
117
|
+
color: var(--frock-danger-text);
|
|
118
|
+
background: var(--frock-danger-surface);
|
|
119
|
+
border: 1px solid var(--frock-danger-border);
|
|
120
|
+
border-radius: var(--frock-radius-control);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.search-empty {
|
|
124
|
+
padding: 24px 8px;
|
|
125
|
+
font-size: var(--frock-text-sm);
|
|
126
|
+
color: var(--frock-text-subtle);
|
|
127
|
+
text-align: center;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.search-groups,
|
|
131
|
+
.search-hits {
|
|
132
|
+
display: flex;
|
|
133
|
+
flex-direction: column;
|
|
134
|
+
gap: 8px;
|
|
135
|
+
padding: 0;
|
|
136
|
+
margin: 0;
|
|
137
|
+
list-style: none;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.search-group {
|
|
141
|
+
display: flex;
|
|
142
|
+
flex-direction: column;
|
|
143
|
+
gap: 6px;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.search-group-head {
|
|
147
|
+
display: flex;
|
|
148
|
+
gap: 8px;
|
|
149
|
+
align-items: center;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.search-group-avatar {
|
|
153
|
+
display: inline-flex;
|
|
154
|
+
flex: 0 0 auto;
|
|
155
|
+
align-items: center;
|
|
156
|
+
justify-content: center;
|
|
157
|
+
width: var(--frock-avatar-sm);
|
|
158
|
+
height: var(--frock-avatar-sm);
|
|
159
|
+
overflow: hidden;
|
|
160
|
+
object-fit: cover;
|
|
161
|
+
border-radius: 9px;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.search-group-monogram {
|
|
165
|
+
font-size: var(--frock-text-sm);
|
|
166
|
+
color: var(--frock-accent-text);
|
|
167
|
+
background: var(--frock-surface-accent);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.search-group-name {
|
|
171
|
+
font-size: var(--frock-text-sm);
|
|
172
|
+
color: var(--frock-text);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.search-tag {
|
|
176
|
+
padding: 1px 6px;
|
|
177
|
+
font-size: var(--frock-text-xs);
|
|
178
|
+
color: var(--frock-text-subtle);
|
|
179
|
+
background: var(--frock-surface-subtle);
|
|
180
|
+
border: 1px solid var(--frock-border);
|
|
181
|
+
border-radius: 999px;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.search-group-count {
|
|
185
|
+
margin-left: auto;
|
|
186
|
+
font-size: var(--frock-text-xs);
|
|
187
|
+
color: var(--frock-text-subtle);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.search-hit {
|
|
191
|
+
display: flex;
|
|
192
|
+
flex-direction: column;
|
|
193
|
+
gap: 3px;
|
|
194
|
+
width: 100%;
|
|
195
|
+
padding: 8px 10px;
|
|
196
|
+
text-align: left;
|
|
197
|
+
cursor: pointer;
|
|
198
|
+
background: var(--frock-surface-subtle);
|
|
199
|
+
border: 1px solid var(--frock-border);
|
|
200
|
+
border-radius: var(--frock-radius-control);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.search-hit:hover {
|
|
204
|
+
background: var(--frock-fill-hover);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.search-hit:focus-visible {
|
|
208
|
+
outline: var(--frock-focus-ring);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.search-hit-meta {
|
|
212
|
+
display: flex;
|
|
213
|
+
gap: 8px;
|
|
214
|
+
font-size: var(--frock-text-xs);
|
|
215
|
+
color: var(--frock-text-subtle);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.search-hit-kind {
|
|
219
|
+
color: var(--frock-text-muted);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.search-hit-snippet {
|
|
223
|
+
font-size: var(--frock-text-sm);
|
|
224
|
+
color: var(--frock-text);
|
|
225
|
+
}
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { groupSearchHitsV1, type SearchBotDescriptorV1 } from "./groups.ts";
|
|
3
|
+
import {
|
|
4
|
+
decodeClientSearchResultsV1,
|
|
5
|
+
type SearchIndexResultsV1,
|
|
6
|
+
} from "./shared.ts";
|
|
7
|
+
|
|
8
|
+
const AT = "2026-08-31T00:00:00.000Z";
|
|
9
|
+
|
|
10
|
+
function results(
|
|
11
|
+
hits: Array<{ botId: string; runId: string }>,
|
|
12
|
+
overrides: Partial<SearchIndexResultsV1> = {},
|
|
13
|
+
): SearchIndexResultsV1 {
|
|
14
|
+
return {
|
|
15
|
+
schemaVersion: 1,
|
|
16
|
+
query: "gym",
|
|
17
|
+
hits: hits.map((hit) => ({
|
|
18
|
+
botId: hit.botId,
|
|
19
|
+
runId: hit.runId,
|
|
20
|
+
kind: "user" as const,
|
|
21
|
+
at: AT,
|
|
22
|
+
snippet: "the gym build",
|
|
23
|
+
})),
|
|
24
|
+
truncated: false,
|
|
25
|
+
indexState: "ready",
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DIRECTORY: SearchBotDescriptorV1[] = [
|
|
31
|
+
{ botId: "bot-a", name: "Site foreman", archived: false, hidden: false },
|
|
32
|
+
{ botId: "bot-b", name: "Bookkeeper", archived: false, hidden: true },
|
|
33
|
+
{ botId: "bot-c", name: "Old hand", archived: true, hidden: false },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
describe("grouping hits by Bot", () => {
|
|
37
|
+
test("keeps rank order for groups and for hits inside a group", () => {
|
|
38
|
+
const grouped = groupSearchHitsV1(
|
|
39
|
+
results([
|
|
40
|
+
{ botId: "bot-b", runId: "run-1" },
|
|
41
|
+
{ botId: "bot-a", runId: "run-2" },
|
|
42
|
+
{ botId: "bot-b", runId: "run-3" },
|
|
43
|
+
]),
|
|
44
|
+
DIRECTORY,
|
|
45
|
+
);
|
|
46
|
+
expect(grouped.groups.map((group) => group.botId)).toEqual([
|
|
47
|
+
"bot-b",
|
|
48
|
+
"bot-a",
|
|
49
|
+
]);
|
|
50
|
+
expect(grouped.groups[0]!.hits.map((hit) => hit.runId)).toEqual([
|
|
51
|
+
"run-1",
|
|
52
|
+
"run-3",
|
|
53
|
+
]);
|
|
54
|
+
expect(grouped.groups[0]!.totalHits).toBe(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("names each Bot from the live directory and labels hidden and archived", () => {
|
|
58
|
+
const grouped = groupSearchHitsV1(
|
|
59
|
+
results([
|
|
60
|
+
{ botId: "bot-b", runId: "run-1" },
|
|
61
|
+
{ botId: "bot-c", runId: "run-2" },
|
|
62
|
+
]),
|
|
63
|
+
DIRECTORY,
|
|
64
|
+
);
|
|
65
|
+
expect(grouped.groups[0]).toMatchObject({
|
|
66
|
+
botName: "Bookkeeper",
|
|
67
|
+
hidden: true,
|
|
68
|
+
archived: false,
|
|
69
|
+
});
|
|
70
|
+
expect(grouped.groups[1]).toMatchObject({
|
|
71
|
+
botName: "Old hand",
|
|
72
|
+
archived: true,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("builds the deep link so no client assembles one", () => {
|
|
77
|
+
const grouped = groupSearchHitsV1(
|
|
78
|
+
results([{ botId: "bot-a", runId: "run-2" }]),
|
|
79
|
+
DIRECTORY,
|
|
80
|
+
);
|
|
81
|
+
expect(grouped.groups[0]!.hits[0]!.deepLink).toBe("/?bot=bot-a#turn-run-2");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("keeps a hit whose Bot the directory no longer describes", () => {
|
|
85
|
+
const grouped = groupSearchHitsV1(
|
|
86
|
+
results([{ botId: "bot-gone", runId: "run-9" }]),
|
|
87
|
+
DIRECTORY,
|
|
88
|
+
);
|
|
89
|
+
expect(grouped.groups[0]).toMatchObject({
|
|
90
|
+
botId: "bot-gone",
|
|
91
|
+
botName: "bot-gone",
|
|
92
|
+
archived: false,
|
|
93
|
+
hidden: false,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("carries the page and index state through", () => {
|
|
98
|
+
const grouped = groupSearchHitsV1(
|
|
99
|
+
results([{ botId: "bot-a", runId: "run-1" }], {
|
|
100
|
+
truncated: true,
|
|
101
|
+
nextCursor: "p50",
|
|
102
|
+
indexState: "truncated",
|
|
103
|
+
}),
|
|
104
|
+
DIRECTORY,
|
|
105
|
+
);
|
|
106
|
+
expect(grouped.page).toEqual({ truncated: true, nextCursor: "p50" });
|
|
107
|
+
expect(grouped.indexState).toBe("truncated");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("produces a value the client decoder accepts unchanged", () => {
|
|
111
|
+
const grouped = groupSearchHitsV1(
|
|
112
|
+
results([
|
|
113
|
+
{ botId: "bot-a", runId: "run-1" },
|
|
114
|
+
{ botId: "bot-c", runId: "run-2" },
|
|
115
|
+
]),
|
|
116
|
+
[
|
|
117
|
+
...DIRECTORY,
|
|
118
|
+
{
|
|
119
|
+
botId: "bot-a",
|
|
120
|
+
name: "Site foreman",
|
|
121
|
+
archived: false,
|
|
122
|
+
hidden: false,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
);
|
|
126
|
+
expect(
|
|
127
|
+
decodeClientSearchResultsV1(JSON.parse(JSON.stringify(grouped))),
|
|
128
|
+
).toEqual(grouped);
|
|
129
|
+
});
|
|
130
|
+
});
|
package/src/groups.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Hits, grouped into the shape a person reads.
|
|
2
|
+
//
|
|
3
|
+
// The index answers with a flat, ranked list of `(botId, runId)` hits and
|
|
4
|
+
// knows nothing about Bots beyond their ids — deliberately, because Bot
|
|
5
|
+
// identity is the Flock Contribution's authority and a name copied into a row
|
|
6
|
+
// would be stale the moment a Bot is renamed. This module is the join: it
|
|
7
|
+
// reads the live directory and turns hits into groups the WebUI renders.
|
|
8
|
+
//
|
|
9
|
+
// Pure, so it is its own test surface. It is the only place a deep link is
|
|
10
|
+
// built, so a client never assembles one from parts it might get wrong.
|
|
11
|
+
import {
|
|
12
|
+
searchDeepLinkV1,
|
|
13
|
+
SEARCH_MAX_GROUPS_V1,
|
|
14
|
+
type ClientSearchBotGroupV1,
|
|
15
|
+
type ClientSearchResultsV1,
|
|
16
|
+
type SearchIndexResultsV1,
|
|
17
|
+
} from "./shared.js";
|
|
18
|
+
|
|
19
|
+
/** One Bot, as the live directory describes it at query time. */
|
|
20
|
+
export interface SearchBotDescriptorV1 {
|
|
21
|
+
botId: string;
|
|
22
|
+
name: string;
|
|
23
|
+
archived: boolean;
|
|
24
|
+
/** A Bot its own settings keep out of the sidebar. Searchable, and labelled. */
|
|
25
|
+
hidden: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Groups a page of hits by Bot, newest-ranked group first.
|
|
30
|
+
*
|
|
31
|
+
* Rank order is preserved twice over: groups appear in the order their best
|
|
32
|
+
* hit did, and hits keep their order inside a group. A Bot the directory no
|
|
33
|
+
* longer describes still gets a group, labelled by its id: the alternative is
|
|
34
|
+
* dropping a hit the index legitimately holds because a rename raced a query.
|
|
35
|
+
*/
|
|
36
|
+
export function groupSearchHitsV1(
|
|
37
|
+
results: SearchIndexResultsV1,
|
|
38
|
+
directory: readonly SearchBotDescriptorV1[],
|
|
39
|
+
): ClientSearchResultsV1 {
|
|
40
|
+
const described = new Map(directory.map((bot) => [bot.botId, bot]));
|
|
41
|
+
const groups = new Map<string, ClientSearchBotGroupV1>();
|
|
42
|
+
for (const hit of results.hits) {
|
|
43
|
+
let group = groups.get(hit.botId);
|
|
44
|
+
if (!group) {
|
|
45
|
+
if (groups.size >= SEARCH_MAX_GROUPS_V1) continue;
|
|
46
|
+
const bot = described.get(hit.botId);
|
|
47
|
+
group = {
|
|
48
|
+
botId: hit.botId,
|
|
49
|
+
botName: bot?.name ?? hit.botId,
|
|
50
|
+
archived: bot?.archived ?? false,
|
|
51
|
+
hidden: bot?.hidden ?? false,
|
|
52
|
+
hits: [],
|
|
53
|
+
totalHits: 0,
|
|
54
|
+
};
|
|
55
|
+
groups.set(hit.botId, group);
|
|
56
|
+
}
|
|
57
|
+
group.hits.push({
|
|
58
|
+
runId: hit.runId,
|
|
59
|
+
kind: hit.kind,
|
|
60
|
+
at: hit.at,
|
|
61
|
+
snippet: hit.snippet,
|
|
62
|
+
deepLink: searchDeepLinkV1(hit.botId, hit.runId),
|
|
63
|
+
});
|
|
64
|
+
group.totalHits += 1;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
schemaVersion: 1,
|
|
68
|
+
query: results.query,
|
|
69
|
+
groups: [...groups.values()],
|
|
70
|
+
page: {
|
|
71
|
+
truncated: results.truncated,
|
|
72
|
+
...(results.nextCursor === undefined
|
|
73
|
+
? {}
|
|
74
|
+
: { nextCursor: results.nextCursor }),
|
|
75
|
+
},
|
|
76
|
+
indexState: results.indexState,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SearchIndexV1, searchMatchExpressionV1 } from "./index-store.ts";
|
|
3
|
+
import { FakeSearchSql } from "./testing.ts";
|
|
4
|
+
import type { SearchRowV1 } from "./shared.ts";
|
|
5
|
+
|
|
6
|
+
function row(overrides: Partial<SearchRowV1> = {}): SearchRowV1 {
|
|
7
|
+
return {
|
|
8
|
+
botId: "bot-a",
|
|
9
|
+
runId: "run-1",
|
|
10
|
+
seq: 0,
|
|
11
|
+
kind: "user",
|
|
12
|
+
at: "2026-08-31T00:00:00.000Z",
|
|
13
|
+
body: "the gym build in Wollongong",
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function index(maxRows?: number) {
|
|
19
|
+
const sql = new FakeSearchSql();
|
|
20
|
+
return {
|
|
21
|
+
sql,
|
|
22
|
+
index: new SearchIndexV1({
|
|
23
|
+
sql,
|
|
24
|
+
...(maxRows === undefined ? {} : { maxRows }),
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NO_ARCHIVED = { archivedBotIds: [] as string[] };
|
|
30
|
+
|
|
31
|
+
describe("the FTS5 match expression", () => {
|
|
32
|
+
test("quotes every token, so FTS5 syntax is searched for and never executed", () => {
|
|
33
|
+
expect(searchMatchExpressionV1("gym build")).toBe('"gym" "build"*');
|
|
34
|
+
// `NEAR`, `OR`, `-`, `^` and a column filter are all literal text here.
|
|
35
|
+
expect(searchMatchExpressionV1("NEAR(a b) OR -c^")).toBe(
|
|
36
|
+
'"NEAR" "a" "b" "OR" "c"*',
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a query with no word characters matches nothing at all", () => {
|
|
41
|
+
expect(searchMatchExpressionV1(" * ")).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("the transcript index", () => {
|
|
46
|
+
test("indexes a row and finds it", () => {
|
|
47
|
+
const { index: store } = index();
|
|
48
|
+
expect(store.insert([row()])).toBe(1);
|
|
49
|
+
const results = store.query(
|
|
50
|
+
{ schemaVersion: 1, query: "wollongong" },
|
|
51
|
+
NO_ARCHIVED,
|
|
52
|
+
);
|
|
53
|
+
expect(results.hits).toHaveLength(1);
|
|
54
|
+
expect(results.hits[0]).toMatchObject({ botId: "bot-a", runId: "run-1" });
|
|
55
|
+
expect(results.indexState).toBe("ready");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("is idempotent on (botId, runId, seq)", () => {
|
|
59
|
+
const { index: store } = index();
|
|
60
|
+
expect(store.insert([row(), row()])).toBe(1);
|
|
61
|
+
expect(store.insert([row()])).toBe(0);
|
|
62
|
+
expect(store.count()).toBe(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("excludes tool rows by default and returns them when asked", () => {
|
|
66
|
+
const { index: store } = index();
|
|
67
|
+
store.insert([
|
|
68
|
+
row({ seq: 0, kind: "user", body: "run the deploy" }),
|
|
69
|
+
row({ seq: 1, kind: "tool", body: "shell\ndeploy token abc" }),
|
|
70
|
+
]);
|
|
71
|
+
expect(
|
|
72
|
+
store.query({ schemaVersion: 1, query: "deploy" }, NO_ARCHIVED).hits,
|
|
73
|
+
).toHaveLength(1);
|
|
74
|
+
expect(
|
|
75
|
+
store.query(
|
|
76
|
+
{ schemaVersion: 1, query: "deploy", kinds: ["tool"] },
|
|
77
|
+
NO_ARCHIVED,
|
|
78
|
+
).hits,
|
|
79
|
+
).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("hides an archived Bot's rows unless they are opted in", () => {
|
|
83
|
+
const { index: store } = index();
|
|
84
|
+
store.insert([row({ botId: "bot-archived" })]);
|
|
85
|
+
const directory = { archivedBotIds: ["bot-archived"] };
|
|
86
|
+
expect(
|
|
87
|
+
store.query({ schemaVersion: 1, query: "wollongong" }, directory).hits,
|
|
88
|
+
).toHaveLength(0);
|
|
89
|
+
expect(
|
|
90
|
+
store.query(
|
|
91
|
+
{ schemaVersion: 1, query: "wollongong", includeArchived: true },
|
|
92
|
+
directory,
|
|
93
|
+
).hits,
|
|
94
|
+
).toHaveLength(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("filters to one Bot", () => {
|
|
98
|
+
const { index: store } = index();
|
|
99
|
+
store.insert([row({ botId: "bot-a" }), row({ botId: "bot-b" })]);
|
|
100
|
+
const results = store.query(
|
|
101
|
+
{ schemaVersion: 1, query: "wollongong", botId: "bot-b" },
|
|
102
|
+
NO_ARCHIVED,
|
|
103
|
+
);
|
|
104
|
+
expect(results.hits.map((hit) => hit.botId)).toEqual(["bot-b"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("refuses a cursor it did not mint", () => {
|
|
108
|
+
const { index: store } = index();
|
|
109
|
+
expect(() =>
|
|
110
|
+
store.query(
|
|
111
|
+
{ schemaVersion: 1, query: "gym", before: "'; DROP TABLE" },
|
|
112
|
+
NO_ARCHIVED,
|
|
113
|
+
),
|
|
114
|
+
).toThrow("cursor is invalid");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("evicts the oldest rows over quota and records a durable marker", () => {
|
|
118
|
+
const { index: store } = index(2);
|
|
119
|
+
store.insert([
|
|
120
|
+
row({ runId: "run-1", at: "2026-08-01T00:00:00.000Z" }),
|
|
121
|
+
row({ runId: "run-2", at: "2026-08-02T00:00:00.000Z" }),
|
|
122
|
+
row({ runId: "run-3", at: "2026-08-03T00:00:00.000Z" }),
|
|
123
|
+
]);
|
|
124
|
+
expect(store.count()).toBe(2);
|
|
125
|
+
expect(store.state()).toBe("truncated");
|
|
126
|
+
const results = store.query(
|
|
127
|
+
{ schemaVersion: 1, query: "wollongong" },
|
|
128
|
+
NO_ARCHIVED,
|
|
129
|
+
);
|
|
130
|
+
expect(results.hits.map((hit) => hit.runId)).toEqual(["run-2", "run-3"]);
|
|
131
|
+
expect(results.indexState).toBe("truncated");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("purges one Bot and leaves the others", () => {
|
|
135
|
+
const { index: store } = index();
|
|
136
|
+
store.insert([row({ botId: "bot-a" }), row({ botId: "bot-b" })]);
|
|
137
|
+
expect(store.purge("bot-a")).toBe(1);
|
|
138
|
+
expect(store.count()).toBe(1);
|
|
139
|
+
expect(
|
|
140
|
+
store.query({ schemaVersion: 1, query: "gym" }, NO_ARCHIVED).hits[0]
|
|
141
|
+
?.botId,
|
|
142
|
+
).toBe("bot-b");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a rebuild from an emptied table reproduces the identical result set", async () => {
|
|
146
|
+
const { index: store } = index();
|
|
147
|
+
const rows = [
|
|
148
|
+
row({ botId: "bot-a", runId: "run-1", seq: 0 }),
|
|
149
|
+
row({
|
|
150
|
+
botId: "bot-a",
|
|
151
|
+
runId: "run-1",
|
|
152
|
+
seq: 1,
|
|
153
|
+
kind: "assistant",
|
|
154
|
+
body: "Wollongong noted.",
|
|
155
|
+
}),
|
|
156
|
+
row({ botId: "bot-b", runId: "run-2", seq: 0, body: "the gym roster" }),
|
|
157
|
+
];
|
|
158
|
+
store.insert(rows);
|
|
159
|
+
const before = store.query({ schemaVersion: 1, query: "gym" }, NO_ARCHIVED);
|
|
160
|
+
|
|
161
|
+
const outcome = await store.rebuild([
|
|
162
|
+
{
|
|
163
|
+
botId: "bot-a",
|
|
164
|
+
page: async (cursor) =>
|
|
165
|
+
cursor
|
|
166
|
+
? { rows: rows.slice(1, 2) }
|
|
167
|
+
: { rows: rows.slice(0, 1), nextCursor: "page-2" },
|
|
168
|
+
},
|
|
169
|
+
{ botId: "bot-b", page: async () => ({ rows: rows.slice(2) }) },
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
expect(outcome).toEqual({ indexedRows: 3, bots: 2, indexState: "ready" });
|
|
173
|
+
expect(
|
|
174
|
+
store.query({ schemaVersion: 1, query: "gym" }, NO_ARCHIVED),
|
|
175
|
+
).toEqual(before);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a rebuild clears a truncation marker it no longer needs", async () => {
|
|
179
|
+
const { index: store } = index(1);
|
|
180
|
+
store.insert([
|
|
181
|
+
row({ runId: "run-1", at: "2026-08-01T00:00:00.000Z" }),
|
|
182
|
+
row({ runId: "run-2", at: "2026-08-02T00:00:00.000Z" }),
|
|
183
|
+
]);
|
|
184
|
+
expect(store.state()).toBe("truncated");
|
|
185
|
+
await store.rebuild([
|
|
186
|
+
{
|
|
187
|
+
botId: "bot-a",
|
|
188
|
+
page: async () => ({ rows: [row({ runId: "run-9" })] }),
|
|
189
|
+
},
|
|
190
|
+
]);
|
|
191
|
+
expect(store.state()).toBe("ready");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a rebuild refuses rows a Bot offers on another Bot's behalf", async () => {
|
|
195
|
+
const { index: store } = index();
|
|
196
|
+
await store.rebuild([
|
|
197
|
+
{
|
|
198
|
+
botId: "bot-a",
|
|
199
|
+
page: async () => ({
|
|
200
|
+
rows: [row({ botId: "bot-a" }), row({ botId: "bot-elsewhere" })],
|
|
201
|
+
}),
|
|
202
|
+
},
|
|
203
|
+
]);
|
|
204
|
+
expect(store.count()).toBe(1);
|
|
205
|
+
});
|
|
206
|
+
});
|