@cosmicdrift/kumiko-framework 0.154.1 → 0.155.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/package.json +2 -2
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/api/__tests__/batch.integration.test.ts +9 -9
- package/src/engine/__tests__/engine.test.ts +16 -0
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/post-query-hook.test.ts +7 -7
- package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
- package/src/engine/define-feature.ts +23 -5
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +8 -7
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +22 -8
- package/src/engine/feature-ast/__tests__/patcher.test.ts +8 -8
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +210 -4
- package/src/engine/feature-ast/extractors/index.ts +0 -2
- package/src/engine/feature-ast/extractors/round4.ts +21 -151
- package/src/engine/feature-ast/index.ts +0 -2
- package/src/engine/feature-ast/parse.ts +0 -3
- package/src/engine/feature-ast/patch.ts +42 -11
- package/src/engine/feature-ast/patcher.ts +4 -20
- package/src/engine/feature-ast/patterns.ts +10 -22
- package/src/engine/feature-ast/render.ts +7 -14
- package/src/engine/feature-config-events-jobs.ts +74 -24
- package/src/engine/feature-entity-handlers.ts +26 -21
- package/src/engine/feature-ui-extensions.ts +116 -45
- package/src/engine/object-form.ts +12 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +0 -9
- package/src/engine/pattern-library/library.ts +0 -2
- package/src/engine/pattern-library/mixed-schemas.ts +1 -45
- package/src/engine/pattern-library/shared-fields.ts +1 -6
- package/src/engine/registry-ingest.ts +7 -2
- package/src/engine/types/feature.ts +58 -34
- package/src/engine/types/nav.ts +4 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +66 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +200 -185
- package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
- package/src/search/meilisearch-adapter.ts +13 -12
- package/src/engine/__tests__/define-feature-crud.test.ts +0 -55
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { generateId as uuid } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
3
|
import { Meilisearch } from "meilisearch";
|
|
5
|
-
import { createMeilisearchAdapter } from "../meilisearch-adapter";
|
|
4
|
+
import { createMeilisearchAdapter, meilisearchTenantIndex } from "../meilisearch-adapter";
|
|
6
5
|
import type { SearchAdapter } from "../types";
|
|
7
6
|
|
|
8
7
|
const MEILI_URL = process.env["MEILI_URL"] ?? "http://localhost:17700";
|
|
9
8
|
const MEILI_KEY = process.env["MEILI_MASTER_KEY"] ?? "kumiko-dev-key";
|
|
10
9
|
|
|
10
|
+
async function meiliAvailable(): Promise<boolean> {
|
|
11
|
+
try {
|
|
12
|
+
const health = await fetch(`${MEILI_URL}/health`);
|
|
13
|
+
return health.ok;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// skipIf when compose Meili is down — unit tests still cover index/doc id shaping.
|
|
20
|
+
const MEILI_UP = await meiliAvailable();
|
|
21
|
+
if (!MEILI_UP) {
|
|
22
|
+
console.warn(
|
|
23
|
+
`Meilisearch not reachable at ${MEILI_URL} — skipping live adapter tests ` +
|
|
24
|
+
`(docker compose up -d meilisearch, default port 17700)`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
11
28
|
// Use a fake tenantId to get a unique index name
|
|
12
29
|
const TENANT = uuid();
|
|
13
30
|
|
|
@@ -15,222 +32,220 @@ let adapter: SearchAdapter;
|
|
|
15
32
|
let client: Meilisearch;
|
|
16
33
|
let indexPrefix: string;
|
|
17
34
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
apiKey: MEILI_KEY,
|
|
28
|
-
indexPrefix,
|
|
29
|
-
});
|
|
35
|
+
describe.skipIf(!MEILI_UP)("meilisearch adapter (live)", () => {
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
client = new Meilisearch({ host: MEILI_URL, apiKey: MEILI_KEY });
|
|
38
|
+
indexPrefix = `test_${uuid().slice(-6)}_`;
|
|
39
|
+
adapter = createMeilisearchAdapter({
|
|
40
|
+
url: MEILI_URL,
|
|
41
|
+
apiKey: MEILI_KEY,
|
|
42
|
+
indexPrefix,
|
|
43
|
+
});
|
|
30
44
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
await adapter.configure(TENANT, {
|
|
46
|
+
searchableFields: ["email", "firstName", "lastName", "notes", "_roles"],
|
|
47
|
+
rankingFields: ["email", "firstName", "lastName", "notes", "_roles"],
|
|
48
|
+
});
|
|
35
49
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
50
|
+
// Seed data with different entity types and weights
|
|
51
|
+
await adapter.index(TENANT, {
|
|
52
|
+
entityType: "user",
|
|
53
|
+
entityId: 1,
|
|
54
|
+
weight: 10,
|
|
55
|
+
fields: {
|
|
56
|
+
email: "marc.weber@company.de",
|
|
57
|
+
firstName: "Marc",
|
|
58
|
+
lastName: "Weber",
|
|
59
|
+
notes: "Senior developer",
|
|
60
|
+
_roles: "Admin, Developer",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
await adapter.index(TENANT, {
|
|
64
|
+
entityType: "user",
|
|
65
|
+
entityId: 2,
|
|
66
|
+
weight: 10,
|
|
67
|
+
fields: {
|
|
68
|
+
email: "anna.schmidt@company.de",
|
|
69
|
+
firstName: "Anna",
|
|
70
|
+
lastName: "Schmidt",
|
|
71
|
+
notes: "Project manager",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
await adapter.index(TENANT, {
|
|
75
|
+
entityType: "user",
|
|
76
|
+
entityId: 3,
|
|
77
|
+
weight: 10,
|
|
78
|
+
fields: {
|
|
79
|
+
email: "admin@company.de",
|
|
80
|
+
firstName: "Admin",
|
|
81
|
+
lastName: "User",
|
|
82
|
+
notes: "System administrator",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
await adapter.index(TENANT, {
|
|
86
|
+
entityType: "role",
|
|
87
|
+
entityId: 1,
|
|
88
|
+
weight: 1,
|
|
89
|
+
fields: { firstName: "Admin" },
|
|
90
|
+
});
|
|
91
|
+
await adapter.index(TENANT, {
|
|
92
|
+
entityType: "role",
|
|
93
|
+
entityId: 2,
|
|
94
|
+
weight: 1,
|
|
95
|
+
fields: { firstName: "Developer" },
|
|
96
|
+
});
|
|
97
|
+
await adapter.index(TENANT, {
|
|
98
|
+
entityType: "department",
|
|
99
|
+
entityId: 1,
|
|
100
|
+
weight: 5,
|
|
101
|
+
fields: { firstName: "Engineering" },
|
|
102
|
+
});
|
|
88
103
|
});
|
|
89
|
-
});
|
|
90
104
|
|
|
91
|
-
afterAll(async () => {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
afterAll(async () => {
|
|
106
|
+
// Clean up all test indices
|
|
107
|
+
const indices = await client.getIndexes();
|
|
108
|
+
for (const idx of indices.results) {
|
|
109
|
+
if (idx.uid.startsWith("test_")) {
|
|
110
|
+
try {
|
|
111
|
+
await client.index(idx.uid).delete().waitTask();
|
|
112
|
+
} catch {
|
|
113
|
+
/* ok */
|
|
114
|
+
}
|
|
100
115
|
}
|
|
101
116
|
}
|
|
102
|
-
}
|
|
103
|
-
});
|
|
117
|
+
});
|
|
104
118
|
|
|
105
|
-
// --- Basic search ---
|
|
119
|
+
// --- Basic search ---
|
|
106
120
|
|
|
107
|
-
describe("basic search", () => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
121
|
+
describe("basic search", () => {
|
|
122
|
+
test("finds user by name", async () => {
|
|
123
|
+
const results = await adapter.search(TENANT, "anna");
|
|
124
|
+
expect(results.some((r) => r.entityId === 2 && r.entityType === "user")).toBe(true);
|
|
125
|
+
});
|
|
112
126
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
127
|
+
test("returns empty for no match", async () => {
|
|
128
|
+
const results = await adapter.search(TENANT, "zzzznonexistent99999");
|
|
129
|
+
expect(results).toEqual([]);
|
|
130
|
+
});
|
|
116
131
|
});
|
|
117
|
-
});
|
|
118
132
|
|
|
119
|
-
// --- Partial matching ---
|
|
133
|
+
// --- Partial matching ---
|
|
120
134
|
|
|
121
|
-
describe("partial matching", () => {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
135
|
+
describe("partial matching", () => {
|
|
136
|
+
test("finds by prefix", async () => {
|
|
137
|
+
const results = await adapter.search(TENANT, "mar");
|
|
138
|
+
expect(results.some((r) => r.entityId === 1 && r.entityType === "user")).toBe(true);
|
|
139
|
+
});
|
|
125
140
|
});
|
|
126
|
-
});
|
|
127
141
|
|
|
128
|
-
// --- Typo tolerance ---
|
|
142
|
+
// --- Typo tolerance ---
|
|
129
143
|
|
|
130
|
-
describe("typo tolerance", () => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
describe("typo tolerance", () => {
|
|
145
|
+
test("finds despite typos", async () => {
|
|
146
|
+
const results = await adapter.search(TENANT, "schmit");
|
|
147
|
+
expect(results.some((r) => r.entityId === 2)).toBe(true);
|
|
148
|
+
});
|
|
134
149
|
});
|
|
135
|
-
});
|
|
136
150
|
|
|
137
|
-
// --- Filter by entity type (list search) ---
|
|
151
|
+
// --- Filter by entity type (list search) ---
|
|
138
152
|
|
|
139
|
-
describe("list search (filterType)", () => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
153
|
+
describe("list search (filterType)", () => {
|
|
154
|
+
test("only returns specified entity type", async () => {
|
|
155
|
+
const results = await adapter.search(TENANT, "admin", { filterType: "user" });
|
|
156
|
+
expect(results.every((r) => r.entityType === "user")).toBe(true);
|
|
157
|
+
expect(results.length).toBeGreaterThan(0);
|
|
158
|
+
});
|
|
145
159
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
160
|
+
test("role filter returns only roles", async () => {
|
|
161
|
+
const results = await adapter.search(TENANT, "admin", { filterType: "role" });
|
|
162
|
+
expect(results.every((r) => r.entityType === "role")).toBe(true);
|
|
163
|
+
});
|
|
149
164
|
});
|
|
150
|
-
});
|
|
151
165
|
|
|
152
|
-
// --- Global search with weight ---
|
|
166
|
+
// --- Global search with weight ---
|
|
153
167
|
|
|
154
|
-
describe("global search with searchWeight", () => {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
168
|
+
describe("global search with searchWeight", () => {
|
|
169
|
+
test("user (weight 10) ranks before role (weight 1) for same query", async () => {
|
|
170
|
+
const results = await adapter.search(TENANT, "admin");
|
|
171
|
+
const userIdx = results.findIndex((r) => r.entityType === "user");
|
|
172
|
+
const roleIdx = results.findIndex((r) => r.entityType === "role");
|
|
173
|
+
// User should appear before Role due to _weight:desc sort
|
|
174
|
+
if (userIdx >= 0 && roleIdx >= 0) {
|
|
175
|
+
expect(userIdx).toBeLessThan(roleIdx);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
163
178
|
});
|
|
164
|
-
});
|
|
165
179
|
|
|
166
|
-
// --- Resolved relation data ---
|
|
180
|
+
// --- Resolved relation data ---
|
|
167
181
|
|
|
168
|
-
describe("relation data in search", () => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
describe("relation data in search", () => {
|
|
183
|
+
test("finds user by role name in _roles field", async () => {
|
|
184
|
+
const results = await adapter.search(TENANT, "developer", { filterType: "user" });
|
|
185
|
+
expect(results.some((r) => r.entityId === 1)).toBe(true);
|
|
186
|
+
});
|
|
172
187
|
});
|
|
173
|
-
});
|
|
174
188
|
|
|
175
|
-
// --- Remove ---
|
|
189
|
+
// --- Remove ---
|
|
176
190
|
|
|
177
|
-
describe("remove", () => {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
191
|
+
describe("remove", () => {
|
|
192
|
+
test("removed document not found", async () => {
|
|
193
|
+
// Create temp doc
|
|
194
|
+
await adapter.index(TENANT, {
|
|
195
|
+
entityType: "temp",
|
|
196
|
+
entityId: 999,
|
|
197
|
+
weight: 1,
|
|
198
|
+
fields: { firstName: "DeleteMe" },
|
|
199
|
+
});
|
|
200
|
+
let results = await adapter.search(TENANT, "deleteme");
|
|
201
|
+
expect(results.some((r) => r.entityId === 999)).toBe(true);
|
|
188
202
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
203
|
+
await adapter.remove(TENANT, "temp", 999);
|
|
204
|
+
results = await adapter.search(TENANT, "deleteme");
|
|
205
|
+
expect(results.some((r) => r.entityId === 999)).toBe(false);
|
|
206
|
+
});
|
|
192
207
|
});
|
|
193
|
-
});
|
|
194
208
|
|
|
195
|
-
// --- Batch variants ---
|
|
209
|
+
// --- Batch variants ---
|
|
196
210
|
|
|
197
|
-
describe("indexBatch / removeBatch", () => {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const hits = await adapter.search(TENANT, "batchtoken", { limit: 20, filterType: "batch" });
|
|
208
|
-
expect(hits.length).toBe(5);
|
|
209
|
-
const ids = hits.map((h) => h.entityId).sort();
|
|
210
|
-
expect(ids).toEqual([1000, 1001, 1002, 1003, 1004]);
|
|
211
|
-
});
|
|
211
|
+
describe("indexBatch / removeBatch", () => {
|
|
212
|
+
test("indexBatch indexes multiple docs in a single task", async () => {
|
|
213
|
+
const docs = Array.from({ length: 5 }, (_, i) => ({
|
|
214
|
+
entityType: "batch" as const,
|
|
215
|
+
entityId: 1000 + i,
|
|
216
|
+
weight: 1,
|
|
217
|
+
fields: { firstName: `Bulk${i}`, notes: "batchtoken" },
|
|
218
|
+
}));
|
|
219
|
+
await adapter.indexBatch?.(TENANT, docs);
|
|
212
220
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
[1000, 1001, 1002, 1003, 1004]
|
|
217
|
-
);
|
|
218
|
-
const hits = await adapter.search(TENANT, "batchtoken", { limit: 20, filterType: "batch" });
|
|
219
|
-
expect(hits.length).toBe(0);
|
|
220
|
-
});
|
|
221
|
+
const hits = await adapter.search(TENANT, "batchtoken", { limit: 20, filterType: "batch" });
|
|
222
|
+
expect(hits.length).toBe(5);
|
|
223
|
+
const ids = hits.map((h) => h.entityId).sort();
|
|
224
|
+
expect(ids).toEqual([1000, 1001, 1002, 1003, 1004]);
|
|
225
|
+
});
|
|
221
226
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
227
|
+
test("removeBatch removes multiple docs in a single task", async () => {
|
|
228
|
+
await adapter.removeBatch?.(
|
|
229
|
+
TENANT,
|
|
230
|
+
[1000, 1001, 1002, 1003, 1004].map((id) => ({ entityType: "batch", entityId: id })),
|
|
231
|
+
);
|
|
232
|
+
const hits = await adapter.search(TENANT, "batchtoken", { limit: 20, filterType: "batch" });
|
|
233
|
+
expect(hits.length).toBe(0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("indexBatch no-ops on empty array — no Meilisearch task created", async () => {
|
|
237
|
+
// We verify the "no-op" contract by peeking at Meilisearch's own
|
|
238
|
+
// IndexStats.numberOfDocuments + isIndexing directly. If the adapter had
|
|
239
|
+
// accidentally sent an addDocuments request (even with an empty body),
|
|
240
|
+
// isIndexing would flip to true or a task would land in the queue.
|
|
241
|
+
// numberOfDocuments must also stay unchanged — the empty batch must not
|
|
242
|
+
// replace, delete, or otherwise touch existing docs.
|
|
243
|
+
const index = client.index(meilisearchTenantIndex(indexPrefix, TENANT));
|
|
244
|
+
const before = await index.getStats();
|
|
245
|
+
await expect(adapter.indexBatch?.(TENANT, [])).resolves.toBeUndefined();
|
|
246
|
+
const after = await index.getStats();
|
|
247
|
+
expect(after.numberOfDocuments).toBe(before.numberOfDocuments);
|
|
248
|
+
expect(after.isIndexing).toBe(false);
|
|
249
|
+
});
|
|
235
250
|
});
|
|
236
251
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { meilisearchDocId, meilisearchTenantIndex } from "../meilisearch-adapter";
|
|
3
|
+
|
|
4
|
+
describe("meilisearchTenantIndex", () => {
|
|
5
|
+
test("prefixes tenant id with t after the index prefix", () => {
|
|
6
|
+
expect(meilisearchTenantIndex("kumiko_", "00000000-0000-4000-8000-000000000001")).toBe(
|
|
7
|
+
"kumiko_t00000000-0000-4000-8000-000000000001",
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("keeps custom prefixes intact", () => {
|
|
12
|
+
expect(meilisearchTenantIndex("test_abc_", 42 as never)).toBe("test_abc_t42");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("meilisearchDocId", () => {
|
|
17
|
+
test("joins entity type and id with underscore", () => {
|
|
18
|
+
expect(meilisearchDocId("user", 7)).toBe("user_7");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("preserves UUID dashes (legal Meilisearch primary-key chars)", () => {
|
|
22
|
+
expect(meilisearchDocId("order", "a1b2c3d4-e5f6-7890-abcd-ef1234567890" as never)).toBe(
|
|
23
|
+
"order_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("sanitizes illegal primary-key characters to underscore", () => {
|
|
28
|
+
expect(meilisearchDocId("note", "a:b/c.d" as never)).toBe("note_a_b_c_d");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -8,13 +8,14 @@ export type MeilisearchAdapterOptions = {
|
|
|
8
8
|
indexPrefix?: string;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// Exported for unit tests (index-name / primary-key shape) without a live Meili.
|
|
12
|
+
export function meilisearchTenantIndex(prefix: string, tenantId: TenantId): string {
|
|
12
13
|
return `${prefix}t${tenantId}`;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
// Meilisearch primary-key-ids: alphanumerics, `-`, `_`. UUIDs contain `-` —
|
|
16
17
|
// legal. Replace anything else just in case callers pass unexpected shapes.
|
|
17
|
-
function
|
|
18
|
+
export function meilisearchDocId(entityType: string, entityId: EntityId): string {
|
|
18
19
|
return `${entityType}_${String(entityId).replace(/[^0-9A-Za-z_-]/g, "_")}`;
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -24,7 +25,7 @@ export function createMeilisearchAdapter(options: MeilisearchAdapterOptions): Se
|
|
|
24
25
|
|
|
25
26
|
return {
|
|
26
27
|
async configure(tenantId, config) {
|
|
27
|
-
const index = client.index(
|
|
28
|
+
const index = client.index(meilisearchTenantIndex(prefix, tenantId));
|
|
28
29
|
const fields = config.rankingFields ?? config.searchableFields;
|
|
29
30
|
await index.updateSearchableAttributes([...fields]).waitTask();
|
|
30
31
|
await index.updateFilterableAttributes(["_type", "_weight"]).waitTask();
|
|
@@ -32,12 +33,12 @@ export function createMeilisearchAdapter(options: MeilisearchAdapterOptions): Se
|
|
|
32
33
|
},
|
|
33
34
|
|
|
34
35
|
async index(tenantId, doc) {
|
|
35
|
-
const index = client.index(
|
|
36
|
+
const index = client.index(meilisearchTenantIndex(prefix, tenantId));
|
|
36
37
|
await index
|
|
37
38
|
.addDocuments(
|
|
38
39
|
[
|
|
39
40
|
{
|
|
40
|
-
_id:
|
|
41
|
+
_id: meilisearchDocId(doc.entityType, doc.entityId),
|
|
41
42
|
_type: doc.entityType,
|
|
42
43
|
_weight: doc.weight,
|
|
43
44
|
_entityId: doc.entityId,
|
|
@@ -52,9 +53,9 @@ export function createMeilisearchAdapter(options: MeilisearchAdapterOptions): Se
|
|
|
52
53
|
async indexBatch(tenantId, docs) {
|
|
53
54
|
// skip: empty batch — avoid an unnecessary Meilisearch round-trip
|
|
54
55
|
if (docs.length === 0) return;
|
|
55
|
-
const index = client.index(
|
|
56
|
+
const index = client.index(meilisearchTenantIndex(prefix, tenantId));
|
|
56
57
|
const payload = docs.map((doc) => ({
|
|
57
|
-
_id:
|
|
58
|
+
_id: meilisearchDocId(doc.entityType, doc.entityId),
|
|
58
59
|
_type: doc.entityType,
|
|
59
60
|
_weight: doc.weight,
|
|
60
61
|
_entityId: doc.entityId,
|
|
@@ -69,13 +70,13 @@ export function createMeilisearchAdapter(options: MeilisearchAdapterOptions): Se
|
|
|
69
70
|
async removeBatch(tenantId, items) {
|
|
70
71
|
// skip: empty batch — avoid an unnecessary Meilisearch round-trip
|
|
71
72
|
if (items.length === 0) return;
|
|
72
|
-
const index = client.index(
|
|
73
|
-
const ids = items.map((i) =>
|
|
73
|
+
const index = client.index(meilisearchTenantIndex(prefix, tenantId));
|
|
74
|
+
const ids = items.map((i) => meilisearchDocId(i.entityType, i.entityId));
|
|
74
75
|
await index.deleteDocuments(ids).waitTask();
|
|
75
76
|
},
|
|
76
77
|
|
|
77
78
|
async search(tenantId, query, options) {
|
|
78
|
-
const index = client.index(
|
|
79
|
+
const index = client.index(meilisearchTenantIndex(prefix, tenantId));
|
|
79
80
|
|
|
80
81
|
const filter: string[] = [];
|
|
81
82
|
if (options?.filterType) {
|
|
@@ -99,8 +100,8 @@ export function createMeilisearchAdapter(options: MeilisearchAdapterOptions): Se
|
|
|
99
100
|
},
|
|
100
101
|
|
|
101
102
|
async remove(tenantId, entityType, entityId) {
|
|
102
|
-
const index = client.index(
|
|
103
|
-
await index.deleteDocument(
|
|
103
|
+
const index = client.index(meilisearchTenantIndex(prefix, tenantId));
|
|
104
|
+
await index.deleteDocument(meilisearchDocId(entityType, entityId)).waitTask();
|
|
104
105
|
},
|
|
105
106
|
};
|
|
106
107
|
}
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { defineFeature } from "../define-feature";
|
|
3
|
-
import { registerEntityCrud } from "../entity-handlers";
|
|
4
|
-
import { createEntity, createTextField } from "../factories";
|
|
5
|
-
|
|
6
|
-
const taskEntity = createEntity({
|
|
7
|
-
table: "crud_sugar_tasks",
|
|
8
|
-
fields: {
|
|
9
|
-
title: createTextField({ required: true }),
|
|
10
|
-
},
|
|
11
|
-
softDelete: true,
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
const access = {
|
|
15
|
-
write: { access: { roles: ["Admin"] } },
|
|
16
|
-
read: { access: { openToAll: true } },
|
|
17
|
-
} as const;
|
|
18
|
-
|
|
19
|
-
describe("r.crud", () => {
|
|
20
|
-
test("registers the same entity + handlers as registerEntityCrud", () => {
|
|
21
|
-
const viaCrud = defineFeature("crud-sugar", (r) => {
|
|
22
|
-
r.crud("task", taskEntity, access);
|
|
23
|
-
});
|
|
24
|
-
const viaHelper = defineFeature("crud-sugar", (r) => {
|
|
25
|
-
registerEntityCrud(r, "task", taskEntity, access);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
expect(viaCrud.entities).toEqual(viaHelper.entities);
|
|
29
|
-
expect(Object.keys(viaCrud.writeHandlers)).toEqual(Object.keys(viaHelper.writeHandlers));
|
|
30
|
-
expect(Object.keys(viaCrud.queryHandlers)).toEqual(Object.keys(viaHelper.queryHandlers));
|
|
31
|
-
expect(Object.keys(viaCrud.writeHandlers)).toEqual([
|
|
32
|
-
"task:create",
|
|
33
|
-
"task:update",
|
|
34
|
-
"task:delete",
|
|
35
|
-
"task:restore",
|
|
36
|
-
]);
|
|
37
|
-
expect(Object.keys(viaCrud.queryHandlers)).toEqual(["task:list", "task:detail"]);
|
|
38
|
-
expect(viaCrud.writeHandlers["task:create"]?.access).toEqual({ roles: ["Admin"] });
|
|
39
|
-
expect(viaCrud.queryHandlers["task:list"]?.access).toEqual({ openToAll: true });
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("returns an EntityRef like r.entity", () => {
|
|
43
|
-
defineFeature("crud-sugar", (r) => {
|
|
44
|
-
const ref = r.crud("task", taskEntity, access);
|
|
45
|
-
expect(ref).toEqual({ name: "task", table: "crud_sugar_tasks" });
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("verbs opt-out skips handlers", () => {
|
|
50
|
-
const feature = defineFeature("crud-sugar", (r) => {
|
|
51
|
-
r.crud("task", taskEntity, { ...access, verbs: { delete: false, restore: false } });
|
|
52
|
-
});
|
|
53
|
-
expect(Object.keys(feature.writeHandlers)).toEqual(["task:create", "task:update"]);
|
|
54
|
-
});
|
|
55
|
-
});
|