@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/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/dist/__tests__/plugin-integration.test.d.ts +2 -0
- package/dist/__tests__/plugin-integration.test.d.ts.map +1 -0
- package/dist/__tests__/plugin-integration.test.js +426 -0
- package/dist/__tests__/plugin-integration.test.js.map +1 -0
- package/dist/define.d.ts +121 -0
- package/dist/define.d.ts.map +1 -0
- package/dist/define.js +165 -0
- package/dist/define.js.map +1 -0
- package/dist/docgen.d.ts +14 -0
- package/dist/docgen.d.ts.map +1 -0
- package/dist/docgen.js +135 -0
- package/dist/docgen.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +122 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +282 -0
- package/dist/loader.js.map +1 -0
- package/dist/types.d.ts +341 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/package.json +28 -0
- package/src/__tests__/plugin-integration.test.ts +536 -0
- package/src/define.ts +202 -0
- package/src/docgen.ts +172 -0
- package/src/index.ts +58 -0
- package/src/loader.ts +401 -0
- package/src/types.ts +343 -0
- package/tsconfig.json +10 -0
- package/tsconfig.tsbuildinfo +1 -0
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
|
+
}
|