@toist/aja 0.6.1 → 0.8.0

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.
@@ -1,66 +1,28 @@
1
1
  // 2121
2
- import type { NodeKind, NodeKindManifest } from "./types.ts"
3
- import { dataJson, dataMerge } from "./data.ts"
4
- import { transformFilter, transformMap, transformSort, transformAggregate } from "./transform.ts"
5
- import { dbInsert, dbQuery } from "./db.ts"
6
- import { httpFetch, httpPost } from "./http.ts"
7
- import { trigger, sink } from "./control.ts"
8
- import { humanInput } from "./hitl.ts"
9
- import { runsLastOutput, runsNodeOutput, pipelineRun } from "./runs.ts"
2
+ // Aja-side kind registry shim.
3
+ //
4
+ // Builtins and registry implementation now live in @toist/core. Aja keeps
5
+ // this module so existing local imports and custom kind registration patterns
6
+ // continue to work during the package carve-out.
10
7
 
11
- const builtins: NodeKind<any, any>[] = [
12
- trigger,
13
- sink,
14
- dataJson,
15
- dataMerge,
16
- transformFilter,
17
- transformMap,
18
- transformSort,
19
- transformAggregate,
20
- dbInsert,
21
- dbQuery,
22
- httpFetch,
23
- httpPost,
24
- humanInput,
25
- runsLastOutput,
26
- runsNodeOutput,
27
- pipelineRun,
28
- ]
8
+ export {
9
+ builtinKinds,
10
+ createRegistry,
11
+ register,
12
+ getKind,
13
+ manifest,
14
+ } from "@toist/core"
29
15
 
30
- export const registry: Map<string, NodeKind<any, any>> = new Map(builtins.map((k) => [k.id, k]))
16
+ export type {
17
+ NodeKind,
18
+ NodeKindManifest,
19
+ DataHandle,
20
+ DataStatement,
21
+ ParamDef,
22
+ PortDef,
23
+ ExecContext,
24
+ PlatformCtx,
25
+ } from "@toist/spec"
31
26
 
32
- /**
33
- * Register one or more custom domain kinds. Domain projects edit
34
- * `runner/src/kinds/custom.ts` (created by `platform init`) and call
35
- * `register(...)` there with their own kinds. Builtin kinds and domain kinds
36
- * live in the same registry — `getKind(id)` resolves both transparently.
37
- *
38
- * Re-registering an existing id replaces the previous entry; useful for hot
39
- * iteration but warn loudly so accidental shadows are visible.
40
- */
41
- export function register(...kinds: NodeKind<any, any>[]): void {
42
- for (const k of kinds) {
43
- if (registry.has(k.id) && !builtins.find((b) => b.id === k.id)) {
44
- console.warn(`[kinds] re-registering "${k.id}" — previous registration replaced`)
45
- }
46
- registry.set(k.id, k)
47
- }
48
- }
49
-
50
- export function getKind(id: string): NodeKind<any, any> | undefined {
51
- return registry.get(id)
52
- }
53
-
54
- export function manifest(): NodeKindManifest[] {
55
- return [...registry.values()].map(({ run: _e, ...rest }) => rest)
56
- }
57
-
58
- export type { NodeKind, NodeKindManifest, ParamDef, PortDef, ExecContext, PlatformCtx } from "./types.ts"
59
-
60
- // Side-effect import: domain kinds register themselves when this module loads.
61
- // Dynamic import + top-level await defers custom.ts evaluation until AFTER
62
- // `registry` and `register` are initialised — a static `import "./custom.ts"`
63
- // gets hoisted ahead of the const init, putting `registry` in the temporal
64
- // dead zone when custom.ts calls register(). The dynamic form runs at
65
- // statement position, after init, breaking the cycle.
27
+ // Preserve the old side-effect registration hook for domain kinds.
66
28
  await import("./custom.ts")
package/src/lock.ts CHANGED
@@ -1,64 +1,38 @@
1
1
  // 2121
2
- // Single-leader runner lock. Prevents two runner processes from racing on
3
- // migrations, scheduled cron-wheel firings, or HITL resume dispatch.
4
- //
5
- // The lock guards the data/ directory using a sentinel lockfile at
6
- // data/.lock. proper-lockfile creates a holder directory that includes mtime;
7
- // stale locks (from a crashed previous process) are detected after `stale` ms
8
- // and overtaken automatically.
9
- //
10
- // Watch-mode reload tolerance: bun --watch SIGTERMs the old process before
11
- // spawning the new one. The cleanup handlers below release the lock on
12
- // SIGTERM/SIGINT/exit. Async lock acquisition with retries handles the brief
13
- // window where the old process has not yet released.
14
-
15
2
  import lockfile from "proper-lockfile"
16
3
  import { mkdirSync } from "node:fs"
17
- import { dataDir, lockfilePath } from "./config.ts"
18
4
 
19
- let release: (() => Promise<void>) | null = null
5
+ export interface AcquireRunnerLockOptions {
6
+ dir: string
7
+ lockfilePath: string
8
+ }
20
9
 
21
- export async function acquireRunnerLock(): Promise<void> {
22
- const dir = dataDir()
23
- const lockPath = lockfilePath()
24
- mkdirSync(dir, { recursive: true })
10
+ export async function acquireRunnerLock(opts: AcquireRunnerLockOptions): Promise<() => Promise<void>> {
11
+ mkdirSync(opts.dir, { recursive: true })
25
12
 
26
- try {
27
- release = await lockfile.lock(dir, {
28
- lockfilePath: lockPath,
29
- realpath: false,
30
- stale: 10_000,
31
- retries: { retries: 5, factor: 2, minTimeout: 100, maxTimeout: 1000 },
32
- })
33
- } catch (err) {
34
- const msg = err instanceof Error ? err.message : String(err)
35
- console.error(`[runner] failed to acquire ${lockPath}: ${msg}`)
36
- console.error(`[runner] another runner appears live. Stop it first; only one runner per project is allowed.`)
37
- process.exit(1)
38
- }
13
+ let release = await lockfile.lock(opts.dir, {
14
+ lockfilePath: opts.lockfilePath,
15
+ realpath: false,
16
+ stale: 10_000,
17
+ retries: { retries: 5, factor: 2, minTimeout: 100, maxTimeout: 1000 },
18
+ })
39
19
 
40
- const cleanup = (): void => {
41
- if (!release) return
42
- const r = release
43
- release = null
44
- r().catch(() => { /* best-effort */ })
20
+ let released = false
21
+ const cleanup = async () => {
22
+ if (released) return
23
+ released = true
24
+ const current = release
25
+ release = async () => {}
26
+ try {
27
+ await current()
28
+ } catch {
29
+ // best-effort cleanup on process exit / stop
30
+ }
45
31
  }
46
32
 
47
- process.on("exit", cleanup)
48
- process.on("SIGTERM", () => { cleanup(); process.exit(0) })
49
- process.on("SIGINT", () => { cleanup(); process.exit(0) })
50
- }
33
+ process.on("exit", () => { void cleanup() })
34
+ process.on("SIGTERM", () => { void cleanup() })
35
+ process.on("SIGINT", () => { void cleanup() })
51
36
 
52
- /** Release the runner lock explicitly. Used by `startRunner`'s graceful
53
- * `stop()` path. Idempotent: no-op if the lock is not held. The signal
54
- * cleanup handlers installed by acquireRunnerLock remain in place. */
55
- export async function releaseRunnerLock(): Promise<void> {
56
- if (!release) return
57
- const r = release
58
- release = null
59
- try {
60
- await r()
61
- } catch {
62
- // best-effort — surfacing this would mask the actual stop reason
63
- }
37
+ return cleanup
64
38
  }
package/src/migrate.ts CHANGED
@@ -103,7 +103,7 @@ function backupAndPrune(dbPath: string): string | null {
103
103
  const backupPath = join(dir, `${base}.bak-${ts}`)
104
104
 
105
105
  copyFileSync(dbPath, backupPath)
106
- console.log(`[migrate] backup ${backupPath}`)
106
+ console.error(`[migrate] backup ${backupPath}`)
107
107
 
108
108
  const prefix = `${base}.bak-`
109
109
  const all = readdirSync(dir)
@@ -113,7 +113,7 @@ function backupAndPrune(dbPath: string): string | null {
113
113
 
114
114
  for (const stale of all.slice(BACKUP_RETAIN)) {
115
115
  unlinkSync(join(dir, stale.name))
116
- console.log(`[migrate] pruned old backup ${stale.name}`)
116
+ console.error(`[migrate] pruned old backup ${stale.name}`)
117
117
  }
118
118
 
119
119
  return backupPath
@@ -191,7 +191,7 @@ export function runMigrations(dbPath: string, opts: MigrateOptions = {}): Migrat
191
191
  try {
192
192
  apply()
193
193
  result.applied.push(file.filename)
194
- console.log(`[migrate] applied ${file.filename}`)
194
+ console.error(`[migrate] applied ${file.filename}`)
195
195
  } catch (err) {
196
196
  const msg = err instanceof Error ? err.message : String(err)
197
197
  throw new Error(`[migrate] failed applying "${file.filename}": ${msg}`)
@@ -0,0 +1,31 @@
1
+ import { join } from "node:path"
2
+ import { getPipeline, getPipelines, loadAll, watchAll, type KindRegistry } from "@toist/core"
3
+ import type { PipelineSpec } from "@toist/spec"
4
+
5
+ export interface FilesystemPipelineStore {
6
+ get(id: string): PipelineSpec | null
7
+ list(): PipelineSpec[]
8
+ close(): void
9
+ }
10
+
11
+ export function createFilesystemPipelineStore(options: {
12
+ rootDir: string
13
+ watch?: boolean
14
+ registry?: KindRegistry
15
+ }): FilesystemPipelineStore {
16
+ const pipelinesDir = join(options.rootDir, "pipelines")
17
+ loadAll(pipelinesDir, { registry: options.registry })
18
+ const watcher = options.watch === false ? null : watchAll(pipelinesDir, undefined, { registry: options.registry })
19
+
20
+ return {
21
+ get(id) {
22
+ return getPipeline(id) ?? null
23
+ },
24
+ list() {
25
+ return getPipelines()
26
+ },
27
+ close() {
28
+ watcher?.close()
29
+ },
30
+ }
31
+ }
@@ -0,0 +1,43 @@
1
+ // 2121
2
+ import { existsSync, readFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import YAML from "yaml"
5
+
6
+ const ENV_EXPR = /\$\{env:([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}/g
7
+
8
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
9
+ return !!value && typeof value === "object" && !Array.isArray(value)
10
+ }
11
+
12
+ function substituteEnvInString(value: string): string {
13
+ return value.replace(ENV_EXPR, (_match, name: string, fallback: string | undefined) => {
14
+ const resolved = process.env[name]
15
+ if (resolved !== undefined) return resolved
16
+ if (fallback !== undefined) return fallback
17
+ throw new Error(`[resources-fs] missing required environment variable ${name}`)
18
+ })
19
+ }
20
+
21
+ function substituteEnv(value: unknown): unknown {
22
+ if (typeof value === "string") return substituteEnvInString(value)
23
+ if (Array.isArray(value)) return value.map((entry) => substituteEnv(entry))
24
+ if (!isPlainObject(value)) return value
25
+ return Object.fromEntries(
26
+ Object.entries(value).map(([key, entry]) => [key, substituteEnv(entry)]),
27
+ )
28
+ }
29
+
30
+ export async function loadFilesystemResources(
31
+ rootDir: string,
32
+ _opts: { env?: string } = {},
33
+ ): Promise<Record<string, Record<string, unknown>>> {
34
+ const candidates = [join(rootDir, "toist.yml"), join(rootDir, "toist.yaml")]
35
+ const path = candidates.find((candidate) => existsSync(candidate))
36
+ if (!path) return {}
37
+
38
+ const parsed = (YAML.parse(readFileSync(path, "utf8")) ?? {}) as Record<string, unknown>
39
+ const resources = parsed.resources
40
+ if (!isPlainObject(resources)) return {}
41
+
42
+ return substituteEnv(resources) as Record<string, Record<string, unknown>>
43
+ }
package/src/resources.ts CHANGED
@@ -1,29 +1,14 @@
1
1
  // 2121
2
- // Resource system typed external-system handles.
2
+ // Resource type registry + runtime DB CRUD for the admin API.
3
3
  //
4
- // Three-tier override chain (highest priority first):
5
- // 1. ENV-VAR: PLATFORM_RESOURCE_<NAME>_<FIELD>=value
6
- // 2. DB: runtime.db resources table (set via UI or POST /resources)
7
- // 3. YAML: <runner>/resources/*.yaml (committable; secrets use { $env: "VAR" })
8
- //
9
- // ctx.resource.<name>.<field> is the pipeline-side surface.
10
- // Fields from higher tiers override lower tiers field-by-field.
11
- //
12
- // Sensitive fields (x-sensitive:true in the Type schema) are stored
13
- // clear-text in v1. A load-time warning surfaces this. Encryption at
14
- // rest is deferred to pipeline-spec.md §16.1.
4
+ // Note: execution-time resource resolution no longer reads from the resources
5
+ // table. Phase 4 resolves resources from <rootDir>/toist.yml at runtime
6
+ // construction time via resources-fs.ts. The DB table remains for the admin
7
+ // surface / legacy UI editor until a later UX pass decides its fate.
15
8
 
16
- import { existsSync, readdirSync, readFileSync } from "node:fs"
17
- import { basename, extname, join, dirname } from "node:path"
18
- import { fileURLToPath } from "node:url"
19
9
  import type { Database } from "bun:sqlite"
20
- import { parseYaml, YamlError } from "@toist/spec"
21
10
  import type { ResourceTypeDef } from "@toist/spec"
22
11
 
23
- const __dir = dirname(fileURLToPath(import.meta.url))
24
- // CWD is apps/runner/server/; resources/*.yaml lives at apps/runner/resources/
25
- const RESOURCES_DIR = join(__dir, "..", "..", "resources")
26
-
27
12
  // ─── Resource Type registry ───────────────────────────────────────────────────
28
13
 
29
14
  const typeRegistry = new Map<string, ResourceTypeDef>()
@@ -49,7 +34,7 @@ const BUILTIN_TYPES: ResourceTypeDef[] = [
49
34
  type: "object",
50
35
  properties: {
51
36
  apiKey: { type: "string", "x-sensitive": true, description: "Anthropic API key (sk-ant-…)" },
52
- model: { type: "string", default: "claude-sonnet-4-6", description: "Default model ID" },
37
+ model: { type: "string", default: "claude-sonnet-4-6", description: "Default model ID" },
53
38
  },
54
39
  required: ["apiKey"],
55
40
  },
@@ -61,8 +46,8 @@ const BUILTIN_TYPES: ResourceTypeDef[] = [
61
46
  $schema: "https://json-schema.org/draft/2020-12/schema",
62
47
  type: "object",
63
48
  properties: {
64
- apiKey: { type: "string", "x-sensitive": true },
65
- model: { type: "string", default: "gpt-4o" },
49
+ apiKey: { type: "string", "x-sensitive": true },
50
+ model: { type: "string", default: "gpt-4o" },
66
51
  baseUrl: { type: "string", description: "Override for OpenAI-compatible endpoints" },
67
52
  },
68
53
  required: ["apiKey"],
@@ -75,12 +60,12 @@ const BUILTIN_TYPES: ResourceTypeDef[] = [
75
60
  $schema: "https://json-schema.org/draft/2020-12/schema",
76
61
  type: "object",
77
62
  properties: {
78
- host: { type: "string" },
79
- port: { type: "number", default: 5432 },
63
+ host: { type: "string" },
64
+ port: { type: "number", default: 5432 },
80
65
  database: { type: "string" },
81
- user: { type: "string" },
66
+ user: { type: "string" },
82
67
  password: { type: "string", "x-sensitive": true },
83
- ssl: { type: "boolean", default: false },
68
+ ssl: { type: "boolean", default: false },
84
69
  },
85
70
  required: ["host", "database", "user"],
86
71
  },
@@ -92,11 +77,11 @@ const BUILTIN_TYPES: ResourceTypeDef[] = [
92
77
  $schema: "https://json-schema.org/draft/2020-12/schema",
93
78
  type: "object",
94
79
  properties: {
95
- bucket: { type: "string" },
96
- region: { type: "string" },
97
- accessKeyId: { type: "string", "x-sensitive": true },
80
+ bucket: { type: "string" },
81
+ region: { type: "string" },
82
+ accessKeyId: { type: "string", "x-sensitive": true },
98
83
  secretAccessKey: { type: "string", "x-sensitive": true },
99
- endpoint: { type: "string", description: "S3-compatible endpoint URL (optional)" },
84
+ endpoint: { type: "string", description: "S3-compatible endpoint URL (optional)" },
100
85
  },
101
86
  required: ["bucket", "accessKeyId", "secretAccessKey"],
102
87
  },
@@ -108,8 +93,8 @@ const BUILTIN_TYPES: ResourceTypeDef[] = [
108
93
  $schema: "https://json-schema.org/draft/2020-12/schema",
109
94
  type: "object",
110
95
  properties: {
111
- baseUrl: { type: "string" },
112
- apiKey: { type: "string", "x-sensitive": true },
96
+ baseUrl: { type: "string" },
97
+ apiKey: { type: "string", "x-sensitive": true },
113
98
  bearerToken: { type: "string", "x-sensitive": true },
114
99
  },
115
100
  required: ["baseUrl"],
@@ -119,82 +104,7 @@ const BUILTIN_TYPES: ResourceTypeDef[] = [
119
104
 
120
105
  registerResourceType(...BUILTIN_TYPES)
121
106
 
122
- // ─── Sensitive-field detection ────────────────────────────────────────────────
123
-
124
- function sensitiveFields(typeName: string): Set<string> {
125
- const type = typeRegistry.get(typeName)
126
- if (!type) return new Set()
127
- const props = (type.schema?.properties ?? {}) as Record<string, { "x-sensitive"?: boolean }>
128
- return new Set(Object.entries(props).filter(([, v]) => v?.["x-sensitive"]).map(([k]) => k))
129
- }
130
-
131
- function warnSensitive(name: string, typeName: string, fields: Record<string, unknown>): void {
132
- const sensitive = sensitiveFields(typeName)
133
- const exposed = Object.keys(fields).filter((k) => sensitive.has(k))
134
- if (exposed.length > 0) {
135
- console.warn(
136
- `[resources] resource "${name}" (${typeName}) has sensitive fields stored unencrypted: ${exposed.join(", ")}. Encryption pending (§16.1).`,
137
- )
138
- }
139
- }
140
-
141
- // ─── $env placeholder resolver ────────────────────────────────────────────────
142
-
143
- function resolveEnvPlaceholders(val: unknown, source: string): unknown {
144
- if (val === null || typeof val !== "object") return val
145
- if (Array.isArray(val)) return val.map((v) => resolveEnvPlaceholders(v, source))
146
- const obj = val as Record<string, unknown>
147
- if ("$env" in obj && typeof obj["$env"] === "string") {
148
- const envVal = process.env[obj["$env"]]
149
- if (envVal === undefined) {
150
- throw new Error(`[resources] ${source}: $env placeholder "${obj["$env"]}" is not set at startup`)
151
- }
152
- return envVal
153
- }
154
- return Object.fromEntries(
155
- Object.entries(obj).map(([k, v]) => [k, resolveEnvPlaceholders(v, source)]),
156
- )
157
- }
158
-
159
- // ─── YAML resource loader ─────────────────────────────────────────────────────
160
-
161
- interface ResourceYaml {
162
- type: string
163
- name?: string
164
- fields?: Record<string, unknown>
165
- }
166
-
167
- function loadResourceYamls(): Map<string, { type: string; fields: Record<string, unknown> }> {
168
- const out = new Map<string, { type: string; fields: Record<string, unknown> }>()
169
- if (!existsSync(RESOURCES_DIR)) return out
170
-
171
- const files = readdirSync(RESOURCES_DIR).filter((f) => /\.ya?ml$/.test(f))
172
- for (const file of files) {
173
- const path = join(RESOURCES_DIR, file)
174
- try {
175
- const raw = readFileSync(path, "utf8")
176
- const parsed = parseYaml(raw) as ResourceYaml
177
- if (!parsed || typeof parsed !== "object" || !parsed.type) {
178
- console.warn(`[resources] ${file}: missing required "type" field — skipped`)
179
- continue
180
- }
181
- const stem = basename(file, extname(file))
182
- const name = parsed.name ?? stem
183
- const rawFields = parsed.fields ?? {}
184
- const fields = resolveEnvPlaceholders(rawFields, file) as Record<string, unknown>
185
- out.set(name, { type: parsed.type, fields })
186
- } catch (err) {
187
- if (err instanceof YamlError) {
188
- console.warn(`[resources] ${file}: YAML parse error — ${err.message}`)
189
- } else {
190
- console.warn(`[resources] ${file}: ${(err as Error).message}`)
191
- }
192
- }
193
- }
194
- return out
195
- }
196
-
197
- // ─── DB resource loader ───────────────────────────────────────────────────────
107
+ // ─── DB CRUD (used by API routes) ─────────────────────────────────────────────
198
108
 
199
109
  interface ResourceRow {
200
110
  id: number
@@ -205,79 +115,6 @@ interface ResourceRow {
205
115
  updated_at: string
206
116
  }
207
117
 
208
- function loadResourcesFromDb(db: Database): Map<string, { type: string; fields: Record<string, unknown> }> {
209
- const out = new Map<string, { type: string; fields: Record<string, unknown> }>()
210
- try {
211
- const rows = db.prepare("SELECT name, type, fields_json FROM resources").all() as
212
- { name: string; type: string; fields_json: string }[]
213
- for (const row of rows) {
214
- try {
215
- const fields = JSON.parse(row.fields_json) as Record<string, unknown>
216
- out.set(row.name, { type: row.type, fields })
217
- } catch {
218
- console.warn(`[resources] DB row "${row.name}": invalid fields_json — skipped`)
219
- }
220
- }
221
- } catch (err) {
222
- // Table may not exist yet (migration pending). Log and continue.
223
- console.warn(`[resources] DB read failed: ${(err as Error).message}`)
224
- }
225
- return out
226
- }
227
-
228
- // ─── ENV-VAR resolver ─────────────────────────────────────────────────────────
229
- // Scans process.env for PLATFORM_RESOURCE_<NAME>_<FIELD>=value.
230
- // NAME and FIELD are uppercased in the env key; both are lowercased when
231
- // applied to ctx.resource. Supports single-level fields only (no nested ENV).
232
-
233
- function applyEnvOverrides(result: Map<string, { type: string; fields: Record<string, unknown> }>): void {
234
- for (const [key, value] of Object.entries(process.env)) {
235
- if (!value) continue
236
- const m = key.match(/^PLATFORM_RESOURCE_([A-Z][A-Z0-9_]*)_([A-Z][A-Z0-9_]*)$/)
237
- if (!m) continue
238
- const name = m[1].toLowerCase()
239
- const field = m[2].toLowerCase()
240
- const entry = result.get(name)
241
- if (entry) {
242
- entry.fields[field] = value
243
- } else {
244
- // ENV-only resource with no YAML or DB entry — create a minimal record.
245
- result.set(name, { type: "Unknown", fields: { [field]: value } })
246
- }
247
- }
248
- }
249
-
250
- // ─── Main: build ctx.resource ─────────────────────────────────────────────────
251
-
252
- /** Merges all three tiers and returns the ctx.resource namespace for runPipeline. */
253
- export function buildResourceCtx(db: Database): Record<string, Record<string, unknown>> {
254
- // Tier 3 (lowest): YAML files
255
- const merged = loadResourceYamls()
256
-
257
- // Tier 2: DB overrides YAML field-by-field
258
- for (const [name, dbEntry] of loadResourcesFromDb(db)) {
259
- const existing = merged.get(name)
260
- if (existing) {
261
- existing.fields = { ...existing.fields, ...dbEntry.fields }
262
- } else {
263
- merged.set(name, dbEntry)
264
- }
265
- }
266
-
267
- // Tier 1 (highest): ENV-VAR overrides
268
- applyEnvOverrides(merged)
269
-
270
- // Flatten to Record<name, fields> and warn on exposed sensitive fields
271
- const out: Record<string, Record<string, unknown>> = {}
272
- for (const [name, { type, fields }] of merged) {
273
- warnSensitive(name, type, fields)
274
- out[name] = fields
275
- }
276
- return out
277
- }
278
-
279
- // ─── DB CRUD (used by API routes) ─────────────────────────────────────────────
280
-
281
118
  export interface ResourceRecord {
282
119
  id: number
283
120
  name: string
@@ -307,9 +144,12 @@ export function getResource(db: Database, name: string): ResourceRecord | null {
307
144
  ).get(name) as ResourceRow | undefined
308
145
  if (!row) return null
309
146
  return {
310
- id: row.id, name: row.name, type: row.type,
147
+ id: row.id,
148
+ name: row.name,
149
+ type: row.type,
311
150
  fields: JSON.parse(row.fields_json) as Record<string, unknown>,
312
- created_at: row.created_at, updated_at: row.updated_at,
151
+ created_at: row.created_at,
152
+ updated_at: row.updated_at,
313
153
  }
314
154
  }
315
155
 
@@ -338,13 +178,10 @@ export function patchResource(
338
178
  const existing = getResource(db, name)
339
179
  if (!existing) return null
340
180
  const merged = { ...existing.fields, ...fields }
341
- db.prepare(
342
- "UPDATE resources SET fields_json = ?, updated_at = datetime('now') WHERE name = ?",
343
- ).run(JSON.stringify(merged), name)
344
- return getResource(db, name)!
181
+ return upsertResource(db, name, existing.type, merged)
345
182
  }
346
183
 
347
184
  export function deleteResource(db: Database, name: string): boolean {
348
- const result = db.prepare("DELETE FROM resources WHERE name = ?").run(name)
349
- return result.changes > 0
185
+ const changed = db.prepare("DELETE FROM resources WHERE name = ?").run(name).changes
186
+ return changed > 0
350
187
  }
@@ -0,0 +1,42 @@
1
+ import type { ToistEvent } from "@toist/core"
2
+
3
+ export interface RunEventBroker {
4
+ emit(event: ToistEvent): Promise<void>
5
+ finish(): void
6
+ subscribe(): AsyncIterable<ToistEvent>
7
+ }
8
+
9
+ export function createRunEventBroker(): RunEventBroker {
10
+ const events: ToistEvent[] = []
11
+ let finished = false
12
+ const waiters = new Set<() => void>()
13
+
14
+ return {
15
+ async emit(event) {
16
+ events.push(event)
17
+ for (const notify of waiters) notify()
18
+ waiters.clear()
19
+ },
20
+ finish() {
21
+ finished = true
22
+ for (const notify of waiters) notify()
23
+ waiters.clear()
24
+ },
25
+ subscribe() {
26
+ let index = 0
27
+ return {
28
+ [Symbol.asyncIterator]() {
29
+ return {
30
+ async next(): Promise<IteratorResult<ToistEvent>> {
31
+ while (index >= events.length) {
32
+ if (finished) return { value: undefined as never, done: true }
33
+ await new Promise<void>((resolve) => waiters.add(resolve))
34
+ }
35
+ return { value: events[index++], done: false }
36
+ },
37
+ }
38
+ },
39
+ }
40
+ },
41
+ }
42
+ }