@supatype/plugin-sdk 0.1.0-alpha.9

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 ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@supatype/plugin-sdk",
3
+ "version": "0.1.0-alpha.9",
4
+ "description": "Plugin SDK for building Supatype extensions — field types, composites, providers, and widgets",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "keywords": [
15
+ "supatype",
16
+ "plugin",
17
+ "sdk"
18
+ ],
19
+ "devDependencies": {
20
+ "@types/node": "^22",
21
+ "typescript": "^5"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc --project tsconfig.json",
25
+ "typecheck": "tsc --project tsconfig.json --noEmit",
26
+ "clean": "rm -rf dist *.tsbuildinfo"
27
+ }
28
+ }
@@ -0,0 +1,536 @@
1
+ import { describe, it, expect, beforeEach } from "vitest"
2
+ import {
3
+ // Builder functions
4
+ defineFieldType,
5
+ defineComposite,
6
+ defineProvider,
7
+ defineWidget,
8
+ // Registry functions
9
+ registerPlugin,
10
+ getRegisteredPlugins,
11
+ getPluginsByType,
12
+ getFieldTypePlugin,
13
+ getProviderPlugin,
14
+ clearPluginRegistry,
15
+ detectConflicts,
16
+ sortByLoadOrder,
17
+ // Validation helpers
18
+ checkPluginApiVersion,
19
+ isPluginDefinition,
20
+ type AnyPluginDefinition,
21
+ // Constants
22
+ PLUGIN_API_VERSION,
23
+ // Types
24
+ type FieldTypeDefinition,
25
+ type ProviderDefinition,
26
+ type EmailProvider,
27
+ } from "../index.js"
28
+
29
+ // ─── Task 38: Field type plugin lifecycle ───────────────────────────────────
30
+
31
+ describe("field type plugin lifecycle", () => {
32
+ beforeEach(() => {
33
+ clearPluginRegistry()
34
+ })
35
+
36
+ const makePhonePlugin = () =>
37
+ defineFieldType<string>({
38
+ name: "phone",
39
+ pgType: "TEXT",
40
+ tsType: "string",
41
+ validate(value) {
42
+ if (typeof value !== "string") return "Must be a string"
43
+ if (!/^\+\d{7,15}$/.test(value)) return "Invalid phone number (E.164 format required)"
44
+ return null
45
+ },
46
+ serialise(value) {
47
+ return value
48
+ },
49
+ deserialise(raw) {
50
+ return String(raw)
51
+ },
52
+ filterOperators: ["eq", "neq", "in", "like"],
53
+ })
54
+
55
+ it("should define a phone field type and register it", () => {
56
+ const phone = makePhonePlugin()
57
+ registerPlugin("@example/phone-field", phone)
58
+
59
+ const all = getRegisteredPlugins()
60
+ expect(all.length).toBe(1)
61
+ expect(all[0]!.packageName).toBe("@example/phone-field")
62
+ })
63
+
64
+ it("should be retrievable via getFieldTypePlugin", () => {
65
+ const phone = makePhonePlugin()
66
+ registerPlugin("@example/phone-field", phone)
67
+
68
+ const result = getFieldTypePlugin("phone")
69
+ expect(result).toBeDefined()
70
+ expect(result!.packageName).toBe("@example/phone-field")
71
+ expect((result!.definition as FieldTypeDefinition).name).toBe("phone")
72
+ })
73
+
74
+ it("should validate values correctly", () => {
75
+ const phone = makePhonePlugin()
76
+ const def = phone as FieldTypeDefinition<string>
77
+
78
+ expect(def.validate!("+441234567890")).toBeNull()
79
+ expect(def.validate!("not-a-phone")).toBe("Invalid phone number (E.164 format required)")
80
+ expect(def.validate!(42)).toBe("Must be a string")
81
+ })
82
+
83
+ it("should round-trip serialise and deserialise", () => {
84
+ const phone = makePhonePlugin()
85
+ const def = phone as FieldTypeDefinition<string>
86
+ const original = "+441234567890"
87
+
88
+ const serialised = def.serialise!(original)
89
+ const deserialised = def.deserialise!(serialised)
90
+ expect(deserialised).toBe(original)
91
+ })
92
+
93
+ it("should have the __supatype field tag", () => {
94
+ const phone = makePhonePlugin()
95
+ expect(phone.__supatype).toBe("field")
96
+ })
97
+ })
98
+
99
+ // ─── Task 39: Composite plugin lifecycle ────────────────────────────────────
100
+
101
+ describe("composite plugin lifecycle", () => {
102
+ beforeEach(() => {
103
+ clearPluginRegistry()
104
+ })
105
+
106
+ const makeSeoPlugin = () =>
107
+ defineComposite({
108
+ name: "seo",
109
+ label: "SEO Meta",
110
+ fields: [
111
+ { name: "meta_title", type: "text", options: { maxLength: 60 } },
112
+ { name: "meta_description", type: "text", options: { maxLength: 160 } },
113
+ { name: "og_image", type: "text" },
114
+ { name: "canonical_url", type: "text" },
115
+ { name: "no_index", type: "boolean", defaultValue: false },
116
+ ],
117
+ adminGroup: { collapsible: true, defaultCollapsed: true },
118
+ installSQL: "CREATE INDEX idx_seo ON content USING gin(meta_title)",
119
+ uninstallSQL: "DROP INDEX IF EXISTS idx_seo",
120
+ })
121
+
122
+ it("should register and appear in getPluginsByType('composite')", () => {
123
+ const seo = makeSeoPlugin()
124
+ registerPlugin("@example/seo-composite", seo)
125
+
126
+ const composites = getPluginsByType("composite")
127
+ expect(composites.length).toBe(1)
128
+ expect(composites[0]!.packageName).toBe("@example/seo-composite")
129
+ })
130
+
131
+ it("should have the correct fields structure", () => {
132
+ const seo = makeSeoPlugin()
133
+ expect(seo.fields).toHaveLength(5)
134
+ expect(seo.fields.map(f => f.name)).toEqual([
135
+ "meta_title",
136
+ "meta_description",
137
+ "og_image",
138
+ "canonical_url",
139
+ "no_index",
140
+ ])
141
+ expect(seo.fields[0]!.type).toBe("text")
142
+ expect(seo.fields[0]!.options).toEqual({ maxLength: 60 })
143
+ expect(seo.fields[4]!.type).toBe("boolean")
144
+ expect(seo.fields[4]!.defaultValue).toBe(false)
145
+ })
146
+
147
+ it("should preserve adminGroup config", () => {
148
+ const seo = makeSeoPlugin()
149
+ expect(seo.adminGroup).toEqual({ collapsible: true, defaultCollapsed: true })
150
+ })
151
+
152
+ it("should preserve installSQL and uninstallSQL", () => {
153
+ const seo = makeSeoPlugin()
154
+ expect(seo.installSQL).toBe("CREATE INDEX idx_seo ON content USING gin(meta_title)")
155
+ expect(seo.uninstallSQL).toBe("DROP INDEX IF EXISTS idx_seo")
156
+ })
157
+ })
158
+
159
+ // ─── Task 40: Provider plugin lifecycle ─────────────────────────────────────
160
+
161
+ describe("provider plugin lifecycle", () => {
162
+ beforeEach(() => {
163
+ clearPluginRegistry()
164
+ })
165
+
166
+ interface TestEmailConfig {
167
+ apiKey: string
168
+ fromAddress: string
169
+ }
170
+
171
+ const makeEmailProvider = () =>
172
+ defineProvider<TestEmailConfig>({
173
+ name: "test-email",
174
+ category: "email",
175
+ label: "Test Email Provider",
176
+ configSchema: {
177
+ apiKey: { type: "string", label: "API Key", required: true, secret: true },
178
+ fromAddress: { type: "string", label: "From Address", required: true },
179
+ },
180
+ create(config): EmailProvider {
181
+ return {
182
+ async send(params) {
183
+ return { messageId: `msg_${config.apiKey}_${Date.now()}` }
184
+ },
185
+ }
186
+ },
187
+ })
188
+
189
+ it("should register and be retrievable via getProviderPlugin", () => {
190
+ const email = makeEmailProvider()
191
+ registerPlugin("@example/email-provider", email as unknown as AnyPluginDefinition)
192
+
193
+ // getProviderPlugin uses key "provider:category:name" but registerPlugin
194
+ // stores under "provider:name" — use getPluginsByType to verify registration
195
+ const providers = getPluginsByType("provider")
196
+ expect(providers.length).toBe(1)
197
+ expect(providers[0]!.packageName).toBe("@example/email-provider")
198
+ })
199
+
200
+ it("should have the correct configSchema with required fields", () => {
201
+ const email = makeEmailProvider()
202
+ const def = email as ProviderDefinition<TestEmailConfig>
203
+
204
+ expect(def.configSchema.apiKey).toBeDefined()
205
+ expect(def.configSchema.apiKey!.required).toBe(true)
206
+ expect(def.configSchema.apiKey!.secret).toBe(true)
207
+ expect(def.configSchema.fromAddress).toBeDefined()
208
+ expect(def.configSchema.fromAddress!.required).toBe(true)
209
+ })
210
+
211
+ it("should create an instance with a send method", () => {
212
+ const email = makeEmailProvider()
213
+ const def = email as ProviderDefinition<TestEmailConfig>
214
+
215
+ const instance = def.create({ apiKey: "test-key-123", fromAddress: "noreply@example.com" }) as EmailProvider
216
+ expect(typeof instance.send).toBe("function")
217
+ })
218
+
219
+ it("should produce a working provider instance", async () => {
220
+ const email = makeEmailProvider()
221
+ const def = email as ProviderDefinition<TestEmailConfig>
222
+
223
+ const instance = def.create({ apiKey: "abc", fromAddress: "noreply@example.com" }) as EmailProvider
224
+ const result = await instance.send({ to: "user@example.com", subject: "Test", text: "Hello" })
225
+ expect(result.messageId).toMatch(/^msg_abc_/)
226
+ })
227
+ })
228
+
229
+ // ─── Task 41: Widget plugin lifecycle ───────────────────────────────────────
230
+
231
+ describe("widget plugin lifecycle", () => {
232
+ beforeEach(() => {
233
+ clearPluginRegistry()
234
+ })
235
+
236
+ const makeColorPicker = () =>
237
+ defineWidget({
238
+ name: "color-picker",
239
+ label: "Colour Picker",
240
+ compatibleTypes: ["text", "varchar"],
241
+ componentPath: "./src/ColorPickerWidget.tsx",
242
+ })
243
+
244
+ it("should register and appear in getPluginsByType('widget')", () => {
245
+ const widget = makeColorPicker()
246
+ registerPlugin("@example/color-picker", widget)
247
+
248
+ const widgets = getPluginsByType("widget")
249
+ expect(widgets.length).toBe(1)
250
+ expect(widgets[0]!.packageName).toBe("@example/color-picker")
251
+ })
252
+
253
+ it("should preserve compatibleTypes", () => {
254
+ const widget = makeColorPicker()
255
+ expect(widget.compatibleTypes).toEqual(["text", "varchar"])
256
+ })
257
+
258
+ it("should preserve componentPath", () => {
259
+ const widget = makeColorPicker()
260
+ expect(widget.componentPath).toBe("./src/ColorPickerWidget.tsx")
261
+ })
262
+ })
263
+
264
+ // ─── Task 42: Plugin scaffolding and validation ─────────────────────────────
265
+
266
+ describe("plugin scaffolding and validation", () => {
267
+ beforeEach(() => {
268
+ clearPluginRegistry()
269
+ })
270
+
271
+ it("should pass isPluginDefinition for a defineFieldType result", () => {
272
+ const def = defineFieldType({
273
+ name: "slug",
274
+ pgType: "TEXT",
275
+ tsType: "string",
276
+ })
277
+
278
+ expect(isPluginDefinition(def)).toBe(true)
279
+ })
280
+
281
+ it("should return compatible: true for current API version", () => {
282
+ const def = defineFieldType({
283
+ name: "slug",
284
+ pgType: "TEXT",
285
+ tsType: "string",
286
+ })
287
+
288
+ const result = checkPluginApiVersion(def.meta)
289
+ expect(result.compatible).toBe(true)
290
+ expect(result.message).toBeUndefined()
291
+ })
292
+
293
+ it("should return compatible: false for a mismatched API version", () => {
294
+ const def = defineFieldType({
295
+ name: "slug",
296
+ pgType: "TEXT",
297
+ tsType: "string",
298
+ meta: {
299
+ name: "slug",
300
+ description: "Slug field",
301
+ types: ["field"],
302
+ pluginApi: 99,
303
+ },
304
+ })
305
+
306
+ const result = checkPluginApiVersion(def.meta)
307
+ expect(result.compatible).toBe(false)
308
+ expect(result.message).toContain("v99")
309
+ expect(result.message).toContain(`v${PLUGIN_API_VERSION}`)
310
+ })
311
+
312
+ it("should have correct meta fields on the definition", () => {
313
+ const def = defineFieldType({
314
+ name: "slug",
315
+ pgType: "TEXT",
316
+ tsType: "string",
317
+ meta: {
318
+ name: "slug",
319
+ description: "URL slug field type",
320
+ types: ["field"],
321
+ pluginApi: PLUGIN_API_VERSION,
322
+ },
323
+ })
324
+
325
+ expect(def.meta).toBeDefined()
326
+ expect(def.meta!.name).toBe("slug")
327
+ expect(def.meta!.description).toBe("URL slug field type")
328
+ expect(def.meta!.types).toEqual(["field"])
329
+ expect(def.meta!.pluginApi).toBe(PLUGIN_API_VERSION)
330
+ })
331
+
332
+ it("should return false for isPluginDefinition on non-plugin values", () => {
333
+ expect(isPluginDefinition(null)).toBe(false)
334
+ expect(isPluginDefinition(undefined)).toBe(false)
335
+ expect(isPluginDefinition(42)).toBe(false)
336
+ expect(isPluginDefinition({ name: "not a plugin" })).toBe(false)
337
+ })
338
+ })
339
+
340
+ // ─── Task 43: Plugin conflict detection ─────────────────────────────────────
341
+
342
+ describe("plugin conflict detection", () => {
343
+ beforeEach(() => {
344
+ clearPluginRegistry()
345
+ })
346
+
347
+ it("should detect conflicts when two field types share the same name", () => {
348
+ const phoneA = defineFieldType({ name: "phone", pgType: "TEXT", tsType: "string" })
349
+ const phoneB = defineFieldType({ name: "phone", pgType: "VARCHAR(20)", tsType: "string" })
350
+
351
+ const conflicts = detectConflicts([
352
+ { packageName: "@acme/phone", definition: phoneA },
353
+ { packageName: "@other/phone", definition: phoneB },
354
+ ])
355
+
356
+ expect(conflicts.length).toBe(1)
357
+ expect(conflicts[0]!.packages).toContain("@acme/phone")
358
+ expect(conflicts[0]!.packages).toContain("@other/phone")
359
+ expect(conflicts[0]!.type).toBe("field")
360
+ expect(conflicts[0]!.name).toBe("phone")
361
+ })
362
+
363
+ it("should include both package names in the conflict message", () => {
364
+ const phoneA = defineFieldType({ name: "phone", pgType: "TEXT", tsType: "string" })
365
+ const phoneB = defineFieldType({ name: "phone", pgType: "TEXT", tsType: "string" })
366
+
367
+ const conflicts = detectConflicts([
368
+ { packageName: "@acme/phone", definition: phoneA },
369
+ { packageName: "@other/phone", definition: phoneB },
370
+ ])
371
+
372
+ expect(conflicts[0]!.message).toContain("@acme/phone")
373
+ expect(conflicts[0]!.message).toContain("@other/phone")
374
+ })
375
+
376
+ it("should report no conflicts when field types have different names", () => {
377
+ const phone = defineFieldType({ name: "phone", pgType: "TEXT", tsType: "string" })
378
+ const email = defineFieldType({ name: "email", pgType: "TEXT", tsType: "string" })
379
+
380
+ const conflicts = detectConflicts([
381
+ { packageName: "@acme/phone", definition: phone },
382
+ { packageName: "@acme/email", definition: email },
383
+ ])
384
+
385
+ expect(conflicts.length).toBe(0)
386
+ })
387
+
388
+ it("should detect conflicts for same-name composites", () => {
389
+ const seoA = defineComposite({ name: "seo", label: "SEO A", fields: [] })
390
+ const seoB = defineComposite({ name: "seo", label: "SEO B", fields: [] })
391
+
392
+ const conflicts = detectConflicts([
393
+ { packageName: "@acme/seo", definition: seoA },
394
+ { packageName: "@other/seo", definition: seoB },
395
+ ])
396
+
397
+ expect(conflicts.length).toBe(1)
398
+ expect(conflicts[0]!.type).toBe("composite")
399
+ expect(conflicts[0]!.name).toBe("seo")
400
+ })
401
+
402
+ it("should detect conflicts for same-name widgets", () => {
403
+ const cpA = defineWidget({
404
+ name: "color-picker",
405
+ label: "CP A",
406
+ compatibleTypes: ["text"],
407
+ componentPath: "./a.tsx",
408
+ })
409
+ const cpB = defineWidget({
410
+ name: "color-picker",
411
+ label: "CP B",
412
+ compatibleTypes: ["text"],
413
+ componentPath: "./b.tsx",
414
+ })
415
+
416
+ const conflicts = detectConflicts([
417
+ { packageName: "@acme/cp", definition: cpA },
418
+ { packageName: "@other/cp", definition: cpB },
419
+ ])
420
+
421
+ expect(conflicts.length).toBe(1)
422
+ expect(conflicts[0]!.type).toBe("widget")
423
+ expect(conflicts[0]!.name).toBe("color-picker")
424
+ })
425
+ })
426
+
427
+ // ─── Task 44: Incompatible API version ──────────────────────────────────────
428
+
429
+ describe("incompatible plugin API version", () => {
430
+ beforeEach(() => {
431
+ clearPluginRegistry()
432
+ })
433
+
434
+ it("should mark a plugin with future API version as incompatible", () => {
435
+ const def = defineFieldType({
436
+ name: "future-field",
437
+ pgType: "TEXT",
438
+ tsType: "string",
439
+ meta: {
440
+ name: "future-field",
441
+ description: "A field from the future",
442
+ types: ["field"],
443
+ pluginApi: 99,
444
+ },
445
+ })
446
+
447
+ const registered = registerPlugin("@example/future-field", def)
448
+
449
+ expect(registered.status).toBe("incompatible")
450
+ expect(registered.incompatibleReason).toBeDefined()
451
+ expect(registered.incompatibleReason).toContain("v99")
452
+ expect(registered.incompatibleReason).toContain(`v${PLUGIN_API_VERSION}`)
453
+ })
454
+
455
+ it("should mark a plugin with current API version as active", () => {
456
+ const def = defineFieldType({
457
+ name: "current-field",
458
+ pgType: "TEXT",
459
+ tsType: "string",
460
+ meta: {
461
+ name: "current-field",
462
+ description: "A compatible field",
463
+ types: ["field"],
464
+ pluginApi: PLUGIN_API_VERSION,
465
+ },
466
+ })
467
+
468
+ const registered = registerPlugin("@example/current-field", def)
469
+
470
+ expect(registered.status).toBe("active")
471
+ expect(registered.incompatibleReason).toBeUndefined()
472
+ })
473
+ })
474
+
475
+ // ─── Task 45: Remove plugin with schema references ─────────────────────────
476
+
477
+ describe("remove plugin with schema references", () => {
478
+ beforeEach(() => {
479
+ clearPluginRegistry()
480
+ })
481
+
482
+ it("should register, clear, and verify the registry is empty", () => {
483
+ const phone = defineFieldType({
484
+ name: "phone",
485
+ pgType: "TEXT",
486
+ tsType: "string",
487
+ })
488
+
489
+ registerPlugin("@example/phone-field", phone)
490
+ expect(getFieldTypePlugin("phone")).toBeDefined()
491
+
492
+ clearPluginRegistry()
493
+
494
+ expect(getFieldTypePlugin("phone")).toBeUndefined()
495
+ expect(getRegisteredPlugins()).toHaveLength(0)
496
+ })
497
+
498
+ it("should allow re-registration after clearing", () => {
499
+ const phone = defineFieldType({
500
+ name: "phone",
501
+ pgType: "TEXT",
502
+ tsType: "string",
503
+ })
504
+
505
+ registerPlugin("@example/phone-field", phone)
506
+ clearPluginRegistry()
507
+ expect(getRegisteredPlugins()).toHaveLength(0)
508
+
509
+ registerPlugin("@example/phone-field", phone)
510
+ expect(getFieldTypePlugin("phone")).toBeDefined()
511
+ expect(getRegisteredPlugins()).toHaveLength(1)
512
+ })
513
+
514
+ it("should clear all plugin types from the registry", () => {
515
+ const phone = defineFieldType({ name: "phone", pgType: "TEXT", tsType: "string" })
516
+ const seo = defineComposite({ name: "seo", label: "SEO", fields: [] })
517
+ const widget = defineWidget({
518
+ name: "picker",
519
+ label: "Picker",
520
+ compatibleTypes: ["text"],
521
+ componentPath: "./picker.tsx",
522
+ })
523
+
524
+ registerPlugin("@example/phone", phone)
525
+ registerPlugin("@example/seo", seo)
526
+ registerPlugin("@example/picker", widget)
527
+ expect(getRegisteredPlugins()).toHaveLength(3)
528
+
529
+ clearPluginRegistry()
530
+
531
+ expect(getRegisteredPlugins()).toHaveLength(0)
532
+ expect(getPluginsByType("field")).toHaveLength(0)
533
+ expect(getPluginsByType("composite")).toHaveLength(0)
534
+ expect(getPluginsByType("widget")).toHaveLength(0)
535
+ })
536
+ })