@nightowlsdev/storage-supabase 1.0.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,13 +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,
29
33
  makeBundleRepo: () => makeBundleRepo,
30
34
  makeBundleWritableRepo: () => makeBundleWritableRepo,
35
+ makeThreadStore: () => makeThreadStore,
31
36
  makeVersionedRepo: () => makeVersionedRepo,
32
37
  nightOwlsPlugin: () => nightOwlsPlugin,
33
38
  publishAgentVersion: () => publishAgentVersion,
34
- rollbackAgentVersion: () => rollbackAgentVersion
39
+ rollbackAgentVersion: () => rollbackAgentVersion,
40
+ supabaseUsageSink: () => supabaseUsageSink
35
41
  });
36
42
  module.exports = __toCommonJS(index_exports);
37
43
 
@@ -308,6 +314,18 @@ function makeScratchpadStore(ctx) {
308
314
  };
309
315
  }
310
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
+
311
329
  // src/agents.ts
312
330
  var import_core3 = require("@nightowlsdev/core");
313
331
 
@@ -468,6 +486,41 @@ async function publishAgentVersion(ctx, def) {
468
486
  client.release();
469
487
  }
470
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
+ }
471
524
  async function listAgentVersions(ctx, tenantId, slug) {
472
525
  const rows = await many(
473
526
  ctx.pool,
@@ -633,6 +686,78 @@ function createMastraVectorStore(opts) {
633
686
  return new import_pg2.PgVector({ id: "nightowls-vector", connectionString: opts.dbUrl, schemaName: "nightowls" });
634
687
  }
635
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
+
636
761
  // src/migrations/0001_core.ts
637
762
  var M0001_CORE = {
638
763
  version: "0001_core",
@@ -2085,7 +2210,12 @@ function createSupabaseStorage(opts) {
2085
2210
  runs: makeRunStore(ctx),
2086
2211
  events: makeEventStore(ctx),
2087
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),
2088
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),
2089
2219
  // Record the suspended run in the tenant-scoped followup index so `runs.findSuspended` (the resume
2090
2220
  // authz gate) can resolve it. RE-OPEN on conflict (reset `answered_at`): Mastra REUSES the same
2091
2221
  // `toolCallId` when an agent asks AGAIN after a resume, so the engine's `followupId = runId:toolCallId`
@@ -2155,11 +2285,17 @@ function createSupabaseStorage(opts) {
2155
2285
  createMastraVectorStore,
2156
2286
  createPostgresFloor,
2157
2287
  createSupabaseStorage,
2288
+ ensureAgentVersion,
2289
+ hostOwnedOrgMembershipSql,
2290
+ hostOwnedOrgMigration,
2158
2291
  listAgentVersions,
2292
+ listTenants,
2159
2293
  makeBundleRepo,
2160
2294
  makeBundleWritableRepo,
2295
+ makeThreadStore,
2161
2296
  makeVersionedRepo,
2162
2297
  nightOwlsPlugin,
2163
2298
  publishAgentVersion,
2164
- rollbackAgentVersion
2299
+ rollbackAgentVersion,
2300
+ supabaseUsageSink
2165
2301
  });
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AgentVersionContent, SwarmActor, AgentVersionInfo, VersionedRepo, BundleRepo, BundleWritableRepo, ContainerFloor, StorageAdapter } from '@nightowlsdev/core';
1
+ import { AgentVersionContent, SwarmActor, AgentVersionInfo, VersionedRepo, ThreadStore, SwarmEvent, SwarmContext, BundleRepo, BundleWritableRepo, ContainerFloor, StorageAdapter } from '@nightowlsdev/core';
2
2
  export { AgentVersionInfo } from '@nightowlsdev/core';
3
3
  import { Pool } from 'pg';
4
4
  import { SupabaseClient } from '@supabase/supabase-js';
@@ -56,6 +56,23 @@ type PublishAgentDef = AgentVersionContent & {
56
56
  declare function publishAgentVersion(ctx: Ctx$1, def: PublishAgentDef): Promise<{
57
57
  version: number;
58
58
  }>;
59
+ /**
60
+ * FR-016 — idempotently publish an agent version ONLY if the tenant has no head for that slug yet. The "seed a
61
+ * crew for tenant X if absent" primitive a host loops over `listTenants()` for backfill, so it never re-publishes
62
+ * (no version churn) on a tenant that already has the agent. Returns the head version + whether THIS call created
63
+ * it. (Use `publishAgentVersion` directly when you intentionally want a new version every time.)
64
+ *
65
+ * ATOMIC: the existence check + the publish run in ONE transaction under a per-(tenant,slug) advisory lock, so two
66
+ * concurrent backfill workers for the same slug can't both observe "absent" and each append a version — the second
67
+ * waiter sees v1 already present and returns `created:false`.
68
+ */
69
+ declare function ensureAgentVersion(ctx: Ctx$1, def: PublishAgentDef): Promise<{
70
+ version: number;
71
+ created: boolean;
72
+ }>;
73
+ /** FR-016 — enumerate the engine's tenants (the `orgs` the engine knows), so a host can backfill each tenant's
74
+ * crew without raw SQL against whatever host table happens to hold tenants. Ordered oldest→newest. */
75
+ declare function listTenants(ctx: Ctx$1): Promise<string[]>;
59
76
  /** List an agent's versions (oldest→newest), flagging the current head. Tenant-scoped. Read-only — use it to
60
77
  * pick a `toVersion` for `rollbackAgentVersion`. Returns [] for an unknown agent. */
61
78
  declare function listAgentVersions(ctx: Ctx$1, tenantId: string, slug: string): Promise<AgentVersionInfo[]>;
@@ -76,15 +93,51 @@ declare function rollbackAgentVersion(ctx: Ctx$1, args: {
76
93
  restoredFrom: number;
77
94
  }>;
78
95
 
79
- /** The READ-ONLY bundle repo (head/getVersion/listSlugs). The writable surface lives in `makeBundleWritableRepo`. */
80
- declare function makeBundleRepo(ctx: Ctx$1): BundleRepo;
81
96
  /**
82
- * The WRITABLE bundle definition repo (BN2) the core `BundleWritableRepo` contract backed by Postgres. Extends
83
- * the read repo with the append-only `publish`/`rollback`/`listVersions` surface, on the SAME shared
84
- * `appendVersion` primitive as agents (so a bundle gets the same race-safe append-and-flip + audit + the
85
- * published-row immutability trigger). Every mutation enforces the non-bypassable agent-bar first.
97
+ * FR-009 the supported, schema-private way to create the `threads` row a run FK-references. `runs.thread_id` is
98
+ * a NOT NULL FK to `threads(id)`, and `messages.append` throws `unknown thread` without it, so a host previously
99
+ * had to reach into the raw pool and hardcode the engine's private column names. `ensure` does an insert-or-ignore
100
+ * on the primary key, so it is safe to call before every run (the engine calls it at run start). `org_id` /
101
+ * `user_id` are NOT NULL in the schema; `project_id` is the optional host-owned sub-scope.
86
102
  */
87
- declare function makeBundleWritableRepo(ctx: Ctx$1): BundleWritableRepo;
103
+ declare function makeThreadStore(ctx: Ctx$1): ThreadStore;
104
+
105
+ /** A flat row to upsert: column name → value. Column names are HOST code (trusted), validated to a safe identifier. */
106
+ type UsageRow = Record<string, string | number | boolean | null>;
107
+ interface SupabaseUsageSinkOpts {
108
+ /** The pg pool to write through (e.g. `storage.ctx.pool`). */
109
+ pool: Pool;
110
+ /** Destination table (schema-qualified if needed, e.g. `"public.llm_usage_logs"`). */
111
+ table: string;
112
+ /**
113
+ * The column that carries the engine's per-generation `generationId`. The sink upserts `on conflict (<this>) do
114
+ * nothing`, so a re-delivered usage event (realtime replay, retry) inserts at most once. Must be UNIQUE/PK in
115
+ * the table. Default `"generation_id"`.
116
+ */
117
+ conflictColumn?: string;
118
+ /**
119
+ * Map ONE `swarm.usage` event → the row to insert. Defaults to a sensible shape
120
+ * (`generation_id`, `run_id`, `org_id`, `agent_slug`, `model_id`, `input_tokens`, `output_tokens`, `cost_usd`).
121
+ * Override to match your table. Return `null` to skip an event.
122
+ */
123
+ map?: (ev: Extract<SwarmEvent, {
124
+ type: "swarm.usage";
125
+ }>, ctx: SwarmContext) => UsageRow | null;
126
+ }
127
+ /**
128
+ * FR-004 — an `onEvent` handler that records each per-generation `swarm.usage` into a host Supabase table, keyed
129
+ * idempotently by the engine's stable `generationId` (no double-count under delegation, no OTel pipeline). Wire it
130
+ * onto `defineSwarm({ onEvent })` (compose with your own observer if you have one). Failures are the caller's to
131
+ * handle — the engine swallows a throwing `onEvent` so a sink hiccup never breaks a run, but you should log your own.
132
+ *
133
+ * NOTE: this records the engine's METERED (token-priced) cost — the framework never knows a provider's post-hoc
134
+ * BILLED amount. Use `generationId` to reconcile "actual" cost later if your provider exposes it (see FR-004).
135
+ *
136
+ * @example
137
+ * const sink = supabaseUsageSink({ pool: storage.ctx.pool, table: "llm_usage_logs" });
138
+ * defineSwarm({ ..., onEvent: sink });
139
+ */
140
+ declare function supabaseUsageSink(opts: SupabaseUsageSinkOpts): (ev: SwarmEvent, ctx: SwarmContext) => Promise<void>;
88
141
 
89
142
  /** A single Night Owls migration: a stable `version`, a human `name`, and fully-qualified `nightowls.*` SQL.
90
143
  * Night Owls contributes these to the host's `supabase/migrations/` (via `owl install`/`db eject`);
@@ -96,6 +149,37 @@ interface Migration {
96
149
  }
97
150
  declare const MIGRATIONS: Migration[];
98
151
 
152
+ interface HostOrgSourceOpts {
153
+ /** The engine schema the override targets (the deployed name). Default `"nightowls"`. */
154
+ schema?: string;
155
+ /** The host membership table, schema-qualified, e.g. `"public.organization_members"`. */
156
+ membershipTable: string;
157
+ /** The org-id column on the membership table. Default `"organization_id"`. */
158
+ orgIdColumn?: string;
159
+ /** The user-id column on the membership table (compared to `userIdExpr`). Default `"user_id"`. */
160
+ userIdColumn?: string;
161
+ /** The SQL expression for the current user id (must match `userIdColumn`'s type). Default `(select auth.uid())`. */
162
+ userIdExpr?: string;
163
+ /** Migration version label. Default `"host_org_source"`. */
164
+ version?: string;
165
+ }
166
+ /** Generate the SQL that overrides `<schema>.is_org_member` to read the host's membership table (+ the GRANT the
167
+ * Realtime gate needs to read it as the `authenticated` role during a private subscribe). */
168
+ declare function hostOwnedOrgMembershipSql(opts: HostOrgSourceOpts): string;
169
+ /** The same override packaged as a `Migration` so a host can append it to its migration set AFTER `0001_core`
170
+ * (it `create or replace`s the function the core migration defined). */
171
+ declare function hostOwnedOrgMigration(opts: HostOrgSourceOpts): Migration;
172
+
173
+ /** The READ-ONLY bundle repo (head/getVersion/listSlugs). The writable surface lives in `makeBundleWritableRepo`. */
174
+ declare function makeBundleRepo(ctx: Ctx$1): BundleRepo;
175
+ /**
176
+ * The WRITABLE bundle definition repo (BN2) — the core `BundleWritableRepo` contract backed by Postgres. Extends
177
+ * the read repo with the append-only `publish`/`rollback`/`listVersions` surface, on the SAME shared
178
+ * `appendVersion` primitive as agents (so a bundle gets the same race-safe append-and-flip + audit + the
179
+ * published-row immutability trigger). Every mutation enforces the non-bypassable agent-bar first.
180
+ */
181
+ declare function makeBundleWritableRepo(ctx: Ctx$1): BundleWritableRepo;
182
+
99
183
  /**
100
184
  * The Night Owls adapter plugin manifest. `@nightowlsdev/cli` discovers this from the host's installed
101
185
  * `@nightowlsdev/*` deps, dynamic-imports it, and acts on it declaratively:
@@ -181,4 +265,4 @@ interface SupabaseStorage extends StorageAdapter {
181
265
  }
182
266
  declare function createSupabaseStorage(opts: SupabaseStorageOpts): SupabaseStorage;
183
267
 
184
- export { type Ctx$1 as Ctx, MIGRATIONS, type Migration, type PostgresFloorOpts, type PublishAgentDef, type SupabaseStorage, type SupabaseStorageOpts, createMastraPgStore, createMastraVectorStore, createPostgresFloor, createSupabaseStorage, listAgentVersions, makeBundleRepo, makeBundleWritableRepo, makeVersionedRepo, nightOwlsPlugin, publishAgentVersion, rollbackAgentVersion };
268
+ export { type Ctx$1 as Ctx, type HostOrgSourceOpts, MIGRATIONS, type Migration, type PostgresFloorOpts, type PublishAgentDef, type SupabaseStorage, type SupabaseStorageOpts, type SupabaseUsageSinkOpts, type UsageRow, createMastraPgStore, createMastraVectorStore, createPostgresFloor, createSupabaseStorage, ensureAgentVersion, hostOwnedOrgMembershipSql, hostOwnedOrgMigration, listAgentVersions, listTenants, makeBundleRepo, makeBundleWritableRepo, makeThreadStore, makeVersionedRepo, nightOwlsPlugin, publishAgentVersion, rollbackAgentVersion, supabaseUsageSink };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AgentVersionContent, SwarmActor, AgentVersionInfo, VersionedRepo, BundleRepo, BundleWritableRepo, ContainerFloor, StorageAdapter } from '@nightowlsdev/core';
1
+ import { AgentVersionContent, SwarmActor, AgentVersionInfo, VersionedRepo, ThreadStore, SwarmEvent, SwarmContext, BundleRepo, BundleWritableRepo, ContainerFloor, StorageAdapter } from '@nightowlsdev/core';
2
2
  export { AgentVersionInfo } from '@nightowlsdev/core';
3
3
  import { Pool } from 'pg';
4
4
  import { SupabaseClient } from '@supabase/supabase-js';
@@ -56,6 +56,23 @@ type PublishAgentDef = AgentVersionContent & {
56
56
  declare function publishAgentVersion(ctx: Ctx$1, def: PublishAgentDef): Promise<{
57
57
  version: number;
58
58
  }>;
59
+ /**
60
+ * FR-016 — idempotently publish an agent version ONLY if the tenant has no head for that slug yet. The "seed a
61
+ * crew for tenant X if absent" primitive a host loops over `listTenants()` for backfill, so it never re-publishes
62
+ * (no version churn) on a tenant that already has the agent. Returns the head version + whether THIS call created
63
+ * it. (Use `publishAgentVersion` directly when you intentionally want a new version every time.)
64
+ *
65
+ * ATOMIC: the existence check + the publish run in ONE transaction under a per-(tenant,slug) advisory lock, so two
66
+ * concurrent backfill workers for the same slug can't both observe "absent" and each append a version — the second
67
+ * waiter sees v1 already present and returns `created:false`.
68
+ */
69
+ declare function ensureAgentVersion(ctx: Ctx$1, def: PublishAgentDef): Promise<{
70
+ version: number;
71
+ created: boolean;
72
+ }>;
73
+ /** FR-016 — enumerate the engine's tenants (the `orgs` the engine knows), so a host can backfill each tenant's
74
+ * crew without raw SQL against whatever host table happens to hold tenants. Ordered oldest→newest. */
75
+ declare function listTenants(ctx: Ctx$1): Promise<string[]>;
59
76
  /** List an agent's versions (oldest→newest), flagging the current head. Tenant-scoped. Read-only — use it to
60
77
  * pick a `toVersion` for `rollbackAgentVersion`. Returns [] for an unknown agent. */
61
78
  declare function listAgentVersions(ctx: Ctx$1, tenantId: string, slug: string): Promise<AgentVersionInfo[]>;
@@ -76,15 +93,51 @@ declare function rollbackAgentVersion(ctx: Ctx$1, args: {
76
93
  restoredFrom: number;
77
94
  }>;
78
95
 
79
- /** The READ-ONLY bundle repo (head/getVersion/listSlugs). The writable surface lives in `makeBundleWritableRepo`. */
80
- declare function makeBundleRepo(ctx: Ctx$1): BundleRepo;
81
96
  /**
82
- * The WRITABLE bundle definition repo (BN2) the core `BundleWritableRepo` contract backed by Postgres. Extends
83
- * the read repo with the append-only `publish`/`rollback`/`listVersions` surface, on the SAME shared
84
- * `appendVersion` primitive as agents (so a bundle gets the same race-safe append-and-flip + audit + the
85
- * published-row immutability trigger). Every mutation enforces the non-bypassable agent-bar first.
97
+ * FR-009 the supported, schema-private way to create the `threads` row a run FK-references. `runs.thread_id` is
98
+ * a NOT NULL FK to `threads(id)`, and `messages.append` throws `unknown thread` without it, so a host previously
99
+ * had to reach into the raw pool and hardcode the engine's private column names. `ensure` does an insert-or-ignore
100
+ * on the primary key, so it is safe to call before every run (the engine calls it at run start). `org_id` /
101
+ * `user_id` are NOT NULL in the schema; `project_id` is the optional host-owned sub-scope.
86
102
  */
87
- declare function makeBundleWritableRepo(ctx: Ctx$1): BundleWritableRepo;
103
+ declare function makeThreadStore(ctx: Ctx$1): ThreadStore;
104
+
105
+ /** A flat row to upsert: column name → value. Column names are HOST code (trusted), validated to a safe identifier. */
106
+ type UsageRow = Record<string, string | number | boolean | null>;
107
+ interface SupabaseUsageSinkOpts {
108
+ /** The pg pool to write through (e.g. `storage.ctx.pool`). */
109
+ pool: Pool;
110
+ /** Destination table (schema-qualified if needed, e.g. `"public.llm_usage_logs"`). */
111
+ table: string;
112
+ /**
113
+ * The column that carries the engine's per-generation `generationId`. The sink upserts `on conflict (<this>) do
114
+ * nothing`, so a re-delivered usage event (realtime replay, retry) inserts at most once. Must be UNIQUE/PK in
115
+ * the table. Default `"generation_id"`.
116
+ */
117
+ conflictColumn?: string;
118
+ /**
119
+ * Map ONE `swarm.usage` event → the row to insert. Defaults to a sensible shape
120
+ * (`generation_id`, `run_id`, `org_id`, `agent_slug`, `model_id`, `input_tokens`, `output_tokens`, `cost_usd`).
121
+ * Override to match your table. Return `null` to skip an event.
122
+ */
123
+ map?: (ev: Extract<SwarmEvent, {
124
+ type: "swarm.usage";
125
+ }>, ctx: SwarmContext) => UsageRow | null;
126
+ }
127
+ /**
128
+ * FR-004 — an `onEvent` handler that records each per-generation `swarm.usage` into a host Supabase table, keyed
129
+ * idempotently by the engine's stable `generationId` (no double-count under delegation, no OTel pipeline). Wire it
130
+ * onto `defineSwarm({ onEvent })` (compose with your own observer if you have one). Failures are the caller's to
131
+ * handle — the engine swallows a throwing `onEvent` so a sink hiccup never breaks a run, but you should log your own.
132
+ *
133
+ * NOTE: this records the engine's METERED (token-priced) cost — the framework never knows a provider's post-hoc
134
+ * BILLED amount. Use `generationId` to reconcile "actual" cost later if your provider exposes it (see FR-004).
135
+ *
136
+ * @example
137
+ * const sink = supabaseUsageSink({ pool: storage.ctx.pool, table: "llm_usage_logs" });
138
+ * defineSwarm({ ..., onEvent: sink });
139
+ */
140
+ declare function supabaseUsageSink(opts: SupabaseUsageSinkOpts): (ev: SwarmEvent, ctx: SwarmContext) => Promise<void>;
88
141
 
89
142
  /** A single Night Owls migration: a stable `version`, a human `name`, and fully-qualified `nightowls.*` SQL.
90
143
  * Night Owls contributes these to the host's `supabase/migrations/` (via `owl install`/`db eject`);
@@ -96,6 +149,37 @@ interface Migration {
96
149
  }
97
150
  declare const MIGRATIONS: Migration[];
98
151
 
152
+ interface HostOrgSourceOpts {
153
+ /** The engine schema the override targets (the deployed name). Default `"nightowls"`. */
154
+ schema?: string;
155
+ /** The host membership table, schema-qualified, e.g. `"public.organization_members"`. */
156
+ membershipTable: string;
157
+ /** The org-id column on the membership table. Default `"organization_id"`. */
158
+ orgIdColumn?: string;
159
+ /** The user-id column on the membership table (compared to `userIdExpr`). Default `"user_id"`. */
160
+ userIdColumn?: string;
161
+ /** The SQL expression for the current user id (must match `userIdColumn`'s type). Default `(select auth.uid())`. */
162
+ userIdExpr?: string;
163
+ /** Migration version label. Default `"host_org_source"`. */
164
+ version?: string;
165
+ }
166
+ /** Generate the SQL that overrides `<schema>.is_org_member` to read the host's membership table (+ the GRANT the
167
+ * Realtime gate needs to read it as the `authenticated` role during a private subscribe). */
168
+ declare function hostOwnedOrgMembershipSql(opts: HostOrgSourceOpts): string;
169
+ /** The same override packaged as a `Migration` so a host can append it to its migration set AFTER `0001_core`
170
+ * (it `create or replace`s the function the core migration defined). */
171
+ declare function hostOwnedOrgMigration(opts: HostOrgSourceOpts): Migration;
172
+
173
+ /** The READ-ONLY bundle repo (head/getVersion/listSlugs). The writable surface lives in `makeBundleWritableRepo`. */
174
+ declare function makeBundleRepo(ctx: Ctx$1): BundleRepo;
175
+ /**
176
+ * The WRITABLE bundle definition repo (BN2) — the core `BundleWritableRepo` contract backed by Postgres. Extends
177
+ * the read repo with the append-only `publish`/`rollback`/`listVersions` surface, on the SAME shared
178
+ * `appendVersion` primitive as agents (so a bundle gets the same race-safe append-and-flip + audit + the
179
+ * published-row immutability trigger). Every mutation enforces the non-bypassable agent-bar first.
180
+ */
181
+ declare function makeBundleWritableRepo(ctx: Ctx$1): BundleWritableRepo;
182
+
99
183
  /**
100
184
  * The Night Owls adapter plugin manifest. `@nightowlsdev/cli` discovers this from the host's installed
101
185
  * `@nightowlsdev/*` deps, dynamic-imports it, and acts on it declaratively:
@@ -181,4 +265,4 @@ interface SupabaseStorage extends StorageAdapter {
181
265
  }
182
266
  declare function createSupabaseStorage(opts: SupabaseStorageOpts): SupabaseStorage;
183
267
 
184
- export { type Ctx$1 as Ctx, MIGRATIONS, type Migration, type PostgresFloorOpts, type PublishAgentDef, type SupabaseStorage, type SupabaseStorageOpts, createMastraPgStore, createMastraVectorStore, createPostgresFloor, createSupabaseStorage, listAgentVersions, makeBundleRepo, makeBundleWritableRepo, makeVersionedRepo, nightOwlsPlugin, publishAgentVersion, rollbackAgentVersion };
268
+ export { type Ctx$1 as Ctx, type HostOrgSourceOpts, MIGRATIONS, type Migration, type PostgresFloorOpts, type PublishAgentDef, type SupabaseStorage, type SupabaseStorageOpts, type SupabaseUsageSinkOpts, type UsageRow, createMastraPgStore, createMastraVectorStore, createPostgresFloor, createSupabaseStorage, ensureAgentVersion, hostOwnedOrgMembershipSql, hostOwnedOrgMigration, listAgentVersions, listTenants, makeBundleRepo, makeBundleWritableRepo, makeThreadStore, makeVersionedRepo, nightOwlsPlugin, publishAgentVersion, rollbackAgentVersion, supabaseUsageSink };
package/dist/index.js CHANGED
@@ -271,6 +271,18 @@ function makeScratchpadStore(ctx) {
271
271
  };
272
272
  }
273
273
 
274
+ // src/threads.ts
275
+ function makeThreadStore(ctx) {
276
+ return {
277
+ async ensure({ id, orgId, userId, projectId }) {
278
+ await ctx.pool.query(
279
+ "insert into threads(id, org_id, user_id, project_id) values($1,$2,$3,$4) on conflict (id) do nothing",
280
+ [id, orgId, userId, projectId ?? null]
281
+ );
282
+ }
283
+ };
284
+ }
285
+
274
286
  // src/agents.ts
275
287
  import { assertActorMayMutateDefinition } from "@nightowlsdev/core";
276
288
 
@@ -431,6 +443,41 @@ async function publishAgentVersion(ctx, def) {
431
443
  client.release();
432
444
  }
433
445
  }
446
+ async function ensureAgentVersion(ctx, def) {
447
+ const actor = def.actor ?? SEED_ACTOR;
448
+ assertActorMayMutateDefinition(actor);
449
+ const client = await ctx.pool.connect();
450
+ try {
451
+ await client.query("begin");
452
+ await client.query("select pg_advisory_xact_lock(hashtext($1), hashtext($2))", ["ensure_agent", `${def.tenantId}:${def.slug}`]);
453
+ const head = (await client.query(
454
+ `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`,
455
+ [def.tenantId, def.slug]
456
+ )).rows[0];
457
+ if (head) {
458
+ await client.query("commit");
459
+ return { version: head.version, created: false };
460
+ }
461
+ const agent = (await client.query(
462
+ `insert into agents(org_id, slug, is_orchestrator) values($1,$2,$3)
463
+ on conflict (org_id, project_id, slug) do update set slug=excluded.slug returning id`,
464
+ [def.tenantId, def.slug, def.role === "orchestrator"]
465
+ )).rows[0];
466
+ const version = await commitVersion(client, agent.id, def.tenantId, def, "publish", auditActor(actor), { slug: def.slug });
467
+ await client.query("commit");
468
+ await notifyInvalidate(client, def.tenantId, def.slug);
469
+ return { version, created: true };
470
+ } catch (e) {
471
+ await client.query("rollback");
472
+ throw e;
473
+ } finally {
474
+ client.release();
475
+ }
476
+ }
477
+ async function listTenants(ctx) {
478
+ const rows = await many(ctx.pool, "select id::text as id from orgs order by created_at asc");
479
+ return rows.map((r) => r.id);
480
+ }
434
481
  async function listAgentVersions(ctx, tenantId, slug) {
435
482
  const rows = await many(
436
483
  ctx.pool,
@@ -596,6 +643,78 @@ function createMastraVectorStore(opts) {
596
643
  return new PgVector({ id: "nightowls-vector", connectionString: opts.dbUrl, schemaName: "nightowls" });
597
644
  }
598
645
 
646
+ // src/usage-sink.ts
647
+ var IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
648
+ var TABLE = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;
649
+ function defaultMap(ev, ctx) {
650
+ const b = ev.data.breakdown;
651
+ return {
652
+ generation_id: ev.data.generationId,
653
+ run_id: ctx.runId,
654
+ org_id: ctx.tenantId,
655
+ agent_slug: ev.data.slug,
656
+ model_id: ev.data.modelId,
657
+ input_tokens: b.inputTokens ?? 0,
658
+ output_tokens: b.outputTokens ?? 0,
659
+ cost_usd: ev.data.cost.usd
660
+ };
661
+ }
662
+ function supabaseUsageSink(opts) {
663
+ const conflict = opts.conflictColumn ?? "generation_id";
664
+ if (!IDENT.test(conflict)) throw new Error(`supabaseUsageSink: unsafe conflictColumn ${JSON.stringify(conflict)}`);
665
+ if (!TABLE.test(opts.table)) throw new Error(`supabaseUsageSink: unsafe table ${JSON.stringify(opts.table)}`);
666
+ const map = opts.map ?? defaultMap;
667
+ return async (ev, ctx) => {
668
+ if (ev.type !== "swarm.usage") return;
669
+ const row = map(ev, ctx);
670
+ if (!row) return;
671
+ const cols = Object.keys(row);
672
+ for (const c of cols) if (!IDENT.test(c)) throw new Error(`supabaseUsageSink: unsafe column ${JSON.stringify(c)}`);
673
+ const placeholders = cols.map((_c, i) => `$${i + 1}`).join(",");
674
+ const sql = `insert into ${opts.table}(${cols.join(",")}) values(${placeholders}) on conflict (${conflict}) do nothing`;
675
+ await opts.pool.query(sql, cols.map((c) => row[c]));
676
+ };
677
+ }
678
+
679
+ // src/host-org-source.ts
680
+ var QUALIFIED = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;
681
+ var PLAIN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
682
+ function assertIdent(v, kind, re) {
683
+ if (!re.test(v)) throw new Error(`hostOwnedOrgMembership: unsafe ${kind} ${JSON.stringify(v)}`);
684
+ }
685
+ function hostOwnedOrgMembershipSql(opts) {
686
+ const schema = opts.schema ?? "nightowls";
687
+ const orgIdColumn = opts.orgIdColumn ?? "organization_id";
688
+ const userIdColumn = opts.userIdColumn ?? "user_id";
689
+ const userIdExpr = opts.userIdExpr ?? "(select auth.uid())";
690
+ assertIdent(schema, "schema", PLAIN);
691
+ assertIdent(opts.membershipTable, "membershipTable", QUALIFIED);
692
+ assertIdent(orgIdColumn, "orgIdColumn", PLAIN);
693
+ assertIdent(userIdColumn, "userIdColumn", PLAIN);
694
+ if (/;|--|\/\*|\$\$/.test(userIdExpr)) throw new Error(`hostOwnedOrgMembership: unsafe userIdExpr ${JSON.stringify(userIdExpr)}`);
695
+ return `-- FR-015 \u2014 override ${schema}.is_org_member to read the host membership table ${opts.membershipTable}.
696
+ create or replace function ${schema}.is_org_member(p_org uuid)
697
+ returns boolean language sql stable set search_path = '' as $$
698
+ select exists (
699
+ select 1 from ${opts.membershipTable} m
700
+ where m.${orgIdColumn} = p_org and m.${userIdColumn} = ${userIdExpr}
701
+ );
702
+ $$;
703
+ -- The Realtime gate calls is_org_member as the AUTHENTICATED role during a private subscribe, so it must be able
704
+ -- to read the membership table. (No-op if you already granted it.)
705
+ grant select on ${opts.membershipTable} to authenticated;
706
+ `;
707
+ }
708
+ function hostOwnedOrgMigration(opts) {
709
+ const version = opts.version ?? "host_org_source";
710
+ assertIdent(version, "version", PLAIN);
711
+ return {
712
+ version,
713
+ name: `host-owned org/membership source (is_org_member \u2192 ${opts.membershipTable})`,
714
+ sql: hostOwnedOrgMembershipSql(opts)
715
+ };
716
+ }
717
+
599
718
  // src/migrations/0001_core.ts
600
719
  var M0001_CORE = {
601
720
  version: "0001_core",
@@ -2048,7 +2167,12 @@ function createSupabaseStorage(opts) {
2048
2167
  runs: makeRunStore(ctx),
2049
2168
  events: makeEventStore(ctx),
2050
2169
  messages: makeMessageStore(ctx),
2170
+ // FR-009: the engine ensures this thread row at run start, so a host no longer hand-writes raw SQL against
2171
+ // `nightowls.threads`, and `messages.append` cannot throw `unknown thread` through the supported path.
2172
+ threads: makeThreadStore(ctx),
2051
2173
  scratchpad: makeScratchpadStore(ctx),
2174
+ // FR-016: enumerate the engine's tenants for idempotent per-tenant crew backfill (pairs with ensureAgentVersion).
2175
+ listTenants: () => listTenants(ctx),
2052
2176
  // Record the suspended run in the tenant-scoped followup index so `runs.findSuspended` (the resume
2053
2177
  // authz gate) can resolve it. RE-OPEN on conflict (reset `answered_at`): Mastra REUSES the same
2054
2178
  // `toolCallId` when an agent asks AGAIN after a resume, so the engine's `followupId = runId:toolCallId`
@@ -2117,11 +2241,17 @@ export {
2117
2241
  createMastraVectorStore,
2118
2242
  createPostgresFloor,
2119
2243
  createSupabaseStorage,
2244
+ ensureAgentVersion,
2245
+ hostOwnedOrgMembershipSql,
2246
+ hostOwnedOrgMigration,
2120
2247
  listAgentVersions,
2248
+ listTenants,
2121
2249
  makeBundleRepo,
2122
2250
  makeBundleWritableRepo,
2251
+ makeThreadStore,
2123
2252
  makeVersionedRepo,
2124
2253
  nightOwlsPlugin,
2125
2254
  publishAgentVersion,
2126
- rollbackAgentVersion
2255
+ rollbackAgentVersion,
2256
+ supabaseUsageSink
2127
2257
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightowlsdev/storage-supabase",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -33,7 +33,7 @@
33
33
  "peerDependencies": {
34
34
  "@mastra/core": "^1.38.0",
35
35
  "@mastra/pg": "^1.12.0",
36
- "@nightowlsdev/core": "0.4.0"
36
+ "@nightowlsdev/core": "0.5.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@mastra/core": "^1.38.0",
@@ -46,9 +46,9 @@
46
46
  "typescript": "6.0.3",
47
47
  "vitest": "^3.2.0",
48
48
  "zod": "^4.0.0",
49
- "@nightowlsdev/tsconfig": "0.0.0",
50
- "@nightowlsdev/core": "0.4.0",
51
- "@nightowlsdev/eslint-config": "0.0.0"
49
+ "@nightowlsdev/core": "0.5.0",
50
+ "@nightowlsdev/eslint-config": "0.0.0",
51
+ "@nightowlsdev/tsconfig": "0.0.0"
52
52
  },
53
53
  "scripts": {
54
54
  "build": "tsup",