@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/src/loader.ts ADDED
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Plugin discovery and loading utilities.
3
+ *
4
+ * The plugin loader scans node_modules for packages with the `supatype-plugin`
5
+ * keyword or a `supatype` field in package.json, and auto-registers them.
6
+ */
7
+
8
+ import {
9
+ PLUGIN_API_VERSION,
10
+ type PluginMeta,
11
+ type PluginType,
12
+ type ProviderDefinition,
13
+ type FieldTypeDefinition,
14
+ type CompositeDefinition,
15
+ type CompositeFieldDef,
16
+ } from "./types.js"
17
+ import {
18
+ type AnyPluginDefinition,
19
+ isPluginDefinition,
20
+ checkPluginApiVersion,
21
+ } from "./define.js"
22
+
23
+ // ─── Plugin registry ─────────────────────────────────────────────────────────
24
+
25
+ export interface RegisteredPlugin {
26
+ packageName: string
27
+ meta: PluginMeta
28
+ definition: AnyPluginDefinition
29
+ status: "active" | "inactive" | "incompatible"
30
+ incompatibleReason?: string | undefined
31
+ }
32
+
33
+ const registry = new Map<string, RegisteredPlugin>()
34
+
35
+ /**
36
+ * Register a plugin definition.
37
+ */
38
+ export function registerPlugin(packageName: string, definition: AnyPluginDefinition): RegisteredPlugin {
39
+ const meta: PluginMeta = definition.meta ?? {
40
+ name: packageName,
41
+ description: "",
42
+ types: [definition.__supatype],
43
+ pluginApi: PLUGIN_API_VERSION,
44
+ }
45
+
46
+ const compat = checkPluginApiVersion(meta)
47
+
48
+ const plugin: RegisteredPlugin = {
49
+ packageName,
50
+ meta,
51
+ definition,
52
+ status: compat.compatible ? "active" : "incompatible",
53
+ ...(compat.message !== undefined ? { incompatibleReason: compat.message } : {}),
54
+ }
55
+
56
+ // Check for conflicts
57
+ const existing = registry.get(definition.__supatype === "field"
58
+ ? `field:${(definition as { name: string }).name}`
59
+ : definition.__supatype === "provider"
60
+ ? `provider:${(definition as { category: string }).category}:${(definition as { name: string }).name}`
61
+ : `${definition.__supatype}:${(definition as { name: string }).name}`)
62
+
63
+ if (existing) {
64
+ console.warn(
65
+ `[plugins] conflict: "${packageName}" and "${existing.packageName}" both define ` +
66
+ `${definition.__supatype} "${(definition as { name: string }).name}". ` +
67
+ `Resolve in supatype.config.ts plugins array.`,
68
+ )
69
+ }
70
+
71
+ const key = `${definition.__supatype}:${(definition as { name: string }).name}`
72
+ registry.set(key, plugin)
73
+ return plugin
74
+ }
75
+
76
+ /**
77
+ * Get all registered plugins.
78
+ */
79
+ export function getRegisteredPlugins(): RegisteredPlugin[] {
80
+ return Array.from(registry.values())
81
+ }
82
+
83
+ /**
84
+ * Get plugins by type.
85
+ */
86
+ export function getPluginsByType(type: PluginType): RegisteredPlugin[] {
87
+ return getRegisteredPlugins().filter(p => p.definition.__supatype === type)
88
+ }
89
+
90
+ /**
91
+ * Get a specific field type plugin by name.
92
+ */
93
+ export function getFieldTypePlugin(name: string): RegisteredPlugin | undefined {
94
+ return registry.get(`field:${name}`)
95
+ }
96
+
97
+ /**
98
+ * Get a specific provider plugin by category and name.
99
+ */
100
+ export function getProviderPlugin(category: string, name: string): RegisteredPlugin | undefined {
101
+ return registry.get(`provider:${category}:${name}`)
102
+ }
103
+
104
+ /**
105
+ * Clear the plugin registry (for testing).
106
+ */
107
+ export function clearPluginRegistry(): void {
108
+ registry.clear()
109
+ }
110
+
111
+ // ─── Package.json discovery ──────────────────────────────────────────────────
112
+
113
+ export interface PluginPackageInfo {
114
+ name: string
115
+ version: string
116
+ description: string
117
+ supatype?: {
118
+ pluginApi?: number | undefined
119
+ types?: PluginType[] | undefined
120
+ } | undefined
121
+ keywords?: string[] | undefined
122
+ }
123
+
124
+ /**
125
+ * Check if a package.json represents a Supatype plugin.
126
+ */
127
+ export function isSupatypePlugin(pkg: PluginPackageInfo): boolean {
128
+ // Has a `supatype` field in package.json
129
+ if (pkg.supatype) return true
130
+ // Has `supatype-plugin` keyword
131
+ if (pkg.keywords?.includes("supatype-plugin")) return true
132
+ return false
133
+ }
134
+
135
+ // ─── Load order ──────────────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Sort plugins by load order: providers first, then fields & composites, then widgets.
139
+ */
140
+ export function sortByLoadOrder(plugins: RegisteredPlugin[]): RegisteredPlugin[] {
141
+ const order: Record<string, number> = {
142
+ provider: 0,
143
+ field: 1,
144
+ composite: 2,
145
+ widget: 3,
146
+ }
147
+
148
+ return [...plugins].sort((a, b) => {
149
+ const aOrder = order[a.definition.__supatype] ?? 99
150
+ const bOrder = order[b.definition.__supatype] ?? 99
151
+ return aOrder - bOrder
152
+ })
153
+ }
154
+
155
+ // ─── Conflict detection ──────────────────────────────────────────────────────
156
+
157
+ export interface PluginConflict {
158
+ type: PluginType
159
+ name: string
160
+ packages: string[]
161
+ message: string
162
+ }
163
+
164
+ /**
165
+ * Detect conflicts between a set of plugin definitions.
166
+ */
167
+ export function detectConflicts(plugins: Array<{ packageName: string; definition: AnyPluginDefinition }>): PluginConflict[] {
168
+ const seen = new Map<string, string[]>()
169
+ const conflicts: PluginConflict[] = []
170
+
171
+ for (const p of plugins) {
172
+ const key = `${p.definition.__supatype}:${(p.definition as { name: string }).name}`
173
+ const existing = seen.get(key) ?? []
174
+ existing.push(p.packageName)
175
+ seen.set(key, existing)
176
+ }
177
+
178
+ for (const [key, packages] of seen) {
179
+ if (packages.length > 1) {
180
+ const [type, name] = key.split(":", 2)
181
+ conflicts.push({
182
+ type: type as PluginType,
183
+ name: name!,
184
+ packages,
185
+ message: `Multiple plugins define ${type} "${name}": ${packages.join(", ")}. ` +
186
+ `Resolve by explicitly listing plugins in supatype.config.ts.`,
187
+ })
188
+ }
189
+ }
190
+
191
+ return conflicts
192
+ }
193
+
194
+ // ─── Provider validation (Task 11) ──────────────────────────────────────────
195
+
196
+ /** Optional methods on each provider category that should trigger warnings, not errors. */
197
+ const OPTIONAL_PROVIDER_METHODS: Record<string, string[]> = {
198
+ commerce: ["createSubscription", "cancelSubscription", "getPortalUrl"],
199
+ tracking: ["groupIdentify", "getFeatureFlag"],
200
+ email: ["sendTemplate", "sendBatch"],
201
+ storage: [],
202
+ auth: [],
203
+ ssl: [],
204
+ ai: [],
205
+ search: [],
206
+ "push-notification": [],
207
+ }
208
+
209
+ export interface ProviderValidationResult {
210
+ valid: boolean
211
+ warnings: string[]
212
+ errors: string[]
213
+ }
214
+
215
+ /**
216
+ * Validate configured providers at push time.
217
+ *
218
+ * For each provider:
219
+ * - Checks the definition has a `create()` method
220
+ * - Checks required `configSchema` fields are present in the config
221
+ * - Warns about optional interface methods not implemented
222
+ */
223
+ export function validateProviders(
224
+ configuredProviders: Array<{ name: string; config?: Record<string, unknown> | undefined }>,
225
+ ): ProviderValidationResult {
226
+ const warnings: string[] = []
227
+ const errors: string[] = []
228
+
229
+ for (const cp of configuredProviders) {
230
+ // Find matching registered provider plugin
231
+ const registered = getRegisteredPlugins().find(
232
+ p => p.definition.__supatype === "provider" && (p.definition as ProviderDefinition).name === cp.name,
233
+ )
234
+
235
+ if (!registered) {
236
+ errors.push(`Provider "${cp.name}" is configured but no matching plugin is registered.`)
237
+ continue
238
+ }
239
+
240
+ if (registered.status === "incompatible") {
241
+ errors.push(`Provider "${cp.name}" is incompatible: ${registered.incompatibleReason ?? "unknown reason"}.`)
242
+ continue
243
+ }
244
+
245
+ const def = registered.definition as ProviderDefinition
246
+
247
+ // Check create() method
248
+ if (typeof def.create !== "function") {
249
+ errors.push(`Provider "${cp.name}" definition is missing a create() method.`)
250
+ }
251
+
252
+ // Check required configSchema fields are present in config
253
+ if (def.configSchema && cp.config) {
254
+ for (const [key, schema] of Object.entries(def.configSchema)) {
255
+ if (schema.required === true && (cp.config[key] === undefined || cp.config[key] === null)) {
256
+ errors.push(`Provider "${cp.name}" is missing required config field "${key}".`)
257
+ }
258
+ }
259
+ } else if (def.configSchema && !cp.config) {
260
+ const requiredFields = Object.entries(def.configSchema)
261
+ .filter(([, s]) => s.required === true)
262
+ .map(([k]) => k)
263
+ if (requiredFields.length > 0) {
264
+ errors.push(
265
+ `Provider "${cp.name}" has required config fields (${requiredFields.join(", ")}) but no config was provided.`,
266
+ )
267
+ }
268
+ }
269
+
270
+ // Warn about optional interface methods
271
+ const optionalMethods = OPTIONAL_PROVIDER_METHODS[def.category] ?? []
272
+ if (optionalMethods.length > 0 && typeof def.create === "function") {
273
+ for (const method of optionalMethods) {
274
+ // We can only check if the create function produces an instance with
275
+ // the method at runtime, so we note the optional methods as warnings.
276
+ warnings.push(
277
+ `Provider "${cp.name}" (${def.category}): optional method "${method}" may not be implemented.`,
278
+ )
279
+ }
280
+ }
281
+ }
282
+
283
+ return {
284
+ valid: errors.length === 0,
285
+ warnings,
286
+ errors,
287
+ }
288
+ }
289
+
290
+ // ─── Plugin field type map (Task 15) ────────────────────────────────────────
291
+
292
+ /**
293
+ * Returns a map of custom field type names to their declared `tsType` values
294
+ * from registered field type plugins. Consumed by the engine's type generator.
295
+ */
296
+ export function getPluginFieldTypeMap(): Map<string, string> {
297
+ const map = new Map<string, string>()
298
+
299
+ for (const plugin of getPluginsByType("field")) {
300
+ if (plugin.status !== "active") continue
301
+ const def = plugin.definition as FieldTypeDefinition & { __supatype: "field" }
302
+ map.set(def.name, def.tsType)
303
+ }
304
+
305
+ return map
306
+ }
307
+
308
+ // ─── Plugin field Postgres type map (Task 16) ───────────────────────────────
309
+
310
+ export interface FieldPgTypeInfo {
311
+ pgType: string
312
+ constraints?: string[] | undefined
313
+ }
314
+
315
+ /**
316
+ * Returns a map of custom field type names to their Postgres type and optional
317
+ * constraints. Consumed by the engine's migration system.
318
+ */
319
+ export function getPluginFieldPgTypeMap(): Map<string, FieldPgTypeInfo> {
320
+ const map = new Map<string, FieldPgTypeInfo>()
321
+
322
+ for (const plugin of getPluginsByType("field")) {
323
+ if (plugin.status !== "active") continue
324
+ const def = plugin.definition as FieldTypeDefinition & { __supatype: "field" }
325
+ map.set(def.name, {
326
+ pgType: def.pgType,
327
+ ...(def.constraints !== undefined ? { constraints: def.constraints } : {}),
328
+ })
329
+ }
330
+
331
+ return map
332
+ }
333
+
334
+ // ─── Composite expansion (Task 18) ──────────────────────────────────────────
335
+
336
+ export interface CompositeExpansion {
337
+ fields: Array<{
338
+ name: string
339
+ pgType: string
340
+ required: boolean
341
+ defaultValue?: unknown | undefined
342
+ }>
343
+ installSQL?: string | undefined
344
+ uninstallSQL?: string | undefined
345
+ }
346
+
347
+ /** Built-in type-to-pgType mapping for common types. */
348
+ const BUILTIN_PG_TYPES: Record<string, string> = {
349
+ text: "TEXT",
350
+ varchar: "VARCHAR",
351
+ boolean: "BOOLEAN",
352
+ integer: "INTEGER",
353
+ bigint: "BIGINT",
354
+ float: "FLOAT8",
355
+ decimal: "NUMERIC",
356
+ json: "JSONB",
357
+ jsonb: "JSONB",
358
+ date: "DATE",
359
+ timestamp: "TIMESTAMPTZ",
360
+ uuid: "UUID",
361
+ }
362
+
363
+ /**
364
+ * Expand registered composite plugins into their concrete field definitions
365
+ * with resolved Postgres types.
366
+ *
367
+ * For each composite field, the type is resolved by:
368
+ * 1. Checking registered plugin field types (from getPluginFieldPgTypeMap)
369
+ * 2. Falling back to built-in type mappings
370
+ * 3. Defaulting to TEXT if no match is found
371
+ */
372
+ export function expandPluginComposites(): Map<string, CompositeExpansion> {
373
+ const result = new Map<string, CompositeExpansion>()
374
+ const pluginFieldPgTypes = getPluginFieldPgTypeMap()
375
+
376
+ for (const plugin of getPluginsByType("composite")) {
377
+ if (plugin.status !== "active") continue
378
+ const def = plugin.definition as CompositeDefinition & { __supatype: "composite" }
379
+
380
+ const fields = def.fields.map((f: CompositeFieldDef) => {
381
+ // Resolve pgType: plugin fields first, then built-ins, then TEXT fallback
382
+ const pluginPg = pluginFieldPgTypes.get(f.type)
383
+ const pgType = pluginPg?.pgType ?? BUILTIN_PG_TYPES[f.type] ?? "TEXT"
384
+
385
+ return {
386
+ name: f.name,
387
+ pgType,
388
+ required: f.required === true,
389
+ ...(f.defaultValue !== undefined ? { defaultValue: f.defaultValue } : {}),
390
+ }
391
+ })
392
+
393
+ result.set(def.name, {
394
+ fields,
395
+ ...(def.installSQL !== undefined ? { installSQL: def.installSQL } : {}),
396
+ ...(def.uninstallSQL !== undefined ? { uninstallSQL: def.uninstallSQL } : {}),
397
+ })
398
+ }
399
+
400
+ return result
401
+ }