@omg-dev/server 0.4.24
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/dist/index.mjs +3278 -0
- package/dist/trigger-scan.mjs +95 -0
- package/package.json +40 -0
- package/src/auto-crud.ts +244 -0
- package/src/billing.ts +297 -0
- package/src/broker.ts +85 -0
- package/src/codec.ts +41 -0
- package/src/ctx.ts +33 -0
- package/src/db.ts +258 -0
- package/src/dispatcher.ts +257 -0
- package/src/http-error.ts +20 -0
- package/src/index.ts +702 -0
- package/src/migrator.ts +167 -0
- package/src/notifications.ts +628 -0
- package/src/predicate.ts +440 -0
- package/src/storage.ts +384 -0
- package/src/subscriptions.ts +654 -0
- package/src/test/auto-crud.test.ts +385 -0
- package/src/test/dispatcher.test.ts +271 -0
- package/src/test/migrator.test.ts +166 -0
- package/src/test/notifications.test.ts +96 -0
- package/src/test/predicate.test.ts +267 -0
- package/src/test/schema-swap.test.ts +252 -0
- package/src/test/security.test.ts +323 -0
- package/src/test/subscriptions.test.ts +878 -0
- package/src/test/trigger-scan.test.ts +78 -0
- package/src/trigger-scan.ts +173 -0
- package/src/triggers.ts +837 -0
- package/src/web-push.d.ts +18 -0
- package/src/workflows.test.ts +127 -0
- package/src/workflows.ts +438 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Trigger scanner (moved from vite-plugin) + cron driver selection.
|
|
2
|
+
|
|
3
|
+
import { describe, test, expect, afterEach } from "bun:test"
|
|
4
|
+
import fs from "node:fs"
|
|
5
|
+
import os from "node:os"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import { extractTriggers, scanTriggers, writeTriggersManifest, TriggerScanError } from "../trigger-scan.ts"
|
|
8
|
+
import { setCronDriver } from "../triggers.ts"
|
|
9
|
+
|
|
10
|
+
const tmpRoots: string[] = []
|
|
11
|
+
function makeRoot(files: Record<string, string>): string {
|
|
12
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "vibes-trigger-scan-"))
|
|
13
|
+
tmpRoots.push(root)
|
|
14
|
+
fs.mkdirSync(path.join(root, "functions"), { recursive: true })
|
|
15
|
+
for (const [name, source] of Object.entries(files)) {
|
|
16
|
+
fs.writeFileSync(path.join(root, "functions", name), source, "utf-8")
|
|
17
|
+
}
|
|
18
|
+
return root
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const root of tmpRoots.splice(0)) {
|
|
23
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
24
|
+
}
|
|
25
|
+
// Reset to default so driver state doesn't leak across tests.
|
|
26
|
+
setCronDriver("orchestrator")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("extractTriggers", () => {
|
|
30
|
+
test("detects exported cron() and on() with literal first args", () => {
|
|
31
|
+
const src = [
|
|
32
|
+
`export const purge = cron("0 3 * * *", async () => {})`,
|
|
33
|
+
`export const welcome = on('user.signup', async (_ctx, p) => {})`,
|
|
34
|
+
].join("\n")
|
|
35
|
+
const out = extractTriggers(src, "/x/cleanup.ts", "cleanup")
|
|
36
|
+
expect(out).toEqual([
|
|
37
|
+
expect.objectContaining({ handler: "cleanup.purge", kind: "cron", key: "0 3 * * *" }),
|
|
38
|
+
expect.objectContaining({ handler: "cleanup.welcome", kind: "on", key: "user.signup" }),
|
|
39
|
+
])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("throws loudly on a non-literal schedule", () => {
|
|
43
|
+
expect(() =>
|
|
44
|
+
extractTriggers(`export const p = cron(SCHEDULE, async () => {})`, "/x/a.ts", "a"),
|
|
45
|
+
).toThrow(TriggerScanError)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe("writeTriggersManifest", () => {
|
|
50
|
+
test("scans functions/ and writes .vibes/triggers.json", async () => {
|
|
51
|
+
const root = makeRoot({
|
|
52
|
+
"jobs.ts": `import { cron } from "@omg-dev/server"\nexport const tick = cron("*/10 * * * *", async () => {})\n`,
|
|
53
|
+
})
|
|
54
|
+
const written = await writeTriggersManifest(root)
|
|
55
|
+
expect(written.length).toBe(1)
|
|
56
|
+
const onDisk = JSON.parse(fs.readFileSync(path.join(root, ".vibes", "triggers.json"), "utf-8"))
|
|
57
|
+
expect(onDisk[0]).toMatchObject({ handler: "jobs.tick", kind: "cron", key: "*/10 * * * *" })
|
|
58
|
+
// Matches what scanTriggers returns directly.
|
|
59
|
+
expect(await scanTriggers(root)).toEqual(written)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("no functions/ dir → empty manifest, no throw", async () => {
|
|
63
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "vibes-trigger-scan-"))
|
|
64
|
+
tmpRoots.push(root)
|
|
65
|
+
expect(await writeTriggersManifest(root)).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe("setCronDriver", () => {
|
|
70
|
+
test("accepts the two known drivers", () => {
|
|
71
|
+
setCronDriver("in-process")
|
|
72
|
+
setCronDriver("orchestrator")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("fails loud on anything else", () => {
|
|
76
|
+
expect(() => setCronDriver("inprocess" as never)).toThrow(/unknown cron driver/)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Trigger scanner — detects `cron("…", fn)` and `on("…", fn)` call sites in
|
|
2
|
+
// functions/*.ts and emits a registry consumed by @omg-dev/server's in-process
|
|
3
|
+
// scheduler (dev) and the orchestrator's cron+event queue (prod).
|
|
4
|
+
//
|
|
5
|
+
// Lives in @omg-dev/server (moved from @omg-dev/vite-plugin) so non-vite hosts —
|
|
6
|
+
// the control-plane container, self-host single-container bundles — can scan
|
|
7
|
+
// + write .vibes/triggers.json at boot; the vite-plugin re-imports it from
|
|
8
|
+
// here for the build-time path. Pure node:fs/path, no vite dependency.
|
|
9
|
+
//
|
|
10
|
+
// Convention: a trigger MUST be declared as a top-level export:
|
|
11
|
+
//
|
|
12
|
+
// export const purge = cron("0 3 * * *", async (ctx) => { ... })
|
|
13
|
+
// export const welcome = on("user.signup", async (ctx, payload) => { ... })
|
|
14
|
+
//
|
|
15
|
+
// The first argument must be a *string literal* — dynamic schedules / topics
|
|
16
|
+
// can't be persisted ahead of time. We fail the build loudly when we detect
|
|
17
|
+
// a non-literal first arg so the user can't silently lose a trigger.
|
|
18
|
+
//
|
|
19
|
+
// Scanner shape mirrors scanner.ts (regex + walk). For correctness we use a
|
|
20
|
+
// stricter export-binding regex that also captures the call expression.
|
|
21
|
+
|
|
22
|
+
import fs from "node:fs"
|
|
23
|
+
import path from "node:path"
|
|
24
|
+
|
|
25
|
+
export type TriggerKind = "cron" | "on" | "storage:upload" | "storage:delete"
|
|
26
|
+
|
|
27
|
+
export interface ScannedTrigger {
|
|
28
|
+
/** Dispatch identifier: "<file-basename>.<exportName>" e.g. "cleanup.purge". */
|
|
29
|
+
handler: string
|
|
30
|
+
kind: TriggerKind
|
|
31
|
+
/** Cron expression (kind=cron) or topic string (kind=on). */
|
|
32
|
+
key: string
|
|
33
|
+
/** Absolute file path of the function module (for dev import). */
|
|
34
|
+
module: string
|
|
35
|
+
/** Exported binding name inside the module. */
|
|
36
|
+
exportName: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** scanTriggers walks `<root>/functions/` and returns every detected trigger. */
|
|
40
|
+
export async function scanTriggers(root: string): Promise<ScannedTrigger[]> {
|
|
41
|
+
const fnDir = path.join(root, "functions")
|
|
42
|
+
if (!fs.existsSync(fnDir)) return []
|
|
43
|
+
|
|
44
|
+
const out: ScannedTrigger[] = []
|
|
45
|
+
walk(fnDir, out)
|
|
46
|
+
// Also recurse into functions/api/ — same convention as scanner.ts.
|
|
47
|
+
const apiDir = path.join(fnDir, "api")
|
|
48
|
+
if (fs.existsSync(apiDir) && fs.statSync(apiDir).isDirectory()) {
|
|
49
|
+
walk(apiDir, out)
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function walk(dir: string, out: ScannedTrigger[]): void {
|
|
55
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
56
|
+
if (entry.isDirectory()) continue
|
|
57
|
+
if (!entry.name.endsWith(".ts") || entry.name.endsWith(".d.ts")) continue
|
|
58
|
+
const filePath = path.join(dir, entry.name)
|
|
59
|
+
const source = fs.readFileSync(filePath, "utf-8")
|
|
60
|
+
const basename = path.basename(entry.name, ".ts")
|
|
61
|
+
out.push(...extractTriggers(source, filePath, basename))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Regex extraction ─────────────────────────────────────────────────────────
|
|
66
|
+
//
|
|
67
|
+
// Match either:
|
|
68
|
+
// export const NAME = cron("SCHEDULE", ...)
|
|
69
|
+
// export const NAME = on("TOPIC", ...)
|
|
70
|
+
// export const NAME: SomeType = cron(...) ← type annotation tolerated
|
|
71
|
+
//
|
|
72
|
+
// We rely on the cron/on call appearing on the same line as the export
|
|
73
|
+
// declaration. Multi-line literal-string-then-call is rare; if we miss it,
|
|
74
|
+
// the orchestrator simply doesn't fire the trigger — the build still
|
|
75
|
+
// succeeds (no silent data corruption). For clearer detection of mis-declared
|
|
76
|
+
// triggers we also scan for the call-site without `export const` prefix and
|
|
77
|
+
// emit a structured error.
|
|
78
|
+
|
|
79
|
+
const EXPORT_TRIGGER_RE = /^export\s+const\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*(cron|on)\s*\(\s*(.+?)\s*,/gm
|
|
80
|
+
|
|
81
|
+
// `storage.onUpload(handler)` / `storage.onDelete(handler)` have no first-arg
|
|
82
|
+
// literal — the "key" is implicit (file uploads vs deletes for this app). We
|
|
83
|
+
// emit them with an empty key string; the dispatcher treats matching by kind
|
|
84
|
+
// rather than (kind, key).
|
|
85
|
+
const EXPORT_STORAGE_HOOK_RE = /^export\s+const\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*storage\.(onUpload|onDelete)\s*\(/gm
|
|
86
|
+
|
|
87
|
+
// Detect a `cron(…` or `on(…` call that isn't bound to a top-level export.
|
|
88
|
+
// Used to surface helpful errors ("declare as `export const`...").
|
|
89
|
+
const ORPHAN_TRIGGER_RE = /(?<!export\s+const\s+\w+\s*=\s*)\b(cron|on)\s*\(/g
|
|
90
|
+
|
|
91
|
+
export class TriggerScanError extends Error {
|
|
92
|
+
constructor(public file: string, public detail: string) {
|
|
93
|
+
super(`[vibes:triggers] ${file}: ${detail}`)
|
|
94
|
+
this.name = "TriggerScanError"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function extractTriggers(source: string, filePath: string, basename: string): ScannedTrigger[] {
|
|
99
|
+
const out: ScannedTrigger[] = []
|
|
100
|
+
// Strip line + block comments so trigger-shaped strings inside `// cron(...)`
|
|
101
|
+
// comments don't get picked up. Simple regex pass — good enough for the
|
|
102
|
+
// patterns we expect.
|
|
103
|
+
const stripped = source
|
|
104
|
+
.replace(/\/\/.*$/gm, "")
|
|
105
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
106
|
+
|
|
107
|
+
let m: RegExpExecArray | null
|
|
108
|
+
EXPORT_TRIGGER_RE.lastIndex = 0
|
|
109
|
+
while ((m = EXPORT_TRIGGER_RE.exec(stripped)) !== null) {
|
|
110
|
+
const [, exportName, kind, firstArg] = m
|
|
111
|
+
const lit = parseStringLiteral(firstArg!)
|
|
112
|
+
if (lit === null) {
|
|
113
|
+
throw new TriggerScanError(
|
|
114
|
+
filePath,
|
|
115
|
+
`${kind}() first arg must be a string literal — got: ${firstArg!.slice(0, 60)}`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
out.push({
|
|
119
|
+
handler: `${basename}.${exportName!}`,
|
|
120
|
+
kind: kind as TriggerKind,
|
|
121
|
+
key: lit,
|
|
122
|
+
module: filePath,
|
|
123
|
+
exportName: exportName!,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
// storage.onUpload(...) / storage.onDelete(...) — no string literal key.
|
|
127
|
+
EXPORT_STORAGE_HOOK_RE.lastIndex = 0
|
|
128
|
+
while ((m = EXPORT_STORAGE_HOOK_RE.exec(stripped)) !== null) {
|
|
129
|
+
const [, exportName, hookName] = m
|
|
130
|
+
out.push({
|
|
131
|
+
handler: `${basename}.${exportName!}`,
|
|
132
|
+
kind: hookName === "onUpload" ? "storage:upload" : "storage:delete",
|
|
133
|
+
key: "", // not used for storage hooks; dispatcher matches by kind
|
|
134
|
+
module: filePath,
|
|
135
|
+
exportName: exportName!,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Parse a single-line first-arg into a string literal. Returns null when the
|
|
143
|
+
* input isn't a plain "..." or '...' literal (e.g. it's a template literal, a
|
|
144
|
+
* variable reference, or a runtime expression).
|
|
145
|
+
*/
|
|
146
|
+
function parseStringLiteral(raw: string): string | null {
|
|
147
|
+
const trimmed = raw.trim()
|
|
148
|
+
if (trimmed.length < 2) return null
|
|
149
|
+
const first = trimmed[0]
|
|
150
|
+
if ((first === '"' || first === "'") && trimmed.endsWith(first)) {
|
|
151
|
+
// Disallow embedded interpolation just in case.
|
|
152
|
+
const inner = trimmed.slice(1, -1)
|
|
153
|
+
if (inner.includes("${")) return null
|
|
154
|
+
// Unescape \" and \\ — we don't need full JSON unescaping here.
|
|
155
|
+
return inner.replace(/\\(.)/g, "$1")
|
|
156
|
+
}
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Scan `<root>/functions/` and write the result to `<root>/.vibes/triggers.json`
|
|
162
|
+
* — the file `createVibesServer` loads at boot when no bundled `triggers`
|
|
163
|
+
* array is passed. For hosts without a vite build (control-plane container,
|
|
164
|
+
* self-host bundles): call this before `createVibesServer` so cron()/on()
|
|
165
|
+
* declarations in functions/*.ts actually register.
|
|
166
|
+
*/
|
|
167
|
+
export async function writeTriggersManifest(root: string): Promise<ScannedTrigger[]> {
|
|
168
|
+
const triggers = await scanTriggers(root)
|
|
169
|
+
const vibesDir = path.join(root, ".vibes")
|
|
170
|
+
fs.mkdirSync(vibesDir, { recursive: true })
|
|
171
|
+
fs.writeFileSync(path.join(vibesDir, "triggers.json"), JSON.stringify(triggers, null, 2), "utf-8")
|
|
172
|
+
return triggers
|
|
173
|
+
}
|