@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.
Files changed (41) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +1 -1
  3. package/src/api/__tests__/batch.integration.test.ts +9 -9
  4. package/src/engine/__tests__/engine.test.ts +16 -0
  5. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  6. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  7. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  8. package/src/engine/define-feature.ts +23 -5
  9. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +8 -7
  10. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  11. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  12. package/src/engine/feature-ast/__tests__/parse.test.ts +22 -8
  13. package/src/engine/feature-ast/__tests__/patcher.test.ts +8 -8
  14. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +210 -4
  15. package/src/engine/feature-ast/extractors/index.ts +0 -2
  16. package/src/engine/feature-ast/extractors/round4.ts +21 -151
  17. package/src/engine/feature-ast/index.ts +0 -2
  18. package/src/engine/feature-ast/parse.ts +0 -3
  19. package/src/engine/feature-ast/patch.ts +42 -11
  20. package/src/engine/feature-ast/patcher.ts +4 -20
  21. package/src/engine/feature-ast/patterns.ts +10 -22
  22. package/src/engine/feature-ast/render.ts +7 -14
  23. package/src/engine/feature-config-events-jobs.ts +74 -24
  24. package/src/engine/feature-entity-handlers.ts +26 -21
  25. package/src/engine/feature-ui-extensions.ts +116 -45
  26. package/src/engine/object-form.ts +12 -0
  27. package/src/engine/pattern-library/__tests__/library.test.ts +0 -9
  28. package/src/engine/pattern-library/library.ts +0 -2
  29. package/src/engine/pattern-library/mixed-schemas.ts +1 -45
  30. package/src/engine/pattern-library/shared-fields.ts +1 -6
  31. package/src/engine/registry-ingest.ts +7 -2
  32. package/src/engine/types/feature.ts +58 -34
  33. package/src/engine/types/nav.ts +4 -0
  34. package/src/jobs/__tests__/jobs.integration.test.ts +66 -1
  35. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  36. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  37. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  38. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +200 -185
  39. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  40. package/src/search/meilisearch-adapter.ts +13 -12
  41. 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
- // Mirrors meilisearch-adapter.ts's tenantIndex() used by tests that need
19
- // to talk to Meilisearch directly (e.g. stats before/after a no-op).
20
- const tenantIndex = (prefix: string, tenantId: TenantId): string => `${prefix}t${tenantId}`;
21
-
22
- beforeAll(async () => {
23
- client = new Meilisearch({ host: MEILI_URL, apiKey: MEILI_KEY });
24
- indexPrefix = `test_${uuid().slice(-6)}_`;
25
- adapter = createMeilisearchAdapter({
26
- url: MEILI_URL,
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
- await adapter.configure(TENANT, {
32
- searchableFields: ["email", "firstName", "lastName", "notes", "_roles"],
33
- rankingFields: ["email", "firstName", "lastName", "notes", "_roles"],
34
- });
45
+ await adapter.configure(TENANT, {
46
+ searchableFields: ["email", "firstName", "lastName", "notes", "_roles"],
47
+ rankingFields: ["email", "firstName", "lastName", "notes", "_roles"],
48
+ });
35
49
 
36
- // Seed data with different entity types and weights
37
- await adapter.index(TENANT, {
38
- entityType: "user",
39
- entityId: 1,
40
- weight: 10,
41
- fields: {
42
- email: "marc.weber@company.de",
43
- firstName: "Marc",
44
- lastName: "Weber",
45
- notes: "Senior developer",
46
- _roles: "Admin, Developer",
47
- },
48
- });
49
- await adapter.index(TENANT, {
50
- entityType: "user",
51
- entityId: 2,
52
- weight: 10,
53
- fields: {
54
- email: "anna.schmidt@company.de",
55
- firstName: "Anna",
56
- lastName: "Schmidt",
57
- notes: "Project manager",
58
- },
59
- });
60
- await adapter.index(TENANT, {
61
- entityType: "user",
62
- entityId: 3,
63
- weight: 10,
64
- fields: {
65
- email: "admin@company.de",
66
- firstName: "Admin",
67
- lastName: "User",
68
- notes: "System administrator",
69
- },
70
- });
71
- await adapter.index(TENANT, {
72
- entityType: "role",
73
- entityId: 1,
74
- weight: 1,
75
- fields: { firstName: "Admin" },
76
- });
77
- await adapter.index(TENANT, {
78
- entityType: "role",
79
- entityId: 2,
80
- weight: 1,
81
- fields: { firstName: "Developer" },
82
- });
83
- await adapter.index(TENANT, {
84
- entityType: "department",
85
- entityId: 1,
86
- weight: 5,
87
- fields: { firstName: "Engineering" },
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
- // Clean up all test indices
93
- const indices = await client.getIndexes();
94
- for (const idx of indices.results) {
95
- if (idx.uid.startsWith("test_")) {
96
- try {
97
- await client.index(idx.uid).delete().waitTask();
98
- } catch {
99
- /* ok */
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
- test("finds user by name", async () => {
109
- const results = await adapter.search(TENANT, "anna");
110
- expect(results.some((r) => r.entityId === 2 && r.entityType === "user")).toBe(true);
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
- test("returns empty for no match", async () => {
114
- const results = await adapter.search(TENANT, "zzzznonexistent99999");
115
- expect(results).toEqual([]);
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
- test("finds by prefix", async () => {
123
- const results = await adapter.search(TENANT, "mar");
124
- expect(results.some((r) => r.entityId === 1 && r.entityType === "user")).toBe(true);
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
- test("finds despite typos", async () => {
132
- const results = await adapter.search(TENANT, "schmit");
133
- expect(results.some((r) => r.entityId === 2)).toBe(true);
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
- test("only returns specified entity type", async () => {
141
- const results = await adapter.search(TENANT, "admin", { filterType: "user" });
142
- expect(results.every((r) => r.entityType === "user")).toBe(true);
143
- expect(results.length).toBeGreaterThan(0);
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
- test("role filter returns only roles", async () => {
147
- const results = await adapter.search(TENANT, "admin", { filterType: "role" });
148
- expect(results.every((r) => r.entityType === "role")).toBe(true);
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
- test("user (weight 10) ranks before role (weight 1) for same query", async () => {
156
- const results = await adapter.search(TENANT, "admin");
157
- const userIdx = results.findIndex((r) => r.entityType === "user");
158
- const roleIdx = results.findIndex((r) => r.entityType === "role");
159
- // User should appear before Role due to _weight:desc sort
160
- if (userIdx >= 0 && roleIdx >= 0) {
161
- expect(userIdx).toBeLessThan(roleIdx);
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
- test("finds user by role name in _roles field", async () => {
170
- const results = await adapter.search(TENANT, "developer", { filterType: "user" });
171
- expect(results.some((r) => r.entityId === 1)).toBe(true);
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
- test("removed document not found", async () => {
179
- // Create temp doc
180
- await adapter.index(TENANT, {
181
- entityType: "temp",
182
- entityId: 999,
183
- weight: 1,
184
- fields: { firstName: "DeleteMe" },
185
- });
186
- let results = await adapter.search(TENANT, "deleteme");
187
- expect(results.some((r) => r.entityId === 999)).toBe(true);
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
- await adapter.remove(TENANT, "temp", 999);
190
- results = await adapter.search(TENANT, "deleteme");
191
- expect(results.some((r) => r.entityId === 999)).toBe(false);
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
- test("indexBatch indexes multiple docs in a single task", async () => {
199
- const docs = Array.from({ length: 5 }, (_, i) => ({
200
- entityType: "batch" as const,
201
- entityId: 1000 + i,
202
- weight: 1,
203
- fields: { firstName: `Bulk${i}`, notes: "batchtoken" },
204
- }));
205
- await adapter.indexBatch?.(TENANT, docs);
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
- test("removeBatch removes multiple docs in a single task", async () => {
214
- await adapter.removeBatch?.(
215
- TENANT,
216
- [1000, 1001, 1002, 1003, 1004].map((id) => ({ entityType: "batch", entityId: id })),
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
- test("indexBatch no-ops on empty array no Meilisearch task created", async () => {
223
- // We verify the "no-op" contract by peeking at Meilisearch's own
224
- // IndexStats.numberOfDocuments + isIndexing directly. If the adapter had
225
- // accidentally sent an addDocuments request (even with an empty body),
226
- // isIndexing would flip to true or a task would land in the queue.
227
- // numberOfDocuments must also stay unchanged the empty batch must not
228
- // replace, delete, or otherwise touch existing docs.
229
- const index = client.index(tenantIndex(indexPrefix, TENANT));
230
- const before = await index.getStats();
231
- await expect(adapter.indexBatch?.(TENANT, [])).resolves.toBeUndefined();
232
- const after = await index.getStats();
233
- expect(after.numberOfDocuments).toBe(before.numberOfDocuments);
234
- expect(after.isIndexing).toBe(false);
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
- function tenantIndex(prefix: string, tenantId: TenantId): string {
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 docId(entityType: string, entityId: EntityId): string {
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(tenantIndex(prefix, tenantId));
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(tenantIndex(prefix, tenantId));
36
+ const index = client.index(meilisearchTenantIndex(prefix, tenantId));
36
37
  await index
37
38
  .addDocuments(
38
39
  [
39
40
  {
40
- _id: docId(doc.entityType, doc.entityId),
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(tenantIndex(prefix, tenantId));
56
+ const index = client.index(meilisearchTenantIndex(prefix, tenantId));
56
57
  const payload = docs.map((doc) => ({
57
- _id: docId(doc.entityType, doc.entityId),
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(tenantIndex(prefix, tenantId));
73
- const ids = items.map((i) => docId(i.entityType, i.entityId));
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(tenantIndex(prefix, tenantId));
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(tenantIndex(prefix, tenantId));
103
- await index.deleteDocument(docId(entityType, entityId)).waitTask();
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
- });