@tsctl/cli 0.2.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 (45) hide show
  1. package/README.md +735 -0
  2. package/bin/tsctl.js +2 -0
  3. package/package.json +65 -0
  4. package/src/__tests__/analyticsrules.test.ts +303 -0
  5. package/src/__tests__/apikeys.test.ts +223 -0
  6. package/src/__tests__/apply.test.ts +245 -0
  7. package/src/__tests__/client.test.ts +48 -0
  8. package/src/__tests__/collection-advanced.test.ts +274 -0
  9. package/src/__tests__/config-loader.test.ts +217 -0
  10. package/src/__tests__/curationsets.test.ts +190 -0
  11. package/src/__tests__/helpers.ts +17 -0
  12. package/src/__tests__/import-drift.test.ts +231 -0
  13. package/src/__tests__/migrate-advanced.test.ts +197 -0
  14. package/src/__tests__/migrate.test.ts +220 -0
  15. package/src/__tests__/plan-new-resources.test.ts +258 -0
  16. package/src/__tests__/plan.test.ts +337 -0
  17. package/src/__tests__/presets.test.ts +97 -0
  18. package/src/__tests__/resources.test.ts +592 -0
  19. package/src/__tests__/setup.ts +77 -0
  20. package/src/__tests__/state.test.ts +312 -0
  21. package/src/__tests__/stemmingdictionaries.test.ts +111 -0
  22. package/src/__tests__/stopwords.test.ts +109 -0
  23. package/src/__tests__/synonymsets.test.ts +170 -0
  24. package/src/__tests__/types.test.ts +416 -0
  25. package/src/apply/index.ts +336 -0
  26. package/src/cli/index.ts +1106 -0
  27. package/src/client/index.ts +55 -0
  28. package/src/config/loader.ts +158 -0
  29. package/src/index.ts +45 -0
  30. package/src/migrate/index.ts +220 -0
  31. package/src/plan/index.ts +1333 -0
  32. package/src/resources/alias.ts +59 -0
  33. package/src/resources/analyticsrule.ts +134 -0
  34. package/src/resources/apikey.ts +203 -0
  35. package/src/resources/collection.ts +424 -0
  36. package/src/resources/curationset.ts +155 -0
  37. package/src/resources/index.ts +11 -0
  38. package/src/resources/override.ts +174 -0
  39. package/src/resources/preset.ts +83 -0
  40. package/src/resources/stemmingdictionary.ts +103 -0
  41. package/src/resources/stopword.ts +100 -0
  42. package/src/resources/synonym.ts +152 -0
  43. package/src/resources/synonymset.ts +144 -0
  44. package/src/state/index.ts +206 -0
  45. package/src/types/index.ts +451 -0
@@ -0,0 +1,197 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { setupClient, cleanupTypesense } from "./setup.js";
3
+ import {
4
+ generateVersionedName,
5
+ extractBaseName,
6
+ findCollectionVersions,
7
+ planMigration,
8
+ executeMigration,
9
+ formatMigrationPlan,
10
+ } from "../migrate/index.js";
11
+ import { createCollection, getCollection } from "../resources/collection.js";
12
+ import { upsertAlias, getAlias } from "../resources/alias.js";
13
+ import type { CollectionConfig } from "../types/index.js";
14
+
15
+ describe("migrate advanced", () => {
16
+ beforeEach(async () => {
17
+ setupClient();
18
+ await cleanupTypesense();
19
+ });
20
+
21
+ afterEach(async () => {
22
+ await cleanupTypesense();
23
+ });
24
+
25
+ describe("versioned naming", () => {
26
+ test("extractBaseName handles multiple underscores", () => {
27
+ expect(extractBaseName("my_cool_products_1706486400000")).toBe("my_cool_products");
28
+ });
29
+
30
+ test("extractBaseName ignores non-timestamp suffixes", () => {
31
+ expect(extractBaseName("products_v2")).toBe("products_v2");
32
+ });
33
+
34
+ test("extractBaseName handles plain name", () => {
35
+ expect(extractBaseName("products")).toBe("products");
36
+ });
37
+
38
+ test("generateVersionedName format is correct", () => {
39
+ const name = generateVersionedName("my_collection");
40
+ expect(name).toMatch(/^my_collection_\d{13}$/);
41
+ });
42
+ });
43
+
44
+ describe("findCollectionVersions", () => {
45
+ test("returns empty for non-existent collection", async () => {
46
+ const versions = await findCollectionVersions("nonexistent");
47
+ expect(versions).toHaveLength(0);
48
+ });
49
+
50
+ test("finds base collection and versioned copies", async () => {
51
+ await createCollection({
52
+ name: "products",
53
+ fields: [{ name: "title", type: "string" }],
54
+ });
55
+ await createCollection({
56
+ name: "products_1706486400000",
57
+ fields: [{ name: "title", type: "string" }],
58
+ });
59
+
60
+ const versions = await findCollectionVersions("products");
61
+ expect(versions).toHaveLength(2);
62
+ });
63
+
64
+ test("does not include unrelated collections", async () => {
65
+ await createCollection({
66
+ name: "products",
67
+ fields: [{ name: "title", type: "string" }],
68
+ });
69
+ await createCollection({
70
+ name: "orders",
71
+ fields: [{ name: "id", type: "string" }],
72
+ });
73
+
74
+ const versions = await findCollectionVersions("products");
75
+ expect(versions).toHaveLength(1);
76
+ expect(versions[0]!.name).toBe("products");
77
+ });
78
+ });
79
+
80
+ describe("planMigration", () => {
81
+ test("creates correct plan without existing alias", async () => {
82
+ const config: CollectionConfig = {
83
+ name: "products",
84
+ fields: [
85
+ { name: "title", type: "string" },
86
+ { name: "price", type: "float" },
87
+ ],
88
+ };
89
+
90
+ const plan = await planMigration("products_live", config);
91
+ expect(plan.alias).toBe("products_live");
92
+ expect(plan.currentCollection).toBeNull();
93
+ expect(plan.newCollection).toMatch(/^products_\d{13}$/);
94
+ expect(plan.newCollectionConfig.name).toBe(plan.newCollection);
95
+ expect(plan.newCollectionConfig.fields).toHaveLength(2);
96
+ });
97
+
98
+ test("plan includes delete step when alias exists", async () => {
99
+ await createCollection({
100
+ name: "products_old",
101
+ fields: [{ name: "title", type: "string" }],
102
+ });
103
+ await upsertAlias({ name: "products_live", collection: "products_old" });
104
+
105
+ const plan = await planMigration("products_live", {
106
+ name: "products",
107
+ fields: [{ name: "title", type: "string" }],
108
+ });
109
+
110
+ expect(plan.currentCollection).toBe("products_old");
111
+ const stepActions = plan.steps.map((s) => s.action);
112
+ expect(stepActions).toContain("create_collection");
113
+ expect(stepActions).toContain("switch_alias");
114
+ expect(stepActions).toContain("delete_old_collection");
115
+ });
116
+ });
117
+
118
+ describe("executeMigration", () => {
119
+ test("full migration with schema change", async () => {
120
+ // Create v1
121
+ await createCollection({
122
+ name: "products_v1",
123
+ fields: [{ name: "title", type: "string" }],
124
+ });
125
+ await upsertAlias({ name: "products_live", collection: "products_v1" });
126
+
127
+ // Migrate to v2 with new fields
128
+ const plan = await planMigration("products_live", {
129
+ name: "products",
130
+ fields: [
131
+ { name: "title", type: "string" },
132
+ { name: "description", type: "string", optional: true },
133
+ { name: "price", type: "float" },
134
+ ],
135
+ });
136
+
137
+ const result = await executeMigration(plan);
138
+ expect(result.success).toBe(true);
139
+
140
+ // Verify new collection has updated schema
141
+ const newCollection = await getCollection(plan.newCollection);
142
+ expect(newCollection).not.toBeNull();
143
+ expect(newCollection!.fields.length).toBeGreaterThanOrEqual(3);
144
+
145
+ // Verify alias points to new collection
146
+ const alias = await getAlias("products_live");
147
+ expect(alias!.collection).toBe(plan.newCollection);
148
+
149
+ // Verify old collection was deleted
150
+ const oldCollection = await getCollection("products_v1");
151
+ expect(oldCollection).toBeNull();
152
+ });
153
+
154
+ test("migration with skipDelete preserves old collection", async () => {
155
+ await createCollection({
156
+ name: "old_coll",
157
+ fields: [{ name: "title", type: "string" }],
158
+ });
159
+ await upsertAlias({ name: "my_alias", collection: "old_coll" });
160
+
161
+ const plan = await planMigration("my_alias", {
162
+ name: "my_collection",
163
+ fields: [{ name: "title", type: "string" }],
164
+ });
165
+
166
+ const result = await executeMigration(plan, { skipDelete: true });
167
+ expect(result.success).toBe(true);
168
+
169
+ // Old collection should still exist
170
+ const oldColl = await getCollection("old_coll");
171
+ expect(oldColl).not.toBeNull();
172
+
173
+ // But alias should point to new
174
+ const alias = await getAlias("my_alias");
175
+ expect(alias!.collection).toBe(plan.newCollection);
176
+ });
177
+
178
+ test("formatMigrationPlan produces readable output", async () => {
179
+ await createCollection({
180
+ name: "products_old",
181
+ fields: [{ name: "title", type: "string" }],
182
+ });
183
+ await upsertAlias({ name: "products_live", collection: "products_old" });
184
+
185
+ const plan = await planMigration("products_live", {
186
+ name: "products",
187
+ fields: [{ name: "title", type: "string" }],
188
+ });
189
+
190
+ const output = formatMigrationPlan(plan);
191
+ expect(output).toContain("Migration Plan");
192
+ expect(output).toContain("products_live");
193
+ expect(output).toContain("products_old");
194
+ expect(output).toContain(plan.newCollection);
195
+ });
196
+ });
197
+ });
@@ -0,0 +1,220 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { setupClient, cleanupTypesense } from "./setup.js";
3
+ import {
4
+ generateVersionedName,
5
+ extractBaseName,
6
+ findCollectionVersions,
7
+ planMigration,
8
+ executeMigration,
9
+ } from "../migrate/index.js";
10
+ import { createCollection, getCollection } from "../resources/collection.js";
11
+ import { upsertAlias, getAlias } from "../resources/alias.js";
12
+ import type { CollectionConfig } from "../types/index.js";
13
+
14
+ describe("migrate", () => {
15
+ beforeEach(async () => {
16
+ setupClient();
17
+ await cleanupTypesense();
18
+ });
19
+
20
+ afterEach(async () => {
21
+ await cleanupTypesense();
22
+ });
23
+
24
+ describe("generateVersionedName", () => {
25
+ test("appends timestamp to base name", () => {
26
+ const name = generateVersionedName("products");
27
+ expect(name).toMatch(/^products_\d{13}$/);
28
+ });
29
+
30
+ test("generates unique names on subsequent calls", async () => {
31
+ const name1 = generateVersionedName("products");
32
+ // Tiny delay to ensure different timestamp
33
+ await new Promise((r) => setTimeout(r, 5));
34
+ const name2 = generateVersionedName("products");
35
+ expect(name1).not.toBe(name2);
36
+ });
37
+ });
38
+
39
+ describe("extractBaseName", () => {
40
+ test("extracts base name from versioned name", () => {
41
+ expect(extractBaseName("products_1706486400000")).toBe("products");
42
+ });
43
+
44
+ test("returns original name if not versioned", () => {
45
+ expect(extractBaseName("products")).toBe("products");
46
+ });
47
+
48
+ test("handles names with underscores", () => {
49
+ expect(extractBaseName("my_products_1706486400000")).toBe("my_products");
50
+ });
51
+ });
52
+
53
+ describe("findCollectionVersions", () => {
54
+ test("finds all versions of a collection", async () => {
55
+ await createCollection({
56
+ name: "products",
57
+ fields: [{ name: "title", type: "string" }],
58
+ });
59
+ await createCollection({
60
+ name: "products_1706486400000",
61
+ fields: [{ name: "title", type: "string" }],
62
+ });
63
+ await createCollection({
64
+ name: "products_1706486500000",
65
+ fields: [{ name: "title", type: "string" }],
66
+ });
67
+ await createCollection({
68
+ name: "users",
69
+ fields: [{ name: "name", type: "string" }],
70
+ });
71
+
72
+ const versions = await findCollectionVersions("products");
73
+ expect(versions).toHaveLength(3);
74
+ const names = versions.map((v) => v.name);
75
+ expect(names).toContain("products");
76
+ expect(names).toContain("products_1706486400000");
77
+ expect(names).not.toContain("users");
78
+ });
79
+
80
+ test("returns empty array for non-existent base name", async () => {
81
+ const versions = await findCollectionVersions("nonexistent");
82
+ expect(versions).toHaveLength(0);
83
+ });
84
+ });
85
+
86
+ describe("planMigration", () => {
87
+ test("plans migration without existing alias", async () => {
88
+ const config: CollectionConfig = {
89
+ name: "products",
90
+ fields: [{ name: "title", type: "string" }],
91
+ };
92
+
93
+ const plan = await planMigration("products_live", config);
94
+
95
+ expect(plan.alias).toBe("products_live");
96
+ expect(plan.currentCollection).toBeNull();
97
+ expect(plan.newCollection).toMatch(/^products_\d{13}$/);
98
+ expect(plan.steps).toHaveLength(2); // create + switch (no delete since no current)
99
+ expect(plan.steps[0]!.action).toBe("create_collection");
100
+ expect(plan.steps[1]!.action).toBe("switch_alias");
101
+ });
102
+
103
+ test("plans migration with existing alias", async () => {
104
+ await createCollection({
105
+ name: "products_old",
106
+ fields: [{ name: "title", type: "string" }],
107
+ });
108
+ await upsertAlias({ name: "products_live", collection: "products_old" });
109
+
110
+ const config: CollectionConfig = {
111
+ name: "products",
112
+ fields: [
113
+ { name: "title", type: "string" },
114
+ { name: "description", type: "string" },
115
+ ],
116
+ };
117
+
118
+ const plan = await planMigration("products_live", config);
119
+
120
+ expect(plan.currentCollection).toBe("products_old");
121
+ expect(plan.steps).toHaveLength(3); // create + switch + delete
122
+ expect(plan.steps[2]!.action).toBe("delete_old_collection");
123
+ });
124
+ });
125
+
126
+ describe("executeMigration", () => {
127
+ test("executes full migration", async () => {
128
+ const config: CollectionConfig = {
129
+ name: "products",
130
+ fields: [{ name: "title", type: "string" }],
131
+ };
132
+
133
+ const plan = await planMigration("products_live", config);
134
+ const result = await executeMigration(plan);
135
+
136
+ expect(result.success).toBe(true);
137
+ expect(result.errors).toHaveLength(0);
138
+
139
+ // Verify new collection exists
140
+ const collection = await getCollection(plan.newCollection);
141
+ expect(collection).not.toBeNull();
142
+
143
+ // Verify alias points to new collection
144
+ const alias = await getAlias("products_live");
145
+ expect(alias).not.toBeNull();
146
+ expect(alias!.collection).toBe(plan.newCollection);
147
+ });
148
+
149
+ test("executes migration with skipDelete", async () => {
150
+ await createCollection({
151
+ name: "products_old",
152
+ fields: [{ name: "title", type: "string" }],
153
+ });
154
+ await upsertAlias({ name: "products_live", collection: "products_old" });
155
+
156
+ const config: CollectionConfig = {
157
+ name: "products",
158
+ fields: [{ name: "title", type: "string" }],
159
+ };
160
+
161
+ const plan = await planMigration("products_live", config);
162
+ const result = await executeMigration(plan, { skipDelete: true });
163
+
164
+ expect(result.success).toBe(true);
165
+
166
+ // Old collection should still exist
167
+ const oldCollection = await getCollection("products_old");
168
+ expect(oldCollection).not.toBeNull();
169
+ });
170
+
171
+ test("calls onStep callback for each step", async () => {
172
+ const config: CollectionConfig = {
173
+ name: "products",
174
+ fields: [{ name: "title", type: "string" }],
175
+ };
176
+
177
+ const plan = await planMigration("products_live", config);
178
+ const steps: number[] = [];
179
+
180
+ await executeMigration(plan, {
181
+ onStep: (_step, index) => {
182
+ steps.push(index);
183
+ },
184
+ });
185
+
186
+ expect(steps).toEqual([0, 1]);
187
+ });
188
+
189
+ test("migration replaces old collection when not skipping delete", async () => {
190
+ // Setup: existing collection and alias
191
+ await createCollection({
192
+ name: "products_old",
193
+ fields: [{ name: "title", type: "string" }],
194
+ });
195
+ await upsertAlias({ name: "products_live", collection: "products_old" });
196
+
197
+ const config: CollectionConfig = {
198
+ name: "products",
199
+ fields: [
200
+ { name: "title", type: "string" },
201
+ { name: "price", type: "float" },
202
+ ],
203
+ };
204
+
205
+ const plan = await planMigration("products_live", config);
206
+ const result = await executeMigration(plan);
207
+
208
+ expect(result.success).toBe(true);
209
+
210
+ // Old collection should be deleted
211
+ const oldCollection = await getCollection("products_old");
212
+ expect(oldCollection).toBeNull();
213
+
214
+ // New collection should exist with new schema
215
+ const newCollection = await getCollection(plan.newCollection);
216
+ expect(newCollection).not.toBeNull();
217
+ expect(newCollection!.fields).toHaveLength(2);
218
+ });
219
+ });
220
+ });
@@ -0,0 +1,258 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { setupClient, cleanupTypesense } from "./setup.js";
3
+ import { getTypesenseVersion } from "./helpers.js";
4
+ import { buildPlan } from "../plan/index.js";
5
+ import { applyPlan } from "../apply/index.js";
6
+ import { loadState } from "../state/index.js";
7
+ import { getStopwordSet } from "../resources/stopword.js";
8
+ import { getPreset } from "../resources/preset.js";
9
+ import { getCurationSet } from "../resources/curationset.js";
10
+ import { upsertStopwordSet } from "../resources/stopword.js";
11
+ import { upsertPreset } from "../resources/preset.js";
12
+ import type { TypesenseConfig } from "../types/index.js";
13
+
14
+ describe("plan/apply with new resource types", () => {
15
+ let version: number;
16
+
17
+ beforeEach(async () => {
18
+ setupClient();
19
+ await cleanupTypesense();
20
+ version = await getTypesenseVersion();
21
+ });
22
+
23
+ afterEach(async () => {
24
+ await cleanupTypesense();
25
+ });
26
+
27
+ describe("stopwords", () => {
28
+ test("plans creation for new stopword set", async () => {
29
+ const config: TypesenseConfig = {
30
+ stopwords: [
31
+ { id: "english", stopwords: ["the", "a", "an"] },
32
+ ],
33
+ };
34
+ const plan = await buildPlan(config);
35
+ const change = plan.changes.find((c) => c.identifier.type === "stopword");
36
+ expect(change).toBeDefined();
37
+ expect(change!.action).toBe("create");
38
+ });
39
+
40
+ test("plans no-change for existing stopword set", async () => {
41
+ await upsertStopwordSet({ id: "english", stopwords: ["the", "a", "an"] });
42
+ const config: TypesenseConfig = {
43
+ stopwords: [
44
+ { id: "english", stopwords: ["the", "a", "an"] },
45
+ ],
46
+ };
47
+ const plan = await buildPlan(config);
48
+ const change = plan.changes.find((c) => c.identifier.type === "stopword");
49
+ expect(change!.action).toBe("no-change");
50
+ });
51
+
52
+ test("plans update for modified stopword set", async () => {
53
+ await upsertStopwordSet({ id: "english", stopwords: ["the", "a"] });
54
+ const config: TypesenseConfig = {
55
+ stopwords: [
56
+ { id: "english", stopwords: ["the", "a", "an", "is"] },
57
+ ],
58
+ };
59
+ const plan = await buildPlan(config);
60
+ const change = plan.changes.find((c) => c.identifier.type === "stopword");
61
+ expect(change!.action).toBe("update");
62
+ });
63
+
64
+ test("creates stopword set via apply", async () => {
65
+ const config: TypesenseConfig = {
66
+ stopwords: [
67
+ { id: "english", stopwords: ["the", "a", "an"] },
68
+ ],
69
+ };
70
+ const plan = await buildPlan(config);
71
+ await applyPlan(plan, config);
72
+
73
+ const result = await getStopwordSet("english");
74
+ expect(result).not.toBeNull();
75
+ expect(result!.stopwords).toContain("the");
76
+ });
77
+
78
+ test("deletes stopword set via apply", async () => {
79
+ // Create and apply
80
+ const config1: TypesenseConfig = {
81
+ stopwords: [{ id: "english", stopwords: ["the"] }],
82
+ };
83
+ const plan1 = await buildPlan(config1);
84
+ await applyPlan(plan1, config1);
85
+
86
+ // Delete
87
+ const config2: TypesenseConfig = {};
88
+ const plan2 = await buildPlan(config2);
89
+ await applyPlan(plan2, config2);
90
+
91
+ const result = await getStopwordSet("english");
92
+ expect(result).toBeNull();
93
+ });
94
+ });
95
+
96
+ describe("presets", () => {
97
+ test("plans creation for new preset", async () => {
98
+ const config: TypesenseConfig = {
99
+ presets: [
100
+ { name: "listing", value: { q: "*", sort_by: "price:asc" } },
101
+ ],
102
+ };
103
+ const plan = await buildPlan(config);
104
+ const change = plan.changes.find((c) => c.identifier.type === "preset");
105
+ expect(change).toBeDefined();
106
+ expect(change!.action).toBe("create");
107
+ });
108
+
109
+ test("creates preset via apply", async () => {
110
+ const config: TypesenseConfig = {
111
+ presets: [
112
+ { name: "listing", value: { q: "*" } },
113
+ ],
114
+ };
115
+ const plan = await buildPlan(config);
116
+ await applyPlan(plan, config);
117
+
118
+ const result = await getPreset("listing");
119
+ expect(result).not.toBeNull();
120
+ });
121
+
122
+ test("plans no-change for existing preset", async () => {
123
+ await upsertPreset({ name: "listing", value: { q: "*" } });
124
+ const config: TypesenseConfig = {
125
+ presets: [
126
+ { name: "listing", value: { q: "*" } },
127
+ ],
128
+ };
129
+ const plan = await buildPlan(config);
130
+ const change = plan.changes.find((c) => c.identifier.type === "preset");
131
+ expect(change!.action).toBe("no-change");
132
+ });
133
+
134
+ test("deletes preset via apply", async () => {
135
+ const config1: TypesenseConfig = {
136
+ presets: [{ name: "listing", value: { q: "*" } }],
137
+ };
138
+ const plan1 = await buildPlan(config1);
139
+ await applyPlan(plan1, config1);
140
+
141
+ const config2: TypesenseConfig = {};
142
+ const plan2 = await buildPlan(config2);
143
+ await applyPlan(plan2, config2);
144
+
145
+ const result = await getPreset("listing");
146
+ expect(result).toBeNull();
147
+ });
148
+ });
149
+
150
+ describe("curation sets (v30+)", () => {
151
+ test("plans creation for new curation set", async () => {
152
+ if (version < 30) return;
153
+ const config: TypesenseConfig = {
154
+ curationSets: [
155
+ {
156
+ name: "products",
157
+ items: [
158
+ {
159
+ id: "featured",
160
+ rule: { query: "featured", match: "exact" },
161
+ includes: [{ id: "product-1", position: 1 }],
162
+ },
163
+ ],
164
+ },
165
+ ],
166
+ };
167
+ const plan = await buildPlan(config);
168
+ const change = plan.changes.find((c) => c.identifier.type === "curationSet");
169
+ expect(change).toBeDefined();
170
+ expect(change!.action).toBe("create");
171
+ });
172
+
173
+ test("creates curation set via apply", async () => {
174
+ if (version < 30) return;
175
+ const config: TypesenseConfig = {
176
+ curationSets: [
177
+ {
178
+ name: "products",
179
+ items: [
180
+ {
181
+ id: "boost-shoes",
182
+ rule: { query: "shoes", match: "contains" },
183
+ filter_by: "category:=footwear",
184
+ },
185
+ ],
186
+ },
187
+ ],
188
+ };
189
+ const plan = await buildPlan(config);
190
+ await applyPlan(plan, config);
191
+
192
+ const result = await getCurationSet("products");
193
+ expect(result).not.toBeNull();
194
+ expect(result!.items).toHaveLength(1);
195
+ });
196
+
197
+ test("deletes curation set via apply", async () => {
198
+ if (version < 30) return;
199
+ const config1: TypesenseConfig = {
200
+ curationSets: [
201
+ {
202
+ name: "products",
203
+ items: [
204
+ {
205
+ id: "rule1",
206
+ rule: { query: "x", match: "exact" },
207
+ filter_by: "a:=1",
208
+ },
209
+ ],
210
+ },
211
+ ],
212
+ };
213
+ const plan1 = await buildPlan(config1);
214
+ await applyPlan(plan1, config1);
215
+
216
+ const config2: TypesenseConfig = {};
217
+ const plan2 = await buildPlan(config2);
218
+ await applyPlan(plan2, config2);
219
+
220
+ const result = await getCurationSet("products");
221
+ expect(result).toBeNull();
222
+ });
223
+ });
224
+
225
+ describe("full config with all resource types", () => {
226
+ test("applies config with stopwords, presets, and collections", async () => {
227
+ const config: TypesenseConfig = {
228
+ collections: [
229
+ { name: "products", fields: [{ name: "title", type: "string" }] },
230
+ ],
231
+ aliases: [{ name: "products_live", collection: "products" }],
232
+ stopwords: [
233
+ { id: "english", stopwords: ["the", "a"] },
234
+ ],
235
+ presets: [
236
+ { name: "default_search", value: { q: "*" } },
237
+ ],
238
+ apiKeys: [
239
+ { description: "Search key", actions: ["documents:search"], collections: ["*"] },
240
+ ],
241
+ };
242
+
243
+ const plan = await buildPlan(config);
244
+ const result = await applyPlan(plan, config);
245
+
246
+ expect(result.success).toBe(true);
247
+
248
+ // Verify state
249
+ const state = await loadState();
250
+ const types = state.resources.map((r) => r.identifier.type);
251
+ expect(types).toContain("collection");
252
+ expect(types).toContain("alias");
253
+ expect(types).toContain("stopword");
254
+ expect(types).toContain("preset");
255
+ expect(types).toContain("apiKey");
256
+ });
257
+ });
258
+ });