@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.
- package/README.md +735 -0
- package/bin/tsctl.js +2 -0
- package/package.json +65 -0
- package/src/__tests__/analyticsrules.test.ts +303 -0
- package/src/__tests__/apikeys.test.ts +223 -0
- package/src/__tests__/apply.test.ts +245 -0
- package/src/__tests__/client.test.ts +48 -0
- package/src/__tests__/collection-advanced.test.ts +274 -0
- package/src/__tests__/config-loader.test.ts +217 -0
- package/src/__tests__/curationsets.test.ts +190 -0
- package/src/__tests__/helpers.ts +17 -0
- package/src/__tests__/import-drift.test.ts +231 -0
- package/src/__tests__/migrate-advanced.test.ts +197 -0
- package/src/__tests__/migrate.test.ts +220 -0
- package/src/__tests__/plan-new-resources.test.ts +258 -0
- package/src/__tests__/plan.test.ts +337 -0
- package/src/__tests__/presets.test.ts +97 -0
- package/src/__tests__/resources.test.ts +592 -0
- package/src/__tests__/setup.ts +77 -0
- package/src/__tests__/state.test.ts +312 -0
- package/src/__tests__/stemmingdictionaries.test.ts +111 -0
- package/src/__tests__/stopwords.test.ts +109 -0
- package/src/__tests__/synonymsets.test.ts +170 -0
- package/src/__tests__/types.test.ts +416 -0
- package/src/apply/index.ts +336 -0
- package/src/cli/index.ts +1106 -0
- package/src/client/index.ts +55 -0
- package/src/config/loader.ts +158 -0
- package/src/index.ts +45 -0
- package/src/migrate/index.ts +220 -0
- package/src/plan/index.ts +1333 -0
- package/src/resources/alias.ts +59 -0
- package/src/resources/analyticsrule.ts +134 -0
- package/src/resources/apikey.ts +203 -0
- package/src/resources/collection.ts +424 -0
- package/src/resources/curationset.ts +155 -0
- package/src/resources/index.ts +11 -0
- package/src/resources/override.ts +174 -0
- package/src/resources/preset.ts +83 -0
- package/src/resources/stemmingdictionary.ts +103 -0
- package/src/resources/stopword.ts +100 -0
- package/src/resources/synonym.ts +152 -0
- package/src/resources/synonymset.ts +144 -0
- package/src/state/index.ts +206 -0
- 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
|
+
});
|