@nightowlsdev/storage-supabase 0.3.0 → 2.0.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.
package/dist/index.cjs CHANGED
@@ -25,10 +25,19 @@ __export(index_exports, {
25
25
  createMastraVectorStore: () => createMastraVectorStore,
26
26
  createPostgresFloor: () => createPostgresFloor,
27
27
  createSupabaseStorage: () => createSupabaseStorage,
28
+ ensureAgentVersion: () => ensureAgentVersion,
29
+ hostOwnedOrgMembershipSql: () => hostOwnedOrgMembershipSql,
30
+ hostOwnedOrgMigration: () => hostOwnedOrgMigration,
28
31
  listAgentVersions: () => listAgentVersions,
32
+ listTenants: () => listTenants,
33
+ makeBundleRepo: () => makeBundleRepo,
34
+ makeBundleWritableRepo: () => makeBundleWritableRepo,
35
+ makeThreadStore: () => makeThreadStore,
36
+ makeVersionedRepo: () => makeVersionedRepo,
29
37
  nightOwlsPlugin: () => nightOwlsPlugin,
30
38
  publishAgentVersion: () => publishAgentVersion,
31
- rollbackAgentVersion: () => rollbackAgentVersion
39
+ rollbackAgentVersion: () => rollbackAgentVersion,
40
+ supabaseUsageSink: () => supabaseUsageSink
32
41
  });
33
42
  module.exports = __toCommonJS(index_exports);
34
43
 
@@ -146,6 +155,19 @@ function makeEventStore(ctx) {
146
155
  const rows = await many(ctx.pool, "select run_id, seq, payload from events where org_id=$1 and run_id=$2 and seq>$3 order by seq", [tenantId, runId, sinceSeq]);
147
156
  return rows.map(fromRow);
148
157
  },
158
+ // The full ordered log for a CONTAINER: join events → runs and match the container (the root thread_id, OR a
159
+ // lane sub-thread `<container>:<slug>`). `seq` is GENERATED ALWAYS (global, monotonic across all runs), so a
160
+ // plain `order by seq` interleaves every run correctly. Org-scoped (service connection bypasses RLS, so we
161
+ // enforce tenancy here). split_part is injection-safe; a container id never contains ':'.
162
+ async listForContainer(tenantId, container) {
163
+ const rows = await many(
164
+ ctx.pool,
165
+ `select e.run_id, e.seq, e.payload, r.thread_id from events e join runs r on r.id = e.run_id
166
+ where e.org_id = $1 and split_part(r.thread_id, ':', 1) = $2 order by e.seq`,
167
+ [tenantId, container]
168
+ );
169
+ return rows.map((r) => ({ ...fromRow(r), threadId: r.thread_id }));
170
+ },
149
171
  subscribe: makeSubscribe(ctx)
150
172
  };
151
173
  }
@@ -292,6 +314,34 @@ function makeScratchpadStore(ctx) {
292
314
  };
293
315
  }
294
316
 
317
+ // src/threads.ts
318
+ function makeThreadStore(ctx) {
319
+ return {
320
+ async ensure({ id, orgId, userId, projectId }) {
321
+ await ctx.pool.query(
322
+ "insert into threads(id, org_id, user_id, project_id) values($1,$2,$3,$4) on conflict (id) do nothing",
323
+ [id, orgId, userId, projectId ?? null]
324
+ );
325
+ }
326
+ };
327
+ }
328
+
329
+ // src/agents.ts
330
+ var import_core3 = require("@nightowlsdev/core");
331
+
332
+ // src/versioning.ts
333
+ async function appendVersion(client, entity, headId, tenantId, insertVersionRow, action, actor, auditAfter) {
334
+ await client.query("select pg_advisory_xact_lock(hashtext($1), hashtext($2))", [entity.versionTable, headId]);
335
+ const nextV = (await client.query(`select coalesce(max(version),0)+1 v from ${entity.versionTable} where ${entity.fkColumn}=$1`, [headId])).rows[0].v;
336
+ const versionRowId = await insertVersionRow(nextV);
337
+ await client.query(`update ${entity.headTable} set current_version_id=$2 where id=$1`, [headId, versionRowId]);
338
+ await client.query(
339
+ "insert into audit_log(org_id, actor, action, entity, entity_id, after) values($1,$2,$3,$4,$5,$6)",
340
+ [tenantId, actor, action, entity.kind, headId, JSON.stringify({ version: nextV, ...auditAfter })]
341
+ );
342
+ return nextV;
343
+ }
344
+
295
345
  // src/subscribe-invalidations.ts
296
346
  var INVALIDATE_CHANNEL = "nightowls_agent_invalidate";
297
347
  async function listenForInvalidations(ctx, onInvalidate) {
@@ -303,7 +353,7 @@ async function listenForInvalidations(ctx, onInvalidate) {
303
353
  client.on("error", (e) => {
304
354
  client.removeListener("notification", onNotification);
305
355
  console.warn(
306
- "[nightowls] agent-cache invalidation LISTEN connection errored \u2014 cross-process eviction paused until restart; the 30s cache TTL still bounds staleness:",
356
+ "[@nightowlsdev/storage-supabase] agent-cache invalidation LISTEN connection errored \u2014 cross-process eviction paused until restart; the 30s cache TTL still bounds staleness:",
307
357
  e instanceof Error ? e.message : e
308
358
  );
309
359
  });
@@ -327,6 +377,7 @@ async function listenForInvalidations(ctx, onInvalidate) {
327
377
  }
328
378
 
329
379
  // src/agents.ts
380
+ var AGENT_ENTITY = { kind: "agent", versionTable: "agent_versions", headTable: "agents", fkColumn: "agent_id" };
330
381
  function rowToVersion(r) {
331
382
  return {
332
383
  slug: r.slug,
@@ -339,6 +390,18 @@ function rowToVersion(r) {
339
390
  modelId: r.model_id
340
391
  };
341
392
  }
393
+ function auditActor(actor) {
394
+ switch (actor.type) {
395
+ case "human":
396
+ return `user:${actor.userId}`;
397
+ case "service":
398
+ return `service:${actor.serviceId}`;
399
+ case "system":
400
+ return `system:${actor.reason}`;
401
+ case "agent":
402
+ return `agent:${actor.agentSlug}`;
403
+ }
404
+ }
342
405
  function makeAgentRepo(ctx) {
343
406
  return {
344
407
  async head(tenantId, slug) {
@@ -367,19 +430,32 @@ function makeAgentRepo(ctx) {
367
430
  }
368
431
  };
369
432
  }
433
+ function makeVersionedRepo(ctx) {
434
+ const read = makeAgentRepo(ctx);
435
+ return {
436
+ ...read,
437
+ async publish(tenantId, slug, content, actor) {
438
+ const { version } = await publishAgentVersion(ctx, { tenantId, ...content, slug, actor });
439
+ return { version };
440
+ },
441
+ async rollback(tenantId, slug, toVersion, actor) {
442
+ return rollbackAgentVersion(ctx, { tenantId, slug, toVersion, actor });
443
+ },
444
+ async listVersions(tenantId, slug, actor) {
445
+ (0, import_core3.assertActorMayMutateDefinition)(actor);
446
+ return listAgentVersions(ctx, tenantId, slug);
447
+ }
448
+ };
449
+ }
370
450
  async function commitVersion(client, agentId, tenantId, content, action, actor, auditAfter) {
371
- const nextV = (await client.query("select coalesce(max(version),0)+1 v from agent_versions where agent_id=$1", [agentId])).rows[0].v;
372
- const ver = (await client.query(
373
- `insert into agent_versions(agent_id, org_id, version, role, personality, capabilities, skill_names, delegate_slugs, model_id, status)
374
- values($1,$2,$3,$4,$5,$6,$7,$8,$9,'published') returning id`,
375
- [agentId, tenantId, nextV, content.role, content.personality, JSON.stringify(content.capabilities), JSON.stringify(content.skillNames), JSON.stringify(content.delegateSlugs), content.modelId]
376
- )).rows[0];
377
- await client.query("update agents set current_version_id=$2 where id=$1", [agentId, ver.id]);
378
- await client.query(
379
- "insert into audit_log(org_id, actor, action, entity, entity_id, after) values($1,$2,$3,'agent',$4,$5)",
380
- [tenantId, actor, action, agentId, JSON.stringify({ version: nextV, ...auditAfter })]
381
- );
382
- return nextV;
451
+ return appendVersion(client, AGENT_ENTITY, agentId, tenantId, async (version) => {
452
+ const ver = (await client.query(
453
+ `insert into agent_versions(agent_id, org_id, version, role, personality, capabilities, skill_names, delegate_slugs, model_id, status)
454
+ values($1,$2,$3,$4,$5,$6,$7,$8,$9,'published') returning id`,
455
+ [agentId, tenantId, version, content.role, content.personality, JSON.stringify(content.capabilities), JSON.stringify(content.skillNames), JSON.stringify(content.delegateSlugs), content.modelId]
456
+ )).rows[0];
457
+ return ver.id;
458
+ }, action, actor, auditAfter);
383
459
  }
384
460
  async function notifyInvalidate(client, tenantId, slug) {
385
461
  try {
@@ -387,7 +463,10 @@ async function notifyInvalidate(client, tenantId, slug) {
387
463
  } catch {
388
464
  }
389
465
  }
466
+ var SEED_ACTOR = { type: "system", reason: "seed" };
390
467
  async function publishAgentVersion(ctx, def) {
468
+ const actor = def.actor ?? SEED_ACTOR;
469
+ (0, import_core3.assertActorMayMutateDefinition)(actor);
391
470
  const client = await ctx.pool.connect();
392
471
  try {
393
472
  await client.query("begin");
@@ -396,9 +475,10 @@ async function publishAgentVersion(ctx, def) {
396
475
  on conflict (org_id, project_id, slug) do update set slug=excluded.slug returning id`,
397
476
  [def.tenantId, def.slug, def.role === "orchestrator"]
398
477
  )).rows[0];
399
- await commitVersion(client, agent.id, def.tenantId, def, "publish", def.actor ?? "seed", { slug: def.slug });
478
+ const version = await commitVersion(client, agent.id, def.tenantId, def, "publish", auditActor(actor), { slug: def.slug });
400
479
  await client.query("commit");
401
480
  await notifyInvalidate(client, def.tenantId, def.slug);
481
+ return { version };
402
482
  } catch (e) {
403
483
  await client.query("rollback");
404
484
  throw e;
@@ -406,6 +486,41 @@ async function publishAgentVersion(ctx, def) {
406
486
  client.release();
407
487
  }
408
488
  }
489
+ async function ensureAgentVersion(ctx, def) {
490
+ const actor = def.actor ?? SEED_ACTOR;
491
+ (0, import_core3.assertActorMayMutateDefinition)(actor);
492
+ const client = await ctx.pool.connect();
493
+ try {
494
+ await client.query("begin");
495
+ await client.query("select pg_advisory_xact_lock(hashtext($1), hashtext($2))", ["ensure_agent", `${def.tenantId}:${def.slug}`]);
496
+ const head = (await client.query(
497
+ `select v.version from agents a join agent_versions v on v.id = a.current_version_id where a.org_id=$1 and a.slug=$2 and a.project_id is null`,
498
+ [def.tenantId, def.slug]
499
+ )).rows[0];
500
+ if (head) {
501
+ await client.query("commit");
502
+ return { version: head.version, created: false };
503
+ }
504
+ const agent = (await client.query(
505
+ `insert into agents(org_id, slug, is_orchestrator) values($1,$2,$3)
506
+ on conflict (org_id, project_id, slug) do update set slug=excluded.slug returning id`,
507
+ [def.tenantId, def.slug, def.role === "orchestrator"]
508
+ )).rows[0];
509
+ const version = await commitVersion(client, agent.id, def.tenantId, def, "publish", auditActor(actor), { slug: def.slug });
510
+ await client.query("commit");
511
+ await notifyInvalidate(client, def.tenantId, def.slug);
512
+ return { version, created: true };
513
+ } catch (e) {
514
+ await client.query("rollback");
515
+ throw e;
516
+ } finally {
517
+ client.release();
518
+ }
519
+ }
520
+ async function listTenants(ctx) {
521
+ const rows = await many(ctx.pool, "select id::text as id from orgs order by created_at asc");
522
+ return rows.map((r) => r.id);
523
+ }
409
524
  async function listAgentVersions(ctx, tenantId, slug) {
410
525
  const rows = await many(
411
526
  ctx.pool,
@@ -417,6 +532,8 @@ async function listAgentVersions(ctx, tenantId, slug) {
417
532
  return rows.map((r) => ({ version: Number(r.version), role: r.role, modelId: r.model_id, status: r.status, isCurrent: r.is_current }));
418
533
  }
419
534
  async function rollbackAgentVersion(ctx, args) {
535
+ const actor = args.actor ?? { type: "system", reason: "rollback" };
536
+ (0, import_core3.assertActorMayMutateDefinition)(actor);
420
537
  const client = await ctx.pool.connect();
421
538
  try {
422
539
  await client.query("begin");
@@ -435,7 +552,7 @@ async function rollbackAgentVersion(ctx, args) {
435
552
  delegateSlugs: target.delegate_slugs ?? [],
436
553
  modelId: target.model_id
437
554
  };
438
- const version = await commitVersion(client, target.agent_id, args.tenantId, content, "rollback", args.actor ?? "rollback", {
555
+ const version = await commitVersion(client, target.agent_id, args.tenantId, content, "rollback", auditActor(actor), {
439
556
  slug: args.slug,
440
557
  restoredFrom: args.toVersion
441
558
  });
@@ -450,6 +567,114 @@ async function rollbackAgentVersion(ctx, args) {
450
567
  }
451
568
  }
452
569
 
570
+ // src/bundles.ts
571
+ var import_core4 = require("@nightowlsdev/core");
572
+ var BUNDLE_ENTITY = { kind: "bundle", versionTable: "bundle_versions", headTable: "bundles", fkColumn: "bundle_id" };
573
+ function auditActor2(actor) {
574
+ switch (actor.type) {
575
+ case "human":
576
+ return `user:${actor.userId}`;
577
+ case "service":
578
+ return `service:${actor.serviceId}`;
579
+ case "system":
580
+ return `system:${actor.reason}`;
581
+ case "agent":
582
+ return `agent:${actor.agentSlug}`;
583
+ }
584
+ }
585
+ function rowToBundleVersion(r) {
586
+ return { version: Number(r.version), ...r.content };
587
+ }
588
+ function makeBundleRepo(ctx) {
589
+ return {
590
+ async head(tenantId, slug) {
591
+ const r = await one(
592
+ ctx.pool,
593
+ `select v.version, v.content from bundles b join bundle_versions v on v.id = b.current_version_id
594
+ where b.org_id=$1 and b.slug=$2`,
595
+ [tenantId, slug]
596
+ );
597
+ return r ? rowToBundleVersion(r) : null;
598
+ },
599
+ async getVersion(tenantId, slug, version) {
600
+ const r = await one(
601
+ ctx.pool,
602
+ `select v.version, v.content from bundles b join bundle_versions v on v.bundle_id = b.id
603
+ where b.org_id=$1 and b.slug=$2 and v.version=$3`,
604
+ [tenantId, slug, version]
605
+ );
606
+ return r ? rowToBundleVersion(r) : null;
607
+ },
608
+ async listSlugs(tenantId) {
609
+ const rows = await many(ctx.pool, "select slug from bundles where org_id=$1", [tenantId]);
610
+ return rows.map((r) => r.slug);
611
+ }
612
+ };
613
+ }
614
+ async function commitBundle(ctx, tenantId, slug, content, action, actor, auditAfter) {
615
+ const client = await ctx.pool.connect();
616
+ try {
617
+ await client.query("begin");
618
+ const bundle = (await client.query(
619
+ `insert into bundles(org_id, slug) values($1,$2)
620
+ on conflict (org_id, project_id, slug) do update set slug=excluded.slug returning id`,
621
+ [tenantId, slug]
622
+ )).rows[0];
623
+ const version = await appendVersion(client, BUNDLE_ENTITY, bundle.id, tenantId, async (v) => {
624
+ const row = (await client.query(
625
+ `insert into bundle_versions(bundle_id, org_id, version, content, status) values($1,$2,$3,$4,'published') returning id`,
626
+ [bundle.id, tenantId, v, JSON.stringify(content)]
627
+ )).rows[0];
628
+ return row.id;
629
+ }, action, auditActor2(actor), auditAfter);
630
+ await client.query("commit");
631
+ return { version };
632
+ } catch (e) {
633
+ await client.query("rollback");
634
+ throw e;
635
+ } finally {
636
+ client.release();
637
+ }
638
+ }
639
+ async function listBundleVersions(ctx, tenantId, slug) {
640
+ const rows = await many(
641
+ ctx.pool,
642
+ `select v.version, v.content, v.status, (v.id = b.current_version_id) as is_current
643
+ from bundles b join bundle_versions v on v.bundle_id = b.id
644
+ where b.org_id=$1 and b.slug=$2 order by v.version`,
645
+ [tenantId, slug]
646
+ );
647
+ return rows.map((r) => ({
648
+ version: Number(r.version),
649
+ title: r.content?.title ?? "",
650
+ status: r.status,
651
+ isCurrent: r.is_current,
652
+ memberCount: Array.isArray(r.content?.agents) ? r.content.agents.length : 0
653
+ }));
654
+ }
655
+ function makeBundleWritableRepo(ctx) {
656
+ const read = makeBundleRepo(ctx);
657
+ return {
658
+ ...read,
659
+ async publish(tenantId, slug, content, actor) {
660
+ (0, import_core4.assertActorMayMutateDefinition)(actor);
661
+ return commitBundle(ctx, tenantId, slug, content, "publish", actor, { slug });
662
+ },
663
+ async rollback(tenantId, slug, toVersion, actor) {
664
+ (0, import_core4.assertActorMayMutateDefinition)(actor);
665
+ const target = await read.getVersion(tenantId, slug, toVersion);
666
+ if (!target) throw new Error(`cannot roll back bundle ${slug} to v${toVersion}: no such version for this tenant`);
667
+ const { version: _v, ...content } = target;
668
+ const { version } = await commitBundle(ctx, tenantId, slug, content, "rollback", actor, { slug, restoredFrom: toVersion });
669
+ return { version, restoredFrom: toVersion };
670
+ },
671
+ async listVersions(tenantId, slug, actor) {
672
+ (0, import_core4.assertActorMayMutateDefinition)(actor);
673
+ return listBundleVersions(ctx, tenantId, slug);
674
+ }
675
+ };
676
+ }
677
+
453
678
  // src/mastra-store.ts
454
679
  var import_pg2 = require("@mastra/pg");
455
680
  function createMastraPgStore(opts) {
@@ -461,6 +686,78 @@ function createMastraVectorStore(opts) {
461
686
  return new import_pg2.PgVector({ id: "nightowls-vector", connectionString: opts.dbUrl, schemaName: "nightowls" });
462
687
  }
463
688
 
689
+ // src/usage-sink.ts
690
+ var IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
691
+ var TABLE = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;
692
+ function defaultMap(ev, ctx) {
693
+ const b = ev.data.breakdown;
694
+ return {
695
+ generation_id: ev.data.generationId,
696
+ run_id: ctx.runId,
697
+ org_id: ctx.tenantId,
698
+ agent_slug: ev.data.slug,
699
+ model_id: ev.data.modelId,
700
+ input_tokens: b.inputTokens ?? 0,
701
+ output_tokens: b.outputTokens ?? 0,
702
+ cost_usd: ev.data.cost.usd
703
+ };
704
+ }
705
+ function supabaseUsageSink(opts) {
706
+ const conflict = opts.conflictColumn ?? "generation_id";
707
+ if (!IDENT.test(conflict)) throw new Error(`supabaseUsageSink: unsafe conflictColumn ${JSON.stringify(conflict)}`);
708
+ if (!TABLE.test(opts.table)) throw new Error(`supabaseUsageSink: unsafe table ${JSON.stringify(opts.table)}`);
709
+ const map = opts.map ?? defaultMap;
710
+ return async (ev, ctx) => {
711
+ if (ev.type !== "swarm.usage") return;
712
+ const row = map(ev, ctx);
713
+ if (!row) return;
714
+ const cols = Object.keys(row);
715
+ for (const c of cols) if (!IDENT.test(c)) throw new Error(`supabaseUsageSink: unsafe column ${JSON.stringify(c)}`);
716
+ const placeholders = cols.map((_c, i) => `$${i + 1}`).join(",");
717
+ const sql = `insert into ${opts.table}(${cols.join(",")}) values(${placeholders}) on conflict (${conflict}) do nothing`;
718
+ await opts.pool.query(sql, cols.map((c) => row[c]));
719
+ };
720
+ }
721
+
722
+ // src/host-org-source.ts
723
+ var QUALIFIED = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;
724
+ var PLAIN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
725
+ function assertIdent(v, kind, re) {
726
+ if (!re.test(v)) throw new Error(`hostOwnedOrgMembership: unsafe ${kind} ${JSON.stringify(v)}`);
727
+ }
728
+ function hostOwnedOrgMembershipSql(opts) {
729
+ const schema = opts.schema ?? "nightowls";
730
+ const orgIdColumn = opts.orgIdColumn ?? "organization_id";
731
+ const userIdColumn = opts.userIdColumn ?? "user_id";
732
+ const userIdExpr = opts.userIdExpr ?? "(select auth.uid())";
733
+ assertIdent(schema, "schema", PLAIN);
734
+ assertIdent(opts.membershipTable, "membershipTable", QUALIFIED);
735
+ assertIdent(orgIdColumn, "orgIdColumn", PLAIN);
736
+ assertIdent(userIdColumn, "userIdColumn", PLAIN);
737
+ if (/;|--|\/\*|\$\$/.test(userIdExpr)) throw new Error(`hostOwnedOrgMembership: unsafe userIdExpr ${JSON.stringify(userIdExpr)}`);
738
+ return `-- FR-015 \u2014 override ${schema}.is_org_member to read the host membership table ${opts.membershipTable}.
739
+ create or replace function ${schema}.is_org_member(p_org uuid)
740
+ returns boolean language sql stable set search_path = '' as $$
741
+ select exists (
742
+ select 1 from ${opts.membershipTable} m
743
+ where m.${orgIdColumn} = p_org and m.${userIdColumn} = ${userIdExpr}
744
+ );
745
+ $$;
746
+ -- The Realtime gate calls is_org_member as the AUTHENTICATED role during a private subscribe, so it must be able
747
+ -- to read the membership table. (No-op if you already granted it.)
748
+ grant select on ${opts.membershipTable} to authenticated;
749
+ `;
750
+ }
751
+ function hostOwnedOrgMigration(opts) {
752
+ const version = opts.version ?? "host_org_source";
753
+ assertIdent(version, "version", PLAIN);
754
+ return {
755
+ version,
756
+ name: `host-owned org/membership source (is_org_member \u2192 ${opts.membershipTable})`,
757
+ sql: hostOwnedOrgMembershipSql(opts)
758
+ };
759
+ }
760
+
464
761
  // src/migrations/0001_core.ts
465
762
  var M0001_CORE = {
466
763
  version: "0001_core",
@@ -1587,12 +1884,171 @@ begin
1587
1884
  execute format('alter index nightowls.%I rename to %I', r.indexname, 'nightowls_' || substring(r.indexname from 8));
1588
1885
  end loop;
1589
1886
  end $$;
1887
+
1888
+ -- Repoint any function BODY in the (now) nightowls schema that still hardcodes the old 'corale.' schema \u2014
1889
+ -- ALTER SCHEMA RENAME does not rewrite dollar-quoted bodies (see the header note). Recreate each via its own
1890
+ -- definition with 'corale.' \u2192 'nightowls.'. Generic + idempotent: once rewritten, prosrc no longer matches.
1891
+ do $$
1892
+ declare r record;
1893
+ begin
1894
+ for r in
1895
+ select p.oid
1896
+ from pg_proc p
1897
+ join pg_namespace n on n.oid = p.pronamespace
1898
+ where n.nspname = 'nightowls' and p.prosrc like '%corale.%'
1899
+ loop
1900
+ execute replace(pg_get_functiondef(r.oid), 'corale.', 'nightowls.');
1901
+ end loop;
1902
+ end $$;
1903
+ `
1904
+ )
1905
+ };
1906
+
1907
+ // src/migrations/0014_agent_versions_immutable.ts
1908
+ var M0014_AGENT_VERSIONS_IMMUTABLE = {
1909
+ version: "0014_agent_versions_immutable",
1910
+ name: "agent_versions: published rows are append-only (no content UPDATE, no DELETE)",
1911
+ sql: (
1912
+ /* sql */
1913
+ `
1914
+ -- Guard function: reject any mutation that would rewrite or remove a PUBLISHED agent_versions row.
1915
+ create or replace function nightowls.agent_versions_enforce_immutable()
1916
+ returns trigger language plpgsql as $$
1917
+ begin
1918
+ if (tg_op = 'DELETE') then
1919
+ if (old.status = 'published') then
1920
+ raise exception 'agent_versions: published version v% of agent % is immutable (DELETE forbidden \u2014 append-only)',
1921
+ old.version, old.agent_id
1922
+ using errcode = 'restrict_violation';
1923
+ end if;
1924
+ return old;
1925
+ end if;
1926
+
1927
+ -- UPDATE: a published row's CONTENT is frozen. Allow the lifecycle status to advance (e.g. archive), but
1928
+ -- forbid changing any identifying/behavior column once published.
1929
+ if (old.status = 'published') then
1930
+ if ( new.agent_id is distinct from old.agent_id
1931
+ or new.org_id is distinct from old.org_id
1932
+ or new.version is distinct from old.version
1933
+ or new.role is distinct from old.role
1934
+ or new.personality is distinct from old.personality
1935
+ or new.capabilities is distinct from old.capabilities
1936
+ or new.skill_names is distinct from old.skill_names
1937
+ or new.delegate_slugs is distinct from old.delegate_slugs
1938
+ or new.model_id is distinct from old.model_id
1939
+ or new.model_settings is distinct from old.model_settings
1940
+ or new.guardrails_override is distinct from old.guardrails_override
1941
+ or new.created_at is distinct from old.created_at ) then
1942
+ raise exception 'agent_versions: published version v% of agent % is immutable (content UPDATE forbidden \u2014 append-only; republish a new version instead)',
1943
+ old.version, old.agent_id
1944
+ using errcode = 'restrict_violation';
1945
+ end if;
1946
+ end if;
1947
+ return new;
1948
+ end; $$;
1949
+
1950
+ -- Fire BEFORE every UPDATE/DELETE on agent_versions. (No INSERT trigger \u2014 appends are the whole point.)
1951
+ drop trigger if exists agent_versions_immutable on nightowls.agent_versions;
1952
+ create trigger agent_versions_immutable
1953
+ before update or delete on nightowls.agent_versions
1954
+ for each row execute function nightowls.agent_versions_enforce_immutable();
1955
+ `
1956
+ )
1957
+ };
1958
+
1959
+ // src/migrations/0015_drop_saas_tables.ts
1960
+ var M0015_DROP_SAAS_TABLES = {
1961
+ version: "0015_drop_saas_tables",
1962
+ name: "drop SaaS-only tenant_policies + eval_runs (host-owned, not engine schema)",
1963
+ sql: (
1964
+ /* sql */
1965
+ `
1966
+ drop table if exists nightowls.tenant_policies cascade;
1967
+ drop table if exists nightowls.eval_runs cascade;
1968
+ `
1969
+ )
1970
+ };
1971
+
1972
+ // src/migrations/0016_bundle_versions.ts
1973
+ var M0016_BUNDLE_VERSIONS = {
1974
+ version: "0016_bundle_versions",
1975
+ name: "bundles + bundle_versions (append-only bundle definition versioning) + immutability trigger",
1976
+ sql: (
1977
+ /* sql */
1978
+ `
1979
+ -- Head pointer (mirrors nightowls.agents). NULLS NOT DISTINCT so the common project_id = NULL case still
1980
+ -- collides on (org, slug) \u2014 required for the publish upsert's ON CONFLICT to fire.
1981
+ create table nightowls.bundles (
1982
+ id uuid primary key default gen_random_uuid(),
1983
+ org_id uuid not null references nightowls.orgs(id) on delete cascade,
1984
+ project_id uuid, slug text not null, current_version_id uuid,
1985
+ created_at timestamptz not null default now(),
1986
+ unique nulls not distinct (org_id, project_id, slug)
1987
+ );
1988
+
1989
+ -- Append-only versions (mirrors nightowls.agent_versions; the whole bundle content is a single jsonb snapshot).
1990
+ create table nightowls.bundle_versions (
1991
+ id uuid primary key default gen_random_uuid(),
1992
+ bundle_id uuid not null references nightowls.bundles(id) on delete cascade,
1993
+ org_id uuid not null references nightowls.orgs(id) on delete cascade,
1994
+ version integer not null,
1995
+ content jsonb not null,
1996
+ status text not null default 'draft' check (status in ('draft','published','archived')),
1997
+ created_by uuid, created_at timestamptz not null default now(),
1998
+ unique (bundle_id, version)
1999
+ );
2000
+ alter table nightowls.bundles add constraint bundles_current_version_fk
2001
+ foreign key (current_version_id) references nightowls.bundle_versions(id);
2002
+
2003
+ -- RLS + org indexes (mirrors 0001's per-table pattern for the new tables). The adapter uses the service role
2004
+ -- (RLS-exempt), so this is defense-in-depth for any future direct authenticated-client read: enable RLS, add an
2005
+ -- org-scoped SELECT policy (writes stay service-role-only, like every other definition table), and index org_id.
2006
+ alter table nightowls.bundles enable row level security;
2007
+ alter table nightowls.bundle_versions enable row level security;
2008
+ create policy org_read_bundles on nightowls.bundles
2009
+ for select to authenticated using ((select nightowls.is_org_member(org_id)));
2010
+ create policy org_read_bundle_versions on nightowls.bundle_versions
2011
+ for select to authenticated using ((select nightowls.is_org_member(org_id)));
2012
+ create index bundles_org_idx on nightowls.bundles (org_id);
2013
+ create index bundle_versions_org_idx on nightowls.bundle_versions (org_id);
2014
+
2015
+ -- Published-row immutability (mirrors 0014): once status='published', the content + identity columns are frozen
2016
+ -- and the row may not be DELETEd. The lifecycle status may still advance (e.g. \u2192 'archived'), which doesn't
2017
+ -- rewrite history. No INSERT trigger \u2014 appends are the whole point.
2018
+ create or replace function nightowls.bundle_versions_enforce_immutable()
2019
+ returns trigger language plpgsql as $$
2020
+ begin
2021
+ if (tg_op = 'DELETE') then
2022
+ if (old.status = 'published') then
2023
+ raise exception 'bundle_versions: published version v% of bundle % is immutable (DELETE forbidden \u2014 append-only)',
2024
+ old.version, old.bundle_id using errcode = 'restrict_violation';
2025
+ end if;
2026
+ return old;
2027
+ end if;
2028
+
2029
+ -- UPDATE: a published row's content + identity is frozen; allow only the lifecycle status to advance.
2030
+ if (old.status = 'published') then
2031
+ if ( new.bundle_id is distinct from old.bundle_id
2032
+ or new.org_id is distinct from old.org_id
2033
+ or new.version is distinct from old.version
2034
+ or new.content is distinct from old.content
2035
+ or new.created_at is distinct from old.created_at ) then
2036
+ raise exception 'bundle_versions: published version v% of bundle % is immutable (content UPDATE forbidden \u2014 append-only; republish a new version instead)',
2037
+ old.version, old.bundle_id using errcode = 'restrict_violation';
2038
+ end if;
2039
+ end if;
2040
+ return new;
2041
+ end; $$;
2042
+
2043
+ drop trigger if exists bundle_versions_immutable on nightowls.bundle_versions;
2044
+ create trigger bundle_versions_immutable before update or delete on nightowls.bundle_versions
2045
+ for each row execute function nightowls.bundle_versions_enforce_immutable();
1590
2046
  `
1591
2047
  )
1592
2048
  };
1593
2049
 
1594
2050
  // src/migrations/index.ts
1595
- var MIGRATIONS = [M0001_CORE, M0002_MASTRA, M0003_FOLLOWUPS, M0004_MEMORY_VECTOR, M0005_THREAD_TEXT_IDS, M0006_SCRATCHPAD, M0007_SCRATCHPAD_ENTRIES, M0008_FLOOR, M0009_SCRATCHPAD_REALTIME, M0010_PRESENCE_REALTIME, M0011_BROADCAST_ALLOWLIST, M0012_THREAD_SCOPED_RESOURCE, M0013_RENAME_SCHEMA];
2051
+ var MIGRATIONS = [M0001_CORE, M0002_MASTRA, M0003_FOLLOWUPS, M0004_MEMORY_VECTOR, M0005_THREAD_TEXT_IDS, M0006_SCRATCHPAD, M0007_SCRATCHPAD_ENTRIES, M0008_FLOOR, M0009_SCRATCHPAD_REALTIME, M0010_PRESENCE_REALTIME, M0011_BROADCAST_ALLOWLIST, M0012_THREAD_SCOPED_RESOURCE, M0013_RENAME_SCHEMA, M0014_AGENT_VERSIONS_IMMUTABLE, M0015_DROP_SAAS_TABLES, M0016_BUNDLE_VERSIONS];
1596
2052
 
1597
2053
  // src/plugin.ts
1598
2054
  var nightOwlsPlugin = {
@@ -1744,12 +2200,22 @@ function createPostgresFloor(pool, opts = {}) {
1744
2200
  function createSupabaseStorage(opts) {
1745
2201
  const ctx = makeCtx(opts);
1746
2202
  let invalidationSub = null;
2203
+ const versionedAgents = makeVersionedRepo(ctx);
2204
+ const writableBundles = makeBundleWritableRepo(ctx);
1747
2205
  return {
1748
- agents: makeAgentRepo(ctx),
2206
+ agents: versionedAgents,
2207
+ agentsWritable: versionedAgents,
2208
+ bundles: writableBundles,
2209
+ bundlesWritable: writableBundles,
1749
2210
  runs: makeRunStore(ctx),
1750
2211
  events: makeEventStore(ctx),
1751
2212
  messages: makeMessageStore(ctx),
2213
+ // FR-009: the engine ensures this thread row at run start, so a host no longer hand-writes raw SQL against
2214
+ // `nightowls.threads`, and `messages.append` cannot throw `unknown thread` through the supported path.
2215
+ threads: makeThreadStore(ctx),
1752
2216
  scratchpad: makeScratchpadStore(ctx),
2217
+ // FR-016: enumerate the engine's tenants for idempotent per-tenant crew backfill (pairs with ensureAgentVersion).
2218
+ listTenants: () => listTenants(ctx),
1753
2219
  // Record the suspended run in the tenant-scoped followup index so `runs.findSuspended` (the resume
1754
2220
  // authz gate) can resolve it. RE-OPEN on conflict (reset `answered_at`): Mastra REUSES the same
1755
2221
  // `toolCallId` when an agent asks AGAIN after a resume, so the engine's `followupId = runId:toolCallId`
@@ -1766,10 +2232,11 @@ function createSupabaseStorage(opts) {
1766
2232
  // Mark a followup answered so `findSuspended` (which filters `answered_at is null`) stops returning
1767
2233
  // it — closes the replay window once a resume begins. Tenant-scoped + idempotent.
1768
2234
  markFollowupAnswered: async (followupId, tenantId) => {
1769
- await ctx.pool.query("update followups set answered_at = now() where id=$1 and org_id=$2 and answered_at is null", [
2235
+ const r = await ctx.pool.query("update followups set answered_at = now() where id=$1 and org_id=$2 and answered_at is null", [
1770
2236
  followupId,
1771
2237
  tenantId
1772
2238
  ]);
2239
+ return (r.rowCount ?? 0) > 0;
1773
2240
  },
1774
2241
  // R12: cross-process cache invalidation via Postgres LISTEN. The engine wires this to
1775
2242
  // `rowCache.invalidate`; a `publishAgentVersion` elsewhere NOTIFYs the key and every instance evicts
@@ -1818,8 +2285,17 @@ function createSupabaseStorage(opts) {
1818
2285
  createMastraVectorStore,
1819
2286
  createPostgresFloor,
1820
2287
  createSupabaseStorage,
2288
+ ensureAgentVersion,
2289
+ hostOwnedOrgMembershipSql,
2290
+ hostOwnedOrgMigration,
1821
2291
  listAgentVersions,
2292
+ listTenants,
2293
+ makeBundleRepo,
2294
+ makeBundleWritableRepo,
2295
+ makeThreadStore,
2296
+ makeVersionedRepo,
1822
2297
  nightOwlsPlugin,
1823
2298
  publishAgentVersion,
1824
- rollbackAgentVersion
2299
+ rollbackAgentVersion,
2300
+ supabaseUsageSink
1825
2301
  });