@hachej/boring-core 0.1.63 → 0.1.65

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.
@@ -13,13 +13,13 @@ function createDatabase(config) {
13
13
  if (!config.databaseUrl) {
14
14
  throw new Error("databaseUrl is required to create a database connection");
15
15
  }
16
- const sql7 = postgres(config.databaseUrl, {
16
+ const sql8 = postgres(config.databaseUrl, {
17
17
  max: 10,
18
18
  idle_timeout: 20,
19
19
  connect_timeout: 10
20
20
  });
21
- const db = drizzle(sql7);
22
- return { db, sql: sql7 };
21
+ const db = drizzle(sql8);
22
+ return { db, sql: sql8 };
23
23
  }
24
24
 
25
25
  // src/server/db/migrate.ts
@@ -151,15 +151,19 @@ var LocalWorkspaceStore = class {
151
151
  wsSettings = /* @__PURE__ */ new Map();
152
152
  uiStates = /* @__PURE__ */ new Map();
153
153
  async create(userId, name, appId, opts) {
154
+ const id = opts?.id ?? randomUUID();
155
+ const existing = opts?.id ? this.workspaces.get(id) : void 0;
156
+ if (existing) return existing;
154
157
  const now = (/* @__PURE__ */ new Date()).toISOString();
155
158
  const ws = {
156
- id: randomUUID(),
159
+ id,
157
160
  appId,
158
161
  name,
159
162
  createdBy: userId,
160
163
  createdAt: now,
161
164
  deletedAt: null,
162
- isDefault: opts?.isDefault ?? false
165
+ isDefault: opts?.isDefault ?? false,
166
+ managedBy: opts?.managedBy ?? null
163
167
  };
164
168
  this.workspaces.set(ws.id, ws);
165
169
  const memberKey = `${ws.id}:${userId}`;
@@ -210,6 +214,16 @@ var LocalWorkspaceStore = class {
210
214
  if (!ws || ws.deletedAt) return null;
211
215
  return ws;
212
216
  }
217
+ async getIncludingDeleted(id) {
218
+ return this.workspaces.get(id) ?? null;
219
+ }
220
+ async restore(id) {
221
+ const ws = this.workspaces.get(id);
222
+ if (!ws) return null;
223
+ ws.deletedAt = null;
224
+ this.workspaces.set(id, ws);
225
+ return ws;
226
+ }
213
227
  async update(id, updates) {
214
228
  const ws = this.workspaces.get(id);
215
229
  if (!ws || ws.deletedAt) return null;
@@ -297,11 +311,11 @@ var LocalWorkspaceStore = class {
297
311
  this.members.set(key, updated);
298
312
  return { member: updated };
299
313
  }
300
- async removeMember(workspaceId, userId) {
314
+ async removeMember(workspaceId, userId, opts) {
301
315
  const key = `${workspaceId}:${userId}`;
302
316
  const membership = this.members.get(key);
303
317
  if (!membership) return { removed: false, code: ERROR_CODES.NOT_MEMBER };
304
- if (membership.role === "owner") {
318
+ if (!opts?.allowLastOwner && membership.role === "owner") {
305
319
  let otherOwnerExists = false;
306
320
  for (const m of this.members.values()) {
307
321
  if (m.workspaceId === workspaceId && m.userId !== userId && m.role === "owner") {
@@ -542,6 +556,7 @@ __export(schema_exports, {
542
556
  creditGrants: () => creditGrants,
543
557
  creditPurchases: () => creditPurchases,
544
558
  idempotencyKeys: () => idempotencyKeys,
559
+ modelBudgetReservations: () => modelBudgetReservations,
545
560
  sessions: () => sessions,
546
561
  sessionsRelations: () => sessionsRelations,
547
562
  telemetryEvents: () => telemetryEvents,
@@ -685,7 +700,8 @@ var workspaces = pgTable2(
685
700
  createdBy: uuid2("created_by").notNull().references(() => users.id),
686
701
  createdAt: timestamp2("created_at").defaultNow().notNull(),
687
702
  deletedAt: timestamp2("deleted_at"),
688
- isDefault: boolean2("is_default").notNull().default(false)
703
+ isDefault: boolean2("is_default").notNull().default(false),
704
+ managedBy: text2("managed_by")
689
705
  },
690
706
  (table) => [
691
707
  index2("workspaces_created_by_idx").on(table.createdBy),
@@ -952,6 +968,33 @@ var usageReservations = pgTable2(
952
968
  )
953
969
  ]
954
970
  );
971
+ var modelBudgetReservations = pgTable2(
972
+ "boring_model_budget_reservations",
973
+ {
974
+ id: uuid2("id").default(sql3`gen_random_uuid()`).primaryKey(),
975
+ userId: text2("user_id").notNull(),
976
+ workspaceId: text2("workspace_id"),
977
+ sessionId: text2("session_id"),
978
+ runId: text2("run_id").notNull(),
979
+ provider: text2("provider").notNull(),
980
+ model: text2("model").notNull(),
981
+ period: text2("period").notNull(),
982
+ amountMicros: bigint("amount_micros", { mode: "number" }).notNull(),
983
+ status: text2("status").notNull().default("active"),
984
+ createdAt: timestamp2("created_at").defaultNow().notNull(),
985
+ expiresAt: timestamp2("expires_at").notNull()
986
+ },
987
+ (table) => [
988
+ uniqueIndex("boring_model_budget_reservations_active_user_run_idx").on(table.userId, table.runId).where(sql3`${table.status} = 'active'`),
989
+ index2("boring_model_budget_reservations_budget_idx").on(table.userId, table.provider, table.model, table.period, table.status),
990
+ index2("boring_model_budget_reservations_stale_idx").on(table.status, table.expiresAt),
991
+ check("boring_model_budget_reservations_amount_check", sql3`${table.amountMicros} > 0`),
992
+ check(
993
+ "boring_model_budget_reservations_status_check",
994
+ sql3`${table.status} IN ('active', 'settled', 'released', 'expired')`
995
+ )
996
+ ]
997
+ );
955
998
  var usageLedger = pgTable2(
956
999
  "boring_usage_ledger",
957
1000
  {
@@ -1095,7 +1138,8 @@ function toWorkspace(row) {
1095
1138
  createdBy: row.createdBy,
1096
1139
  createdAt: row.createdAt.toISOString(),
1097
1140
  deletedAt: row.deletedAt?.toISOString() ?? null,
1098
- isDefault: row.isDefault
1141
+ isDefault: row.isDefault,
1142
+ managedBy: row.managedBy
1099
1143
  };
1100
1144
  }
1101
1145
  var PostgresWorkspaceStore = class {
@@ -1117,12 +1161,24 @@ var PostgresWorkspaceStore = class {
1117
1161
  // ---------------------------------------------------------------------------
1118
1162
  async create(userId, name, appId, opts) {
1119
1163
  return this.db.transaction(async (tx) => {
1120
- const [row] = await tx.insert(workspaces).values({ appId, name, createdBy: userId, isDefault: opts?.isDefault ?? false }).returning();
1121
- await tx.insert(workspaceMembers).values({
1122
- workspaceId: row.id,
1123
- userId,
1124
- role: "owner"
1164
+ const insert = tx.insert(workspaces).values({
1165
+ ...opts?.id ? { id: opts.id } : {},
1166
+ appId,
1167
+ name,
1168
+ createdBy: userId,
1169
+ isDefault: opts?.isDefault ?? false,
1170
+ managedBy: opts?.managedBy ?? null
1125
1171
  });
1172
+ const insertedRows = opts?.id ? await insert.onConflictDoNothing().returning() : await insert.returning();
1173
+ const row = insertedRows[0] ?? (opts?.id ? (await tx.select().from(workspaces).where(eq(workspaces.id, opts.id)).limit(1))[0] : void 0);
1174
+ if (!row) throw new Error(`Workspace ${opts?.id ?? name} was not created`);
1175
+ if (insertedRows.length > 0) {
1176
+ await tx.insert(workspaceMembers).values({
1177
+ workspaceId: row.id,
1178
+ userId,
1179
+ role: "owner"
1180
+ });
1181
+ }
1126
1182
  return toWorkspace(row);
1127
1183
  });
1128
1184
  }
@@ -1140,6 +1196,14 @@ var PostgresWorkspaceStore = class {
1140
1196
  const rows = await this.db.select().from(workspaces).where(and(eq(workspaces.id, id), isNull(workspaces.deletedAt))).limit(1);
1141
1197
  return rows.length > 0 ? toWorkspace(rows[0]) : null;
1142
1198
  }
1199
+ async getIncludingDeleted(id) {
1200
+ const rows = await this.db.select().from(workspaces).where(eq(workspaces.id, id)).limit(1);
1201
+ return rows.length > 0 ? toWorkspace(rows[0]) : null;
1202
+ }
1203
+ async restore(id) {
1204
+ const rows = await this.db.update(workspaces).set({ deletedAt: null }).where(eq(workspaces.id, id)).returning();
1205
+ return rows.length > 0 ? toWorkspace(rows[0]) : null;
1206
+ }
1143
1207
  async update(id, updates) {
1144
1208
  const rows = await this.db.update(workspaces).set(updates).where(and(eq(workspaces.id, id), isNull(workspaces.deletedAt))).returning();
1145
1209
  return rows.length > 0 ? toWorkspace(rows[0]) : null;
@@ -1253,7 +1317,7 @@ var PostgresWorkspaceStore = class {
1253
1317
  return { member: toWorkspaceMember(updated) };
1254
1318
  });
1255
1319
  }
1256
- async removeMember(workspaceId, userId) {
1320
+ async removeMember(workspaceId, userId, opts) {
1257
1321
  return this.db.transaction(async (tx) => {
1258
1322
  await tx.execute(sql4`
1259
1323
  SELECT user_id
@@ -1270,7 +1334,7 @@ var PostgresWorkspaceStore = class {
1270
1334
  ).limit(1);
1271
1335
  const role = memberRows[0]?.role;
1272
1336
  if (!role) return { removed: false, code: ERROR_CODES.NOT_MEMBER };
1273
- if (role === "owner") {
1337
+ if (!opts?.allowLastOwner && role === "owner") {
1274
1338
  const [{ count }] = await tx.select({ count: sql4`count(*)::int` }).from(workspaceMembers).where(
1275
1339
  and(
1276
1340
  eq(workspaceMembers.workspaceId, workspaceId),
@@ -1711,6 +1775,22 @@ import { eq as eq2, sql as sql5 } from "drizzle-orm";
1711
1775
  function normalizeEmail2(email) {
1712
1776
  return email.trim().toLowerCase();
1713
1777
  }
1778
+ function jsonbPath(path) {
1779
+ return sql5`ARRAY[${sql5.join(path.map((part) => sql5`${part}`), sql5`, `)}]::text[]`;
1780
+ }
1781
+ function jsonbSetPathExpression(path, value) {
1782
+ let expression = sql5`${userSettings.settings}`;
1783
+ for (let i = 1; i < path.length; i += 1) {
1784
+ const prefixPath = jsonbPath(path.slice(0, i));
1785
+ const existingObject = sql5`CASE
1786
+ WHEN jsonb_typeof(${userSettings.settings} #> ${prefixPath}) = 'object'
1787
+ THEN ${userSettings.settings} #> ${prefixPath}
1788
+ ELSE '{}'::jsonb
1789
+ END`;
1790
+ expression = sql5`jsonb_set(${expression}, ${prefixPath}, ${existingObject}, true)`;
1791
+ }
1792
+ return sql5`jsonb_set(${expression}, ${jsonbPath(path)}, ${JSON.stringify(value)}::jsonb, true)`;
1793
+ }
1714
1794
  function rowToUser(row) {
1715
1795
  return {
1716
1796
  id: row.id,
@@ -1797,6 +1877,60 @@ var PostgresUserStore = class {
1797
1877
  settings: rows[0].settings ?? {}
1798
1878
  };
1799
1879
  }
1880
+ async putClientUserSettings(userId, appId, updates) {
1881
+ if (!updates.settings) return await this.putUserSettings(userId, appId, { displayName: updates.displayName });
1882
+ const current = await this.getUserSettings(userId, appId);
1883
+ const clientSettings = Object.fromEntries(
1884
+ Object.entries(updates.settings).filter(([key]) => !key.startsWith("__server"))
1885
+ );
1886
+ const nextDisplayName = updates.displayName ?? current.displayName;
1887
+ const nextEmail = current.email;
1888
+ const rows = await this.db.insert(userSettings).values({
1889
+ userId,
1890
+ appId,
1891
+ displayName: nextDisplayName,
1892
+ email: nextEmail,
1893
+ settings: clientSettings
1894
+ }).onConflictDoUpdate({
1895
+ target: [userSettings.userId, userSettings.appId],
1896
+ set: {
1897
+ displayName: nextDisplayName,
1898
+ settings: sql5`(
1899
+ SELECT COALESCE(jsonb_object_agg(key, value), '{}'::jsonb)
1900
+ FROM jsonb_each(${userSettings.settings})
1901
+ WHERE left(key, 8) = '__server'
1902
+ ) || ${JSON.stringify(clientSettings)}::jsonb`,
1903
+ updatedAt: /* @__PURE__ */ new Date()
1904
+ }
1905
+ }).returning();
1906
+ return {
1907
+ displayName: rows[0].displayName,
1908
+ email: rows[0].email,
1909
+ settings: rows[0].settings ?? {}
1910
+ };
1911
+ }
1912
+ async patchUserSettingsJsonPath(userId, appId, path, value) {
1913
+ if (path.length === 0) throw new Error("settings JSON path must not be empty");
1914
+ const user = await this.getById(userId);
1915
+ const rows = await this.db.transaction(async (tx) => {
1916
+ await tx.insert(userSettings).values({
1917
+ userId,
1918
+ appId,
1919
+ displayName: user?.name ?? "",
1920
+ email: user?.email ?? "",
1921
+ settings: {}
1922
+ }).onConflictDoNothing({ target: [userSettings.userId, userSettings.appId] });
1923
+ return await tx.update(userSettings).set({
1924
+ settings: jsonbSetPathExpression(path, value),
1925
+ updatedAt: /* @__PURE__ */ new Date()
1926
+ }).where(sql5`${userSettings.userId} = ${userId} AND ${userSettings.appId} = ${appId}`).returning();
1927
+ });
1928
+ return {
1929
+ displayName: rows[0].displayName,
1930
+ email: rows[0].email,
1931
+ settings: rows[0].settings ?? {}
1932
+ };
1933
+ }
1800
1934
  };
1801
1935
 
1802
1936
  // src/server/db/stores/PostgresMeteringStore.ts
@@ -2318,6 +2452,265 @@ function describeUsage(source) {
2318
2452
  return { kind: "usage", description: "Agent usage" };
2319
2453
  }
2320
2454
 
2455
+ // src/server/db/stores/PostgresModelBudgetStore.ts
2456
+ import { and as and3, eq as eq4, inArray as inArray2, lt as lt2, or as or2, sql as sql7 } from "drizzle-orm";
2457
+ var ModelBudgetExceededError = class extends Error {
2458
+ constructor(usedMicros, heldMicros, budgetMicros, requestedMicros) {
2459
+ super("Budget reached for this model.");
2460
+ this.usedMicros = usedMicros;
2461
+ this.heldMicros = heldMicros;
2462
+ this.budgetMicros = budgetMicros;
2463
+ this.requestedMicros = requestedMicros;
2464
+ this.name = "ModelBudgetExceededError";
2465
+ }
2466
+ usedMicros;
2467
+ heldMicros;
2468
+ budgetMicros;
2469
+ requestedMicros;
2470
+ statusCode = 402;
2471
+ code = "MODEL_BUDGET_EXCEEDED";
2472
+ };
2473
+ var UPDATE_ID_BATCH_SIZE = 1e3;
2474
+ function assertPositiveSafeInteger(name, value) {
2475
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer`);
2476
+ }
2477
+ function monthPeriodUtc(now) {
2478
+ const year = now.getUTCFullYear();
2479
+ const month = now.getUTCMonth();
2480
+ return {
2481
+ period: `${year}-${String(month + 1).padStart(2, "0")}`,
2482
+ start: new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)),
2483
+ end: new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0))
2484
+ };
2485
+ }
2486
+ function requireFinishKey(input) {
2487
+ if (input.reservationId) return eq4(modelBudgetReservations.id, input.reservationId);
2488
+ if (!input.runId || !input.userId) throw new Error("finish requires reservationId or runId+userId");
2489
+ return and3(eq4(modelBudgetReservations.runId, input.runId), eq4(modelBudgetReservations.userId, input.userId));
2490
+ }
2491
+ function chunkIds(ids) {
2492
+ const chunks = [];
2493
+ for (let index3 = 0; index3 < ids.length; index3 += UPDATE_ID_BATCH_SIZE) {
2494
+ chunks.push(ids.slice(index3, index3 + UPDATE_ID_BATCH_SIZE));
2495
+ }
2496
+ return chunks;
2497
+ }
2498
+ function modelBudgetAdvisoryLockKey(target) {
2499
+ return `model-budget:${target.userId}:${target.provider}:${target.model}:${target.period}`;
2500
+ }
2501
+ async function lockModelBudgetTargets(executor, targets) {
2502
+ const keys = Array.from(new Set(targets.map(modelBudgetAdvisoryLockKey))).sort();
2503
+ for (const key of keys) {
2504
+ await executor.execute(sql7`SELECT pg_advisory_xact_lock(hashtext(${key}))`);
2505
+ }
2506
+ }
2507
+ var PostgresModelBudgetStore = class {
2508
+ constructor(db) {
2509
+ this.db = db;
2510
+ }
2511
+ db;
2512
+ static monthPeriodUtc(now = /* @__PURE__ */ new Date()) {
2513
+ return monthPeriodUtc(now).period;
2514
+ }
2515
+ async sweepExpired(now = /* @__PURE__ */ new Date()) {
2516
+ return this.db.transaction(async (tx) => {
2517
+ const expired = await tx.select({
2518
+ id: modelBudgetReservations.id,
2519
+ userId: modelBudgetReservations.userId,
2520
+ provider: modelBudgetReservations.provider,
2521
+ model: modelBudgetReservations.model,
2522
+ period: modelBudgetReservations.period
2523
+ }).from(modelBudgetReservations).where(and3(eq4(modelBudgetReservations.status, "active"), lt2(modelBudgetReservations.expiresAt, now)));
2524
+ if (expired.length === 0) return 0;
2525
+ await lockModelBudgetTargets(tx, expired);
2526
+ let count = 0;
2527
+ for (const ids of chunkIds(expired.map((row) => row.id))) {
2528
+ const rows = await tx.update(modelBudgetReservations).set({ status: "expired" }).where(and3(
2529
+ inArray2(modelBudgetReservations.id, ids),
2530
+ eq4(modelBudgetReservations.status, "active"),
2531
+ lt2(modelBudgetReservations.expiresAt, now)
2532
+ )).returning({ id: modelBudgetReservations.id });
2533
+ count += rows.length;
2534
+ }
2535
+ return count;
2536
+ });
2537
+ }
2538
+ async reserve(input) {
2539
+ if (!input.userId) throw new Error("reserve requires userId");
2540
+ if (!input.provider || !input.model) throw new Error("reserve requires provider/model");
2541
+ assertPositiveSafeInteger("budgetMicros", input.budgetMicros);
2542
+ assertPositiveSafeInteger("holdMicros", input.holdMicros);
2543
+ if (!Number.isSafeInteger(input.ttlSeconds) || input.ttlSeconds <= 0) throw new Error("ttlSeconds must be a positive safe integer");
2544
+ const now = input.now ?? /* @__PURE__ */ new Date();
2545
+ const period = monthPeriodUtc(now);
2546
+ const expiresAt = new Date(now.getTime() + input.ttlSeconds * 1e3);
2547
+ return this.db.transaction(async (tx) => {
2548
+ const expired = await tx.select({
2549
+ id: modelBudgetReservations.id,
2550
+ userId: modelBudgetReservations.userId,
2551
+ provider: modelBudgetReservations.provider,
2552
+ model: modelBudgetReservations.model,
2553
+ period: modelBudgetReservations.period
2554
+ }).from(modelBudgetReservations).where(and3(
2555
+ eq4(modelBudgetReservations.status, "active"),
2556
+ lt2(modelBudgetReservations.expiresAt, now),
2557
+ or2(
2558
+ and3(
2559
+ eq4(modelBudgetReservations.userId, input.userId),
2560
+ eq4(modelBudgetReservations.provider, input.provider),
2561
+ eq4(modelBudgetReservations.model, input.model),
2562
+ eq4(modelBudgetReservations.period, period.period)
2563
+ ),
2564
+ and3(
2565
+ eq4(modelBudgetReservations.userId, input.userId),
2566
+ eq4(modelBudgetReservations.runId, input.runId)
2567
+ )
2568
+ )
2569
+ ));
2570
+ await lockModelBudgetTargets(tx, [
2571
+ { userId: input.userId, provider: input.provider, model: input.model, period: period.period },
2572
+ ...expired
2573
+ ]);
2574
+ for (const ids of chunkIds(expired.map((row) => row.id))) {
2575
+ await tx.update(modelBudgetReservations).set({ status: "expired" }).where(and3(
2576
+ inArray2(modelBudgetReservations.id, ids),
2577
+ eq4(modelBudgetReservations.status, "active"),
2578
+ lt2(modelBudgetReservations.expiresAt, now)
2579
+ ));
2580
+ }
2581
+ const existing = await tx.select({
2582
+ id: modelBudgetReservations.id,
2583
+ provider: modelBudgetReservations.provider,
2584
+ model: modelBudgetReservations.model,
2585
+ period: modelBudgetReservations.period
2586
+ }).from(modelBudgetReservations).where(and3(
2587
+ eq4(modelBudgetReservations.status, "active"),
2588
+ eq4(modelBudgetReservations.userId, input.userId),
2589
+ eq4(modelBudgetReservations.runId, input.runId)
2590
+ )).limit(1);
2591
+ const prior = existing[0];
2592
+ if (prior) {
2593
+ if (prior.provider !== input.provider || prior.model !== input.model || prior.period !== period.period) {
2594
+ throw new Error(`active model budget reservation for run ${input.runId} has conflicting tuple`);
2595
+ }
2596
+ return { reservationId: prior.id, created: false, period: period.period };
2597
+ }
2598
+ const periodStartIso = period.start.toISOString();
2599
+ const periodEndIso = period.end.toISOString();
2600
+ const usageRows = await tx.execute(sql7`
2601
+ SELECT COALESCE(SUM(${usageLedger.billedCostMicros}), 0)::text AS total
2602
+ FROM ${usageLedger}
2603
+ WHERE ${usageLedger.userId} = ${input.userId}
2604
+ AND ${usageLedger.provider} = ${input.provider}
2605
+ AND ${usageLedger.model} = ${input.model}
2606
+ AND (
2607
+ (
2608
+ EXISTS (
2609
+ SELECT 1 FROM ${modelBudgetReservations} r
2610
+ WHERE r.user_id = ${usageLedger.userId}
2611
+ AND r.provider = ${usageLedger.provider}
2612
+ AND r.model = ${usageLedger.model}
2613
+ AND r.run_id = ${usageLedger.runId}
2614
+ AND r.period = ${period.period}
2615
+ AND r.status NOT IN ('active', 'settled')
2616
+ AND (
2617
+ ${usageLedger.metadata}->>'modelBudgetReservationId' = r.id::text
2618
+ OR (
2619
+ ${usageLedger.metadata}->>'modelBudgetReservationId' IS NULL
2620
+ AND r.created_at <= ${usageLedger.createdAt}
2621
+ AND NOT EXISTS (
2622
+ SELECT 1 FROM ${modelBudgetReservations} newer
2623
+ WHERE newer.user_id = r.user_id
2624
+ AND newer.provider = r.provider
2625
+ AND newer.model = r.model
2626
+ AND newer.run_id = r.run_id
2627
+ AND newer.created_at <= ${usageLedger.createdAt}
2628
+ AND (
2629
+ newer.created_at > r.created_at
2630
+ OR (newer.created_at = r.created_at AND newer.id > r.id)
2631
+ )
2632
+ )
2633
+ )
2634
+ )
2635
+ )
2636
+ )
2637
+ OR (
2638
+ ${usageLedger.createdAt} >= ${periodStartIso}::timestamp
2639
+ AND ${usageLedger.createdAt} < ${periodEndIso}::timestamp
2640
+ AND ${usageLedger.metadata}->>'modelBudgetReservationId' IS NULL
2641
+ AND NOT EXISTS (
2642
+ SELECT 1 FROM ${modelBudgetReservations} r
2643
+ WHERE r.user_id = ${usageLedger.userId}
2644
+ AND r.provider = ${usageLedger.provider}
2645
+ AND r.model = ${usageLedger.model}
2646
+ AND r.run_id = ${usageLedger.runId}
2647
+ AND r.created_at <= ${usageLedger.createdAt}
2648
+ )
2649
+ )
2650
+ )
2651
+ `);
2652
+ const heldRows = await tx.execute(sql7`
2653
+ SELECT COALESCE(SUM(amount_micros), 0)::text AS total
2654
+ FROM ${modelBudgetReservations}
2655
+ WHERE status = 'active'
2656
+ AND user_id = ${input.userId}
2657
+ AND provider = ${input.provider}
2658
+ AND model = ${input.model}
2659
+ AND period = ${period.period}
2660
+ `);
2661
+ const settledFallbackRows = await tx.execute(sql7`
2662
+ SELECT COALESCE(SUM(amount_micros), 0)::text AS total
2663
+ FROM ${modelBudgetReservations}
2664
+ WHERE status = 'settled'
2665
+ AND user_id = ${input.userId}
2666
+ AND provider = ${input.provider}
2667
+ AND model = ${input.model}
2668
+ AND period = ${period.period}
2669
+ `);
2670
+ const usedMicros = Number(usageRows[0]?.total ?? 0) + Number(settledFallbackRows[0]?.total ?? 0);
2671
+ const heldMicros = Number(heldRows[0]?.total ?? 0);
2672
+ if (usedMicros + heldMicros + input.holdMicros > input.budgetMicros) {
2673
+ throw new ModelBudgetExceededError(usedMicros, heldMicros, input.budgetMicros, input.holdMicros);
2674
+ }
2675
+ const inserted = await tx.insert(modelBudgetReservations).values({
2676
+ userId: input.userId,
2677
+ workspaceId: input.workspaceId ?? null,
2678
+ sessionId: input.sessionId ?? null,
2679
+ runId: input.runId,
2680
+ provider: input.provider,
2681
+ model: input.model,
2682
+ period: period.period,
2683
+ amountMicros: input.holdMicros,
2684
+ expiresAt
2685
+ }).returning({ id: modelBudgetReservations.id });
2686
+ return { reservationId: inserted[0].id, created: true, period: period.period };
2687
+ });
2688
+ }
2689
+ async settle(input) {
2690
+ await this.finish(input, "settled");
2691
+ }
2692
+ async release(input) {
2693
+ await this.finish(input, "released");
2694
+ }
2695
+ async finish(input, status) {
2696
+ await this.db.transaction(async (tx) => {
2697
+ const active = await tx.select({
2698
+ id: modelBudgetReservations.id,
2699
+ userId: modelBudgetReservations.userId,
2700
+ provider: modelBudgetReservations.provider,
2701
+ model: modelBudgetReservations.model,
2702
+ period: modelBudgetReservations.period
2703
+ }).from(modelBudgetReservations).where(and3(requireFinishKey(input), eq4(modelBudgetReservations.status, "active")));
2704
+ if (active.length === 0) return;
2705
+ await lockModelBudgetTargets(tx, active);
2706
+ await tx.update(modelBudgetReservations).set({ status }).where(and3(
2707
+ inArray2(modelBudgetReservations.id, active.map((row) => row.id)),
2708
+ eq4(modelBudgetReservations.status, "active")
2709
+ ));
2710
+ });
2711
+ }
2712
+ };
2713
+
2321
2714
  export {
2322
2715
  users,
2323
2716
  verification_tokens,
@@ -2340,5 +2733,7 @@ export {
2340
2733
  PostgresWorkspaceStore,
2341
2734
  PostgresUserStore,
2342
2735
  InsufficientCreditError,
2343
- PostgresMeteringStore
2736
+ PostgresMeteringStore,
2737
+ ModelBudgetExceededError,
2738
+ PostgresModelBudgetStore
2344
2739
  };
@@ -1,4 +1,4 @@
1
- import { C as CoreConfig, c as Workspace, E as ERROR_CODES, M as MemberRole, d as WorkspaceMember, U as User, e as WorkspaceInvite, f as WorkspaceRuntime, W as WorkspaceRuntimeResourceSelector, a as WorkspaceRuntimeResource, b as WorkspaceRuntimeResourceInput, g as CapabilitiesResponse } from './types-Bb7I-83I.js';
1
+ import { C as CoreConfig, c as Workspace, E as ERROR_CODES, M as MemberRole, d as WorkspaceMember, U as User, e as WorkspaceInvite, f as WorkspaceRuntime, W as WorkspaceRuntimeResourceSelector, a as WorkspaceRuntimeResource, b as WorkspaceRuntimeResourceInput, g as CapabilitiesResponse } from './types-DVgeIw1d.js';
2
2
  import { drizzle } from 'drizzle-orm/postgres-js';
3
3
  import postgres from 'postgres';
4
4
 
@@ -37,13 +37,30 @@ interface UserStore {
37
37
  email: string;
38
38
  settings: Record<string, unknown>;
39
39
  }>;
40
+ putClientUserSettings?(userId: string, appId: string, updates: {
41
+ displayName?: string;
42
+ settings?: Record<string, unknown>;
43
+ }): Promise<{
44
+ displayName: string;
45
+ email: string;
46
+ settings: Record<string, unknown>;
47
+ }>;
48
+ patchUserSettingsJsonPath?(userId: string, appId: string, path: string[], value: unknown): Promise<{
49
+ displayName: string;
50
+ email: string;
51
+ settings: Record<string, unknown>;
52
+ }>;
40
53
  }
41
54
  interface WorkspaceStore {
42
55
  create(userId: string, name: string, appId: string, opts?: {
43
56
  isDefault?: boolean;
57
+ id?: string;
58
+ managedBy?: string;
44
59
  }): Promise<Workspace>;
45
60
  list(userId: string, appId: string): Promise<Workspace[]>;
46
61
  get(id: string): Promise<Workspace | null>;
62
+ getIncludingDeleted(id: string): Promise<Workspace | null>;
63
+ restore(id: string): Promise<Workspace | null>;
47
64
  update(id: string, updates: Partial<Pick<Workspace, 'name'>>): Promise<Workspace | null>;
48
65
  delete(id: string): Promise<{
49
66
  removed: boolean;
@@ -60,7 +77,9 @@ interface WorkspaceStore {
60
77
  member?: WorkspaceMember;
61
78
  code?: typeof ERROR_CODES.LAST_OWNER | typeof ERROR_CODES.NOT_MEMBER;
62
79
  }>;
63
- removeMember(workspaceId: string, userId: string): Promise<{
80
+ removeMember(workspaceId: string, userId: string, opts?: {
81
+ allowLastOwner?: boolean;
82
+ }): Promise<{
64
83
  removed: boolean;
65
84
  code?: typeof ERROR_CODES.LAST_OWNER | typeof ERROR_CODES.NOT_MEMBER;
66
85
  }>;