@nivaro/sdk 0.1.16 → 0.1.17
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/bin/lib/ext-scaffold.mjs +220 -0
- package/bin/lib/graphql-codegen.mjs +737 -0
- package/bin/nivaro.mjs +150 -29
- package/dist/index.d.ts +33 -1
- package/dist/index.js +28 -0
- package/package.json +4 -2
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension scaffolder (#1302) — `nivaro ext init <id> [--dir api/extensions]`.
|
|
3
|
+
*
|
|
4
|
+
* Writes `<dir>/<id>/` with an index.ts written against @nivaro/extension-kit
|
|
5
|
+
* (one setting, one hook, one described cron, one route behind requireAuth),
|
|
6
|
+
* a passing test on the kit's createTestContext, and a README naming the
|
|
7
|
+
* context members the starter uses. Refuses an existing directory and an id
|
|
8
|
+
* that is not kebab-case.
|
|
9
|
+
*
|
|
10
|
+
* Plain ESM so the CLI imports it directly and `node --test` covers it.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** kebab-case: lowercase words joined by single hyphens, 2–64 characters. */
|
|
16
|
+
const ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
|
17
|
+
|
|
18
|
+
/** Null when the id is usable, otherwise the reason it is not. */
|
|
19
|
+
export function validateExtensionId(id) {
|
|
20
|
+
if (typeof id !== 'string' || id.length === 0) return 'An extension id is required'
|
|
21
|
+
if (id.length < 2 || id.length > 64) return 'The id must be 2–64 characters'
|
|
22
|
+
if (!ID.test(id))
|
|
23
|
+
return `"${id}" is not kebab-case — use lowercase letters, digits and single hyphens (my-extension)`
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The directory the extension goes in when --dir is not given. */
|
|
28
|
+
export function defaultExtensionsDir(cwd = process.cwd()) {
|
|
29
|
+
if (existsSync(join(cwd, 'api', 'extensions'))) return join(cwd, 'api', 'extensions')
|
|
30
|
+
if (existsSync(join(cwd, 'extensions', 'tsconfig.json'))) return join(cwd, 'extensions')
|
|
31
|
+
return join(cwd, 'api', 'extensions')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function camel(id) {
|
|
35
|
+
return id.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The files, by name, for an extension called `id`. */
|
|
39
|
+
export function scaffoldFiles(id) {
|
|
40
|
+
// The id is written into source text and a path: only a validated one.
|
|
41
|
+
const bad = validateExtensionId(id)
|
|
42
|
+
if (bad) throw new Error(bad)
|
|
43
|
+
const label = id
|
|
44
|
+
.split('-')
|
|
45
|
+
.map((w) => w[0].toUpperCase() + w.slice(1))
|
|
46
|
+
.join(' ')
|
|
47
|
+
const counter = `${camel(id)}Creates`
|
|
48
|
+
|
|
49
|
+
const index = `import { defineExtension } from '@nivaro/extension-kit'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* ${label} — scaffolded by \`nivaro ext init ${id}\`.
|
|
53
|
+
*
|
|
54
|
+
* One of each thing an extension usually starts with: a setting an admin edits
|
|
55
|
+
* on the Extensions page, a hook on record writes, a scheduled job, and a route.
|
|
56
|
+
* The Extensions registry sheet lists each of them once the API loads this file.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** Creates seen since the last daily summary, per collection. In memory: a
|
|
60
|
+
* restart starts the count again, which is fine for a summary line. */
|
|
61
|
+
const ${counter} = new Map<string, number>()
|
|
62
|
+
|
|
63
|
+
export default defineExtension({
|
|
64
|
+
id: '${id}',
|
|
65
|
+
settings: [
|
|
66
|
+
{
|
|
67
|
+
key: 'greeting',
|
|
68
|
+
label: 'Greeting',
|
|
69
|
+
type: 'string',
|
|
70
|
+
default: 'Hello',
|
|
71
|
+
description: 'The word GET /api/${id}/hello answers with.'
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
async register(ctx) {
|
|
75
|
+
const { app, auth, hooks, cron, logger } = ctx
|
|
76
|
+
|
|
77
|
+
// Route. \`preHandler: auth.requireAuth\` is the gate: the loader records it,
|
|
78
|
+
// and a route with no gate is a warning on the readiness scorecard.
|
|
79
|
+
app.register(
|
|
80
|
+
async (f) => {
|
|
81
|
+
f.get('/${id}/hello', { preHandler: auth.requireAuth }, async () => {
|
|
82
|
+
const greeting = (await ctx.settings?.get('greeting')) ?? 'Hello'
|
|
83
|
+
return { data: { message: \`\${greeting} from ${id}\` } }
|
|
84
|
+
})
|
|
85
|
+
},
|
|
86
|
+
{ prefix: '/api' }
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
// Hook. After-hooks run once the write has landed; keep them quick, the
|
|
90
|
+
// caller waits for them.
|
|
91
|
+
hooks.after('*', 'create', async ({ collection }) => {
|
|
92
|
+
${counter}.set(collection, (${counter}.get(collection) ?? 0) + 1)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Scheduled job. The id is scoped to this extension; the description is
|
|
96
|
+
// what Background Jobs shows beside it.
|
|
97
|
+
cron.schedule(
|
|
98
|
+
'daily-summary',
|
|
99
|
+
'0 6 * * *',
|
|
100
|
+
async () => {
|
|
101
|
+
const total = [...${counter}.values()].reduce((n, v) => n + v, 0)
|
|
102
|
+
const busiest = [...${counter}.entries()].sort((a, b) => b[1] - a[1])[0]
|
|
103
|
+
${counter}.clear()
|
|
104
|
+
await ctx.logActivity({
|
|
105
|
+
action: 'daily-summary',
|
|
106
|
+
comment: busiest
|
|
107
|
+
? \`\${total} record(s) created; most in \${busiest[0]} (\${busiest[1]})\`
|
|
108
|
+
: 'No records created'
|
|
109
|
+
})
|
|
110
|
+
logger.info({ total }, '${id}: daily summary written')
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
description: 'One activity line a day counting the records created since the last.',
|
|
114
|
+
idempotent: 'safe'
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
`
|
|
120
|
+
|
|
121
|
+
const test = `import { createTestContext } from '@nivaro/extension-kit'
|
|
122
|
+
import { describe, expect, it } from 'vitest'
|
|
123
|
+
import extension from './index.js'
|
|
124
|
+
|
|
125
|
+
describe('${id}', () => {
|
|
126
|
+
it('answers the hello route with the configured greeting', async () => {
|
|
127
|
+
const ctx = createTestContext({ settings: { greeting: 'Hi' } })
|
|
128
|
+
await extension.register(ctx)
|
|
129
|
+
const res = await ctx.invoke('GET', '/api/${id}/hello')
|
|
130
|
+
expect(res).toEqual({ status: 200, body: { data: { message: 'Hi from ${id}' } } })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('counts creates and reports them from the daily job', async () => {
|
|
134
|
+
const ctx = createTestContext()
|
|
135
|
+
await extension.register(ctx)
|
|
136
|
+
await ctx.runHooks('orders', 'create', 'after', { keys: [1] })
|
|
137
|
+
await ctx.runHooks('orders', 'create', 'after', { keys: [2] })
|
|
138
|
+
await ctx.runCron('daily-summary')
|
|
139
|
+
expect(ctx.calls.activity).toEqual([
|
|
140
|
+
{ action: 'daily-summary', comment: '2 record(s) created; most in orders (2)' }
|
|
141
|
+
])
|
|
142
|
+
expect(ctx.registered.crons.get('daily-summary')?.opts?.description).toBeTruthy()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('uses no deprecated kit member', async () => {
|
|
146
|
+
const ctx = createTestContext()
|
|
147
|
+
await extension.register(ctx)
|
|
148
|
+
expect(ctx.calls.deprecations).toEqual([])
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
`
|
|
152
|
+
|
|
153
|
+
const readme = `# ${label}
|
|
154
|
+
|
|
155
|
+
Scaffolded by \`nivaro ext init ${id}\`. The API loads it from \`api/extensions/${id}/\` at boot
|
|
156
|
+
(restart the API after adding it); the Extensions page lists it with a registry sheet of
|
|
157
|
+
everything it registered.
|
|
158
|
+
|
|
159
|
+
## What it registers
|
|
160
|
+
|
|
161
|
+
| Thing | Where | Context member |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| Setting \`greeting\` | Extensions page → ${id} → Settings | \`settings\` on the export, read with \`ctx.settings.get('greeting')\` |
|
|
164
|
+
| Route \`GET /api/${id}/hello\` | behind \`requireAuth\` | \`ctx.app.register\`, gated with \`ctx.auth.requireAuth\` |
|
|
165
|
+
| Hook after every create | all collections | \`ctx.hooks.after('*', 'create', fn)\` |
|
|
166
|
+
| Daily job \`daily-summary\` | Background Jobs | \`ctx.cron.schedule(id, expression, fn, { description })\` |
|
|
167
|
+
| Activity line | Activity log | \`ctx.logActivity({ action, comment })\` |
|
|
168
|
+
|
|
169
|
+
## Context members you will reach for next
|
|
170
|
+
|
|
171
|
+
- \`ctx.database\` — knex on the instance database (raw writes skip hooks, revisions and permissions; prefer the items API for record writes).
|
|
172
|
+
- \`ctx.notifyUser(userId, { subject, message })\` — the only way to notify a person.
|
|
173
|
+
- \`ctx.callExternalApi(name, { method, path, body })\` — configured external APIs; credentials never reach the extension.
|
|
174
|
+
- \`ctx.flows.registerOperation\` / \`registerTrigger\` / \`emit\` — custom flow steps and triggers.
|
|
175
|
+
- \`ctx.readiness.registerCheck\`, \`ctx.integrity.registerCheck\` — scored checks and Data Integrity checks.
|
|
176
|
+
- \`ctx.events.publish\` / \`on\` — a durable outbox for this extension's own events.
|
|
177
|
+
- \`ctx.sql.runLong(sql)\` — one long statement outside knex's request timeout.
|
|
178
|
+
|
|
179
|
+
The full contract is \`ExtensionContext\` in \`@nivaro/extension-kit\`.
|
|
180
|
+
|
|
181
|
+
## Test and type-check
|
|
182
|
+
|
|
183
|
+
\`\`\`bash
|
|
184
|
+
cd api
|
|
185
|
+
npx vitest run extensions/${id}
|
|
186
|
+
npx tsc -p extensions/tsconfig.json
|
|
187
|
+
\`\`\`
|
|
188
|
+
|
|
189
|
+
The test runs the extension against the kit's \`createTestContext()\`: every side effect lands in
|
|
190
|
+
\`ctx.calls\`, every registration in \`ctx.registered\`, and \`ctx.calls.deprecations\` lists any
|
|
191
|
+
deprecated kit member the extension read.
|
|
192
|
+
`
|
|
193
|
+
|
|
194
|
+
return { 'index.ts': index, 'index.test.ts': test, 'README.md': readme }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Writes the extension. Throws (with the reason) on a bad id or an existing
|
|
199
|
+
* directory; answers the paths written.
|
|
200
|
+
*/
|
|
201
|
+
export function writeScaffold(id, dir) {
|
|
202
|
+
const bad = validateExtensionId(id)
|
|
203
|
+
if (bad) throw new Error(bad)
|
|
204
|
+
if (typeof dir !== 'string' || dir.length === 0 || dir.includes('\0'))
|
|
205
|
+
throw new Error('--dir must be a directory path')
|
|
206
|
+
const base = resolve(dir)
|
|
207
|
+
const target = join(base, id)
|
|
208
|
+
// The extension lands exactly one level under --dir, never beside or above it.
|
|
209
|
+
const rel = relative(base, target)
|
|
210
|
+
if (rel !== id || isAbsolute(rel)) throw new Error(`Refusing to write outside ${base}`)
|
|
211
|
+
if (existsSync(target)) throw new Error(`${target} already exists — pick another id or remove it`)
|
|
212
|
+
mkdirSync(target, { recursive: true })
|
|
213
|
+
const written = []
|
|
214
|
+
for (const [name, text] of Object.entries(scaffoldFiles(id))) {
|
|
215
|
+
const file = join(target, name)
|
|
216
|
+
writeFileSync(file, text, 'utf8')
|
|
217
|
+
written.push(file)
|
|
218
|
+
}
|
|
219
|
+
return { target, written, hasTsconfig: existsSync(join(base, 'tsconfig.json')) }
|
|
220
|
+
}
|