@antasphere/chassis-db 0.0.0-stage → 1.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/LICENSE +82 -0
- package/dist/auth-schema.d.ts +2132 -0
- package/dist/auth-schema.d.ts.map +1 -0
- package/dist/auth-schema.js +251 -0
- package/dist/auth-schema.js.map +1 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +24 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/migrate.d.ts +50 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +97 -0
- package/dist/migrate.js.map +1 -0
- package/dist/schema.d.ts +2804 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +620 -0
- package/dist/schema.js.map +1 -0
- package/package.json +45 -3
- package/src/auth-schema.ts +300 -0
- package/src/client.ts +59 -0
- package/src/index.ts +3 -0
- package/src/migrate.ts +136 -0
- package/src/schema.ts +734 -0
- package/README.md +0 -3
package/src/schema.ts
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import {
|
|
3
|
+
bigint,
|
|
4
|
+
boolean,
|
|
5
|
+
check,
|
|
6
|
+
doublePrecision,
|
|
7
|
+
index,
|
|
8
|
+
integer,
|
|
9
|
+
jsonb,
|
|
10
|
+
pgTable,
|
|
11
|
+
primaryKey,
|
|
12
|
+
smallint,
|
|
13
|
+
text,
|
|
14
|
+
timestamp,
|
|
15
|
+
uniqueIndex,
|
|
16
|
+
uuid
|
|
17
|
+
} from 'drizzle-orm/pg-core';
|
|
18
|
+
import { account, user } from './auth-schema.js';
|
|
19
|
+
|
|
20
|
+
// Better Auth owns the shape of user/session/account/verification (generated
|
|
21
|
+
// via @better-auth/cli, snapshot-checked in CI); everything below is domain.
|
|
22
|
+
export * from './auth-schema.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Singleton instance row. Exactly one row (id = 'instance') exists after
|
|
26
|
+
* first-boot setup; its presence is what makes POST /api/v1/setup return 410.
|
|
27
|
+
* Inserted by the setup endpoint with ON CONFLICT DO NOTHING so concurrent
|
|
28
|
+
* setup calls cannot both win.
|
|
29
|
+
*/
|
|
30
|
+
export const instanceSettings = pgTable('instance_settings', {
|
|
31
|
+
id: text('id').primaryKey(), // always 'instance'
|
|
32
|
+
instanceId: text('instance_id').notNull(), // ULID minted at setup; used in usage events + discovery
|
|
33
|
+
name: text('name').notNull(),
|
|
34
|
+
setupCompletedAt: timestamp('setup_completed_at', { withTimezone: true }).notNull().defaultNow(),
|
|
35
|
+
schemaVersion: integer('schema_version').notNull().default(1),
|
|
36
|
+
telemetryEnabled: boolean('telemetry_enabled').notNull().default(false),
|
|
37
|
+
/**
|
|
38
|
+
* The EDITION this instance was set up as (R7 boot guard,
|
|
39
|
+
* internal/federation.md). Boot refuses an env EDITION that differs unless
|
|
40
|
+
* EDITION_CHANGE_ALLOWED=true re-stamps it — an edition flip under
|
|
41
|
+
* existing users/workspaces silently changes identity semantics. The
|
|
42
|
+
* default covers pre-column rows honestly: everything so far is oss.
|
|
43
|
+
*/
|
|
44
|
+
edition: text('edition').notNull().default('oss'),
|
|
45
|
+
/**
|
|
46
|
+
* The user setup minted as the instance operator (CLOUD-3, PRDCT-1356).
|
|
47
|
+
* On cloud the operator holds NO membership (every cloud workspace is a
|
|
48
|
+
* hub projection), which made them structurally an orphan: the purge
|
|
49
|
+
* deleted the only local credential and the only break-glass identity.
|
|
50
|
+
* A durable record here excludes them from the sweep by construction —
|
|
51
|
+
* not by an env allowlist the operator may never have set. NULL on
|
|
52
|
+
* instances set up before this column existed.
|
|
53
|
+
*/
|
|
54
|
+
operatorUserId: text('operator_user_id')
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Multi-workspace schema AND runtime (ADR 014): setup creates the FIRST
|
|
59
|
+
* workspace, the cloud edition creates later ones through the registry's
|
|
60
|
+
* WorkspaceService, and every request is scoped to exactly ONE workspace —
|
|
61
|
+
* the one named by the presented credential. Every domain table carries a
|
|
62
|
+
* workspace_id and every read/write filters on the principal's.
|
|
63
|
+
*/
|
|
64
|
+
export const workspaceHubStatuses = ['active', 'suspended'] as const;
|
|
65
|
+
export type WorkspaceHubStatus = (typeof workspaceHubStatuses)[number];
|
|
66
|
+
|
|
67
|
+
export const workspaces = pgTable(
|
|
68
|
+
'workspaces',
|
|
69
|
+
{
|
|
70
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
71
|
+
name: text('name').notNull(),
|
|
72
|
+
// Central-rail seam: a projected workspace names the ONE hub org it
|
|
73
|
+
// mirrors (cloud edition SSO, internal/federation.md); NULL = locally owned.
|
|
74
|
+
centralAccountId: text('central_account_id'),
|
|
75
|
+
/**
|
|
76
|
+
* The hub-asserted org status, materialized locally so suspension is
|
|
77
|
+
* readable inside the resolvers (visible-but-blocked). Written ONLY by
|
|
78
|
+
* the cloud edition's hub reconcile; local workspaces and every oss row
|
|
79
|
+
* stay 'active' forever.
|
|
80
|
+
*/
|
|
81
|
+
hubStatus: text('hub_status', { enum: workspaceHubStatuses }).notNull().default('active'),
|
|
82
|
+
/**
|
|
83
|
+
* The workspace's look (PRDCT-2439 made it per workspace, in the browser;
|
|
84
|
+
* the settings pass of 2026-09-19 made it a fact of the workspace): one
|
|
85
|
+
* of the brand's theme keys and one pattern key of its library, as the
|
|
86
|
+
* dashboard names them. NULL = the dashboard's default for this
|
|
87
|
+
* workspace (the paper theme, a form dealt from the id). Validated as
|
|
88
|
+
* short slugs server-side; the dashboard falls back to its defaults on
|
|
89
|
+
* a key it does not know, so a retired theme never breaks a workspace.
|
|
90
|
+
*/
|
|
91
|
+
lookTheme: text('look_theme'),
|
|
92
|
+
lookPattern: text('look_pattern'),
|
|
93
|
+
/** 0..1: how much of the field's gradient shows, how much grain sits on it. NULL = the brand's constant. */
|
|
94
|
+
lookField: doublePrecision('look_field'),
|
|
95
|
+
lookGrain: doublePrecision('look_grain'),
|
|
96
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
|
97
|
+
},
|
|
98
|
+
(t) => [
|
|
99
|
+
// A hub org projects into AT MOST ONE workspace (migration 0021): two
|
|
100
|
+
// concurrent first-logins into a fresh org race their inserts, and the
|
|
101
|
+
// loser must land on the winner's row (ON CONFLICT + re-select in the
|
|
102
|
+
// SSO projection) — never on a second projection of the same org.
|
|
103
|
+
uniqueIndex('workspaces_central_account_uniq')
|
|
104
|
+
.on(t.centralAccountId)
|
|
105
|
+
.where(sql`${t.centralAccountId} IS NOT NULL`),
|
|
106
|
+
check('workspaces_hub_status_check', sql`${t.hubStatus} IN ('active', 'suspended')`)
|
|
107
|
+
]
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
export const workspaceRoles = ['owner', 'admin', 'member'] as const;
|
|
111
|
+
export type WorkspaceRole = (typeof workspaceRoles)[number];
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Where a membership row came from — who its source of truth is (G2,
|
|
115
|
+
* the cloud-binding plan §5). Pure data in Phase 1 (no enforcement):
|
|
116
|
+
*
|
|
117
|
+
* - 'local': created by setup, workspace invitations, or admin surfaces —
|
|
118
|
+
* this instance owns it.
|
|
119
|
+
* - 'hub': created/updated ONLY by the cloud edition's SSO JIT projection
|
|
120
|
+
* and re-asserted against the hub (Phase 3+); local surfaces never mint it.
|
|
121
|
+
* - 'guest': created by the collaborator claim path — an external party
|
|
122
|
+
* invited to ONE resource whose membership exists only because principal
|
|
123
|
+
* resolution requires one. Excluded from hub re-assertion; guest
|
|
124
|
+
* capability limits arrive in Phase 6.
|
|
125
|
+
*
|
|
126
|
+
* No backfill needed: pre-launch, no production data — the default covers
|
|
127
|
+
* every existing row's honest origin.
|
|
128
|
+
*/
|
|
129
|
+
export const workspaceMemberOrigins = ['local', 'hub', 'guest'] as const;
|
|
130
|
+
export type WorkspaceMemberOrigin = (typeof workspaceMemberOrigins)[number];
|
|
131
|
+
|
|
132
|
+
export const workspaceMembers = pgTable(
|
|
133
|
+
'workspace_members',
|
|
134
|
+
{
|
|
135
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
136
|
+
workspaceId: uuid('workspace_id')
|
|
137
|
+
.notNull()
|
|
138
|
+
.references(() => workspaces.id, { onDelete: 'cascade' }),
|
|
139
|
+
userId: text('user_id')
|
|
140
|
+
.notNull()
|
|
141
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
142
|
+
role: text('role', { enum: workspaceRoles }).notNull().default('member'),
|
|
143
|
+
origin: text('origin', { enum: workspaceMemberOrigins }).notNull().default('local'),
|
|
144
|
+
isActive: boolean('is_active').notNull().default(true),
|
|
145
|
+
/**
|
|
146
|
+
* The user's DEFAULT workspace marker (user-scoped credential model): a
|
|
147
|
+
* request that names no workspace resolves here first, then falls back
|
|
148
|
+
* to the deterministic oldest-active ordering. At most one row per user
|
|
149
|
+
* (partial unique index). Nothing sets it locally yet — on cloud the hub
|
|
150
|
+
* reconcile materializes the hub-level per-user default org into it; on
|
|
151
|
+
* oss it stays false, so resolution is byte-identical to the pre-column
|
|
152
|
+
* behavior.
|
|
153
|
+
*/
|
|
154
|
+
isDefault: boolean('is_default').notNull().default(false),
|
|
155
|
+
invitedBy: text('invited_by').references(() => user.id, { onDelete: 'set null' }),
|
|
156
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
157
|
+
lastSeenAt: timestamp('last_seen_at', { withTimezone: true })
|
|
158
|
+
},
|
|
159
|
+
(t) => [
|
|
160
|
+
uniqueIndex('workspace_members_workspace_user_uniq').on(t.workspaceId, t.userId),
|
|
161
|
+
index('workspace_members_user_idx').on(t.userId),
|
|
162
|
+
// Serves the admin API's keyset pagination (workspace_id, created_at DESC, id DESC).
|
|
163
|
+
index('workspace_members_workspace_created_id_idx').on(t.workspaceId, t.createdAt, t.id),
|
|
164
|
+
// ONE default workspace per user, enforced by the database.
|
|
165
|
+
uniqueIndex('workspace_members_user_default_uniq')
|
|
166
|
+
.on(t.userId)
|
|
167
|
+
.where(sql`${t.isDefault}`)
|
|
168
|
+
]
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Token-bearing invitations. Tokens are never stored — only their sha256 —
|
|
173
|
+
* and the acceptance link always works without SMTP (copyable link).
|
|
174
|
+
*
|
|
175
|
+
* Two tokens per invitation, one row: `token_hash` is the admin-visible
|
|
176
|
+
* copyable link (always returned by the create response), `email_token_hash`
|
|
177
|
+
* is a SECOND token that appears ONLY in the invitation email. Accepting with
|
|
178
|
+
* the emailed token proves control of the invited address (the admin never
|
|
179
|
+
* saw it), so that accept path may honestly set `emailVerified`; accepting
|
|
180
|
+
* with the copyable link proves nothing about the address. Nullable because
|
|
181
|
+
* pre-existing invitations (and rows from products that disable the email
|
|
182
|
+
* leg) have no emailed token.
|
|
183
|
+
*/
|
|
184
|
+
export const invitations = pgTable(
|
|
185
|
+
'invitations',
|
|
186
|
+
{
|
|
187
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
188
|
+
workspaceId: uuid('workspace_id')
|
|
189
|
+
.notNull()
|
|
190
|
+
.references(() => workspaces.id, { onDelete: 'cascade' }),
|
|
191
|
+
email: text('email').notNull(),
|
|
192
|
+
role: text('role', { enum: workspaceRoles }).notNull().default('member'),
|
|
193
|
+
tokenHash: text('token_hash').notNull(),
|
|
194
|
+
emailTokenHash: text('email_token_hash'),
|
|
195
|
+
invitedBy: text('invited_by')
|
|
196
|
+
.notNull()
|
|
197
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
198
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
199
|
+
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
|
|
200
|
+
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
201
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
|
202
|
+
},
|
|
203
|
+
(t) => [
|
|
204
|
+
uniqueIndex('invitations_token_hash_uniq').on(t.tokenHash),
|
|
205
|
+
uniqueIndex('invitations_email_token_hash_uniq').on(t.emailTokenHash),
|
|
206
|
+
// Serves the admin API's keyset pagination (workspace_id, created_at DESC, id DESC).
|
|
207
|
+
index('invitations_workspace_created_id_idx').on(t.workspaceId, t.createdAt, t.id),
|
|
208
|
+
index('invitations_email_idx').on(t.email)
|
|
209
|
+
]
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Demo passes (the demo pass spec, section 3): a link an owner mints so that
|
|
214
|
+
* ONE member of the workspace is signed in without a password, for a
|
|
215
|
+
* demonstration. Only while DEMO_SIGN_IN is on, on the self-hosted edition.
|
|
216
|
+
* The secret is sign-in-equivalent, so it is stored nowhere: `secret_hash`
|
|
217
|
+
* is its sha256 hex, looked up through the unique index at redeem, the same
|
|
218
|
+
* shape as the invitation tokens. A pass is revoked, never deleted by a
|
|
219
|
+
* route; it goes with its workspace or its person.
|
|
220
|
+
*/
|
|
221
|
+
export const demoPasses = pgTable(
|
|
222
|
+
'demo_passes',
|
|
223
|
+
{
|
|
224
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
225
|
+
workspaceId: uuid('workspace_id')
|
|
226
|
+
.notNull()
|
|
227
|
+
.references(() => workspaces.id, { onDelete: 'cascade' }),
|
|
228
|
+
/** The person the pass signs in. */
|
|
229
|
+
userId: text('user_id')
|
|
230
|
+
.notNull()
|
|
231
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
232
|
+
/** The page the link lands on (checked by isSafeDemoPath at mint). */
|
|
233
|
+
targetPath: text('target_path').notNull().default('/'),
|
|
234
|
+
secretHash: text('secret_hash').notNull(),
|
|
235
|
+
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
|
236
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
237
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
238
|
+
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
239
|
+
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
|
240
|
+
useCount: integer('use_count').notNull().default(0)
|
|
241
|
+
},
|
|
242
|
+
(t) => [
|
|
243
|
+
uniqueIndex('demo_passes_secret_hash_uniq').on(t.secretHash),
|
|
244
|
+
// The owner's list: one workspace's passes, newest first.
|
|
245
|
+
index('demo_passes_workspace_created_idx').on(t.workspaceId, t.createdAt)
|
|
246
|
+
]
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* One row per session a demo pass opened (`session_id` is the Better Auth
|
|
251
|
+
* `session.id`). It is what marks the audit rows written under such a
|
|
252
|
+
* session with `metadata.demoPassId`, so a workspace's trail tells what was
|
|
253
|
+
* done through a demo link from what the person did themselves.
|
|
254
|
+
*/
|
|
255
|
+
export const demoPassSessions = pgTable('demo_pass_sessions', {
|
|
256
|
+
sessionId: text('session_id').primaryKey(),
|
|
257
|
+
passId: uuid('pass_id')
|
|
258
|
+
.notNull()
|
|
259
|
+
.references(() => demoPasses.id, { onDelete: 'cascade' }),
|
|
260
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* API keys: `<prefix>_<keyId8>_<secret>`. keyId gives O(1) indexed lookup;
|
|
265
|
+
* the secret is stored as sha256(secret + server pepper) and compared in
|
|
266
|
+
* constant time. Minted by sessions only — a key never mints a key.
|
|
267
|
+
*
|
|
268
|
+
* A key is a USER credential (user-scoped credential model): it acts as its
|
|
269
|
+
* creator, and the target workspace is chosen per request (X-Workspace-Id /
|
|
270
|
+
* the user's default) against the creator's LIVE memberships. `workspace_id`
|
|
271
|
+
* is an OPTIONAL PIN, not the identity: NULL (the mint default) = the key
|
|
272
|
+
* reaches any workspace its creator is an active member of; a value pins the
|
|
273
|
+
* key to that ONE workspace forever (least privilege — and the grandfathered
|
|
274
|
+
* behavior of every key minted before the model change, which stays exactly
|
|
275
|
+
* as issued).
|
|
276
|
+
*/
|
|
277
|
+
export const apiKeys = pgTable(
|
|
278
|
+
'api_keys',
|
|
279
|
+
{
|
|
280
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
281
|
+
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }),
|
|
282
|
+
keyId: text('key_id').notNull(),
|
|
283
|
+
secretHash: text('secret_hash').notNull(),
|
|
284
|
+
// Which pepper version hashed this key's secret (apps/server
|
|
285
|
+
// apikeys/peppers.ts). Version 1 = the AUTH_SECRET-derived pepper that
|
|
286
|
+
// hashed every key before versioning existed (hence the backfilling
|
|
287
|
+
// default); higher versions come from API_KEY_PEPPERS. Resolution fails
|
|
288
|
+
// closed on a version it cannot map.
|
|
289
|
+
pepperVersion: smallint('pepper_version').notNull().default(1),
|
|
290
|
+
name: text('name').notNull(),
|
|
291
|
+
scopes: text('scopes').array().notNull(),
|
|
292
|
+
createdBy: text('created_by')
|
|
293
|
+
.notNull()
|
|
294
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
295
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
296
|
+
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
|
297
|
+
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
298
|
+
// null = never expires; enforced at resolution, normalized to revoked_at
|
|
299
|
+
// by the nightly sweep. Deliberately unindexed: the sweep seq-scans a
|
|
300
|
+
// tiny table once a night, and the hot path rides api_keys_key_id_uniq.
|
|
301
|
+
expiresAt: timestamp('expires_at', { withTimezone: true })
|
|
302
|
+
},
|
|
303
|
+
(t) => [
|
|
304
|
+
uniqueIndex('api_keys_key_id_uniq').on(t.keyId),
|
|
305
|
+
// Serves the admin API's keyset pagination (workspace_id, created_at DESC, id DESC).
|
|
306
|
+
index('api_keys_workspace_created_id_idx').on(t.workspaceId, t.createdAt, t.id),
|
|
307
|
+
// Serves the own-keys listing (keys are user credentials: created_by,
|
|
308
|
+
// created_at DESC, id DESC).
|
|
309
|
+
index('api_keys_created_by_created_id_idx').on(t.createdBy, t.createdAt, t.id)
|
|
310
|
+
]
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Idempotency-Key replay store for the non-idempotent create POSTs. One row
|
|
315
|
+
* per (workspace, user, client key): claimed (insert-first) before the
|
|
316
|
+
* handler runs, then filled with the response so a retried create replays
|
|
317
|
+
* instead of double-creating. The body is stored AES-256-GCM encrypted
|
|
318
|
+
* (see apps/server middleware/idempotency.ts) because these responses carry
|
|
319
|
+
* one-shot secrets — the full API key, the invitation accept token, the
|
|
320
|
+
* reset link — whose plaintext is deliberately never persisted anywhere
|
|
321
|
+
* else. Rows live 24h; a nightly purge deletes expired ones.
|
|
322
|
+
*/
|
|
323
|
+
export const idempotencyKeys = pgTable(
|
|
324
|
+
'idempotency_keys',
|
|
325
|
+
{
|
|
326
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
327
|
+
workspaceId: uuid('workspace_id')
|
|
328
|
+
.notNull()
|
|
329
|
+
.references(() => workspaces.id, { onDelete: 'cascade' }),
|
|
330
|
+
userId: text('user_id')
|
|
331
|
+
.notNull()
|
|
332
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
333
|
+
key: text('key').notNull(),
|
|
334
|
+
requestHash: text('request_hash').notNull(),
|
|
335
|
+
// Both null while the first request is in flight; filled best-effort
|
|
336
|
+
// once the handler's 2xx response is known.
|
|
337
|
+
responseStatus: integer('response_status'),
|
|
338
|
+
responseBodyEnc: text('response_body_enc'),
|
|
339
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
340
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull()
|
|
341
|
+
},
|
|
342
|
+
(t) => [
|
|
343
|
+
uniqueIndex('idempotency_keys_ws_user_key_uniq').on(t.workspaceId, t.userId, t.key),
|
|
344
|
+
// Serves the nightly purge (expires_at range scan).
|
|
345
|
+
index('idempotency_keys_expires_idx').on(t.expiresAt)
|
|
346
|
+
]
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* One-time-use ledger for hub→tool exchange JWTs (`POST /sso/cli-connect`,
|
|
351
|
+
* cloud edition only — internal/federation.md P5). The hub mints every 120 s
|
|
352
|
+
* exchange token with a unique `jti`; consuming a token INSERTs its jti
|
|
353
|
+
* here first, and the primary-key conflict IS the replay detection — a
|
|
354
|
+
* replayed token inside its TTL loses the insert and is rejected before it
|
|
355
|
+
* can mint a second key. Postgres-backed so the guard is MULTI-REPLICA
|
|
356
|
+
* SAFE by construction (every replica races the same table), unlike any
|
|
357
|
+
* in-memory set. Rows are inert once `expires_at` passes (the token itself
|
|
358
|
+
* is expired then — the hub never re-mints a jti) and are swept
|
|
359
|
+
* best-effort on later exchanges; the table stays tiny (rows live ~2 min,
|
|
360
|
+
* arrivals are rate-limited), so the sweep needs no index.
|
|
361
|
+
*/
|
|
362
|
+
export const ssoConnectJtis = pgTable('sso_connect_jtis', {
|
|
363
|
+
jti: text('jti').primaryKey(),
|
|
364
|
+
/** The token's own `exp` — after this the row only documents history. */
|
|
365
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
366
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The hub-grant PRESENTATION marker (PRDCT-1370, internal/federation.md
|
|
371
|
+
* "Ambiguous refresh outcomes"): written by HubGrantService immediately
|
|
372
|
+
* BEFORE it presents a user's refresh token to the hub's token endpoint,
|
|
373
|
+
* cleared once the hub's answer is known (a rotation, or a definitive
|
|
374
|
+
* refusal). A row that survives therefore means "this exact token was
|
|
375
|
+
* presented and its answer never arrived" — a client-side timeout, a lost
|
|
376
|
+
* response, a socket reset after the hub committed. The hub rotates BEFORE
|
|
377
|
+
* it answers and cannot roll back, so blindly re-presenting that token
|
|
378
|
+
* would trip the hub's RFC 9700 reuse detection and tear down the user's
|
|
379
|
+
* whole (client, user) grant family, the CLI grant included. With the
|
|
380
|
+
* marker in place the next refresh PROBES the token through the hub's
|
|
381
|
+
* RFC 7662 introspection endpoint first (read-only: it never rotates and
|
|
382
|
+
* never tears down) and only presents a token the hub still calls active.
|
|
383
|
+
*
|
|
384
|
+
* Keyed by the `account` row (one hub link per user); `refresh_token` is
|
|
385
|
+
* the ciphertext presented, so a browser re-login that re-seeds the row
|
|
386
|
+
* mid-flight invalidates the marker by construction (the ciphertext no
|
|
387
|
+
* longer matches). Postgres-backed so every replica sees it — the refresh
|
|
388
|
+
* lock is cross-replica, and so must the memory of an unanswered
|
|
389
|
+
* presentation be.
|
|
390
|
+
*/
|
|
391
|
+
export const hubGrantPresentations = pgTable('hub_grant_presentations', {
|
|
392
|
+
accountId: text('account_id')
|
|
393
|
+
.primaryKey()
|
|
394
|
+
.references(() => account.id, { onDelete: 'cascade' }),
|
|
395
|
+
/** The refresh-token CIPHERTEXT that was presented (matches account.refresh_token). */
|
|
396
|
+
refreshToken: text('refresh_token').notNull(),
|
|
397
|
+
presentedAt: timestamp('presented_at', { withTimezone: true }).notNull().defaultNow()
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* TOOL-LOCAL first-run onboarding state (SL-6; written on the cloud
|
|
402
|
+
* edition's SSO login path, read by /me). An APP domain table, not a
|
|
403
|
+
* Better Auth column — no drift-guard involvement. The welcome-banner
|
|
404
|
+
* decision reads ONLY this table and is RETRY-SAFE BY CONSTRUCTION:
|
|
405
|
+
* `firstRunPending := NOT EXISTS (row WHERE dismissed_at IS NOT NULL)` —
|
|
406
|
+
* the absence of a DISMISSAL means the welcome is still owed, so a
|
|
407
|
+
* transiently-failed first-login insert self-heals on the next login and
|
|
408
|
+
* only an explicit dismissal ever hides the banner. The hub's advisory
|
|
409
|
+
* `tool_first_login` id_token claim is deliberately ignored for behavior
|
|
410
|
+
* (the hub's user_tool_usage table stays the ECOSYSTEM analytics truth;
|
|
411
|
+
* this table owns the banner). The deploy-time backfill migration inserts
|
|
412
|
+
* DISMISSED rows for every pre-existing user — nobody gets a retroactive
|
|
413
|
+
* welcome.
|
|
414
|
+
*/
|
|
415
|
+
export const userOnboarding = pgTable('user_onboarding', {
|
|
416
|
+
userId: text('user_id')
|
|
417
|
+
.primaryKey()
|
|
418
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
419
|
+
/** First observed login (informational; the banner keys on dismissed_at). */
|
|
420
|
+
firstLoginAt: timestamp('first_login_at', { withTimezone: true }).notNull().defaultNow(),
|
|
421
|
+
/** NULL = welcome still owed; set once by POST /me/onboarding/dismiss (or the backfill). */
|
|
422
|
+
dismissedAt: timestamp('dismissed_at', { withTimezone: true })
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Audit log: who did what to which resource, when, with which credential.
|
|
427
|
+
* Append-only; bigint identity keeps inserts cheap on the hot path.
|
|
428
|
+
*/
|
|
429
|
+
export const auditLog = pgTable(
|
|
430
|
+
'audit_log',
|
|
431
|
+
{
|
|
432
|
+
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
|
|
433
|
+
// NULL = INSTANCE-attributed: the event belongs to no workspace (ADR 014)
|
|
434
|
+
// — e.g. the orphan-user purge, whose subjects have no workspace home.
|
|
435
|
+
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }),
|
|
436
|
+
actorUserId: text('actor_user_id').references(() => user.id, { onDelete: 'set null' }),
|
|
437
|
+
actorVia: text('actor_via', { enum: ['session', 'api_key', 'oauth', 'system'] }).notNull(),
|
|
438
|
+
apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }),
|
|
439
|
+
action: text('action').notNull(),
|
|
440
|
+
resourceType: text('resource_type').notNull(),
|
|
441
|
+
resourceId: text('resource_id'),
|
|
442
|
+
requestId: text('request_id'),
|
|
443
|
+
ip: text('ip'),
|
|
444
|
+
metadata: jsonb('metadata'),
|
|
445
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
|
446
|
+
},
|
|
447
|
+
(t) => [
|
|
448
|
+
index('audit_log_workspace_created_idx').on(t.workspaceId, t.createdAt),
|
|
449
|
+
// Serves the admin API's keyset pagination (workspace_id, id DESC).
|
|
450
|
+
index('audit_log_workspace_id_idx').on(t.workspaceId, t.id),
|
|
451
|
+
// Serves the nightly retention purge (created_at range scan across all
|
|
452
|
+
// workspaces); the workspace-leading index above cannot.
|
|
453
|
+
index('audit_log_created_idx').on(t.createdAt)
|
|
454
|
+
]
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* File metadata over the content-addressed StorageDriver. Rows are
|
|
459
|
+
* workspace-scoped; blobs are stored once per (workspace, sha256).
|
|
460
|
+
*
|
|
461
|
+
* `created_by` is nullable with `set null` on user delete (migration 0008):
|
|
462
|
+
* files are WORKSPACE data, not the uploader's personal data — deleting the
|
|
463
|
+
* account anonymizes the uploader (like audit_log.actor_user_id) while the
|
|
464
|
+
* file stays with the workspace (ADR 006).
|
|
465
|
+
*/
|
|
466
|
+
export const files = pgTable(
|
|
467
|
+
'files',
|
|
468
|
+
{
|
|
469
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
470
|
+
workspaceId: uuid('workspace_id')
|
|
471
|
+
.notNull()
|
|
472
|
+
.references(() => workspaces.id, { onDelete: 'cascade' }),
|
|
473
|
+
sha256: text('sha256').notNull(),
|
|
474
|
+
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
|
|
475
|
+
contentType: text('content_type').notNull(),
|
|
476
|
+
originalName: text('original_name').notNull(),
|
|
477
|
+
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
|
478
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
479
|
+
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
|
480
|
+
},
|
|
481
|
+
(t) => [
|
|
482
|
+
uniqueIndex('files_workspace_sha_uniq').on(t.workspaceId, t.sha256),
|
|
483
|
+
// Serves the admin API's keyset pagination (workspace_id, created_at DESC, id DESC).
|
|
484
|
+
index('files_workspace_created_id_idx').on(t.workspaceId, t.createdAt, t.id)
|
|
485
|
+
]
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Proof of possession: every user who has PUSHED these exact bytes into the
|
|
490
|
+
* workspace, not just the first one (SL-B1 / ADR 013 on the blob surface).
|
|
491
|
+
*
|
|
492
|
+
* `files` is content-addressed and unique per (workspace, sha256), so a
|
|
493
|
+
* second uploader of identical bytes deduplicates onto the FIRST uploader's
|
|
494
|
+
* row and `files.created_by` keeps naming that first uploader. Once blob
|
|
495
|
+
* reads are per-resource authorized, that single column is no longer a truthful
|
|
496
|
+
* answer to "may this principal see these bytes": a member who uploaded a
|
|
497
|
+
* shared logo that another member had uploaded first would be locked out of
|
|
498
|
+
* a blob they demonstrably hold — and their version commit, which must only
|
|
499
|
+
* bind shas they may read, would be refused. This table records each
|
|
500
|
+
* distinct uploader so possession survives deduplication.
|
|
501
|
+
*
|
|
502
|
+
* `on delete cascade` on both sides: attribution is personal data (it dies
|
|
503
|
+
* with the account, like the anonymized `files.created_by`) and carries no
|
|
504
|
+
* value once the file row is gone.
|
|
505
|
+
*/
|
|
506
|
+
export const fileUploaders = pgTable(
|
|
507
|
+
'file_uploaders',
|
|
508
|
+
{
|
|
509
|
+
fileId: uuid('file_id')
|
|
510
|
+
.notNull()
|
|
511
|
+
.references(() => files.id, { onDelete: 'cascade' }),
|
|
512
|
+
userId: text('user_id')
|
|
513
|
+
.notNull()
|
|
514
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
515
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
|
516
|
+
},
|
|
517
|
+
(t) => [
|
|
518
|
+
primaryKey({ columns: [t.fileId, t.userId] }),
|
|
519
|
+
// Serves the per-principal blob visibility probe (file_id + user_id is
|
|
520
|
+
// the PK, so the lookup is the PK index; this one serves the reverse
|
|
521
|
+
// "everything this user uploaded" direction the cascade delete walks).
|
|
522
|
+
index('file_uploaders_user_idx').on(t.userId)
|
|
523
|
+
]
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* A PROJECT is a subgroup of a workspace: a name, a description, members with
|
|
528
|
+
* a role, and whatever the tool links to it through a table of ITS OWN. The
|
|
529
|
+
* chassis owns the concept; it knows none of the tool's resources, and no
|
|
530
|
+
* tool ever needs a column here: `metadata` is the opaque, owner-defined seam
|
|
531
|
+
* (the same shape and size rules as the tool's own resource metadata).
|
|
532
|
+
*
|
|
533
|
+
* A project is ARCHIVED, never deleted: there is no delete route and no
|
|
534
|
+
* `deleted_at`. `archived_at` takes it out of the default list and makes it
|
|
535
|
+
* read-only; nothing cascades onto what is linked to it.
|
|
536
|
+
*/
|
|
537
|
+
export const projects = pgTable(
|
|
538
|
+
'projects',
|
|
539
|
+
{
|
|
540
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
541
|
+
workspaceId: uuid('workspace_id')
|
|
542
|
+
.notNull()
|
|
543
|
+
.references(() => workspaces.id, { onDelete: 'cascade' }),
|
|
544
|
+
name: text('name').notNull(),
|
|
545
|
+
description: text('description'),
|
|
546
|
+
metadata: jsonb('metadata').$type<Record<string, unknown>>().notNull().default({}),
|
|
547
|
+
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
|
548
|
+
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
|
549
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
550
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
|
551
|
+
},
|
|
552
|
+
(t) => [
|
|
553
|
+
// Serves the keyset list (workspace_id, created_at DESC, id DESC).
|
|
554
|
+
index('projects_workspace_created_id_idx').on(t.workspaceId, t.createdAt, t.id)
|
|
555
|
+
]
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* The three project roles. A SECURITY vocabulary read by SQL predicates
|
|
560
|
+
* (`projects/access.ts`), so a stray value must be impossible: text + CHECK,
|
|
561
|
+
* never a free string.
|
|
562
|
+
*/
|
|
563
|
+
export const projectRoles = ['manager', 'editor', 'viewer'] as const;
|
|
564
|
+
export type ProjectRole = (typeof projectRoles)[number];
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* One row per (project, person). **The grant rides on the workspace
|
|
568
|
+
* membership and dies with it**: `member_id` references the
|
|
569
|
+
* `workspace_members` row with ON DELETE CASCADE, so a removed member (a
|
|
570
|
+
* local delete, or the hub reconcile sweeping a membership) loses every
|
|
571
|
+
* project grant, and a later re-invite starts with none. The person and the
|
|
572
|
+
* workspace are READ THROUGH that row, never copied beside it: a copy could
|
|
573
|
+
* drift, and every predicate joins the membership anyway for its two live
|
|
574
|
+
* facts (`is_active`, `origin <> 'guest'`).
|
|
575
|
+
*/
|
|
576
|
+
export const projectMembers = pgTable(
|
|
577
|
+
'project_members',
|
|
578
|
+
{
|
|
579
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
580
|
+
projectId: uuid('project_id')
|
|
581
|
+
.notNull()
|
|
582
|
+
.references(() => projects.id, { onDelete: 'cascade' }),
|
|
583
|
+
memberId: uuid('member_id')
|
|
584
|
+
.notNull()
|
|
585
|
+
.references(() => workspaceMembers.id, { onDelete: 'cascade' }),
|
|
586
|
+
role: text('role', { enum: projectRoles }).notNull(),
|
|
587
|
+
addedBy: text('added_by').references(() => user.id, { onDelete: 'set null' }),
|
|
588
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
|
589
|
+
},
|
|
590
|
+
(t) => [
|
|
591
|
+
uniqueIndex('project_members_project_member_uniq').on(t.projectId, t.memberId),
|
|
592
|
+
// The reverse direction: "every project of this membership" (the list's
|
|
593
|
+
// WHERE for a plain member, and the cascade's walk).
|
|
594
|
+
index('project_members_member_idx').on(t.memberId),
|
|
595
|
+
// Serves the members list's keyset pagination.
|
|
596
|
+
index('project_members_project_created_id_idx').on(t.projectId, t.createdAt, t.id),
|
|
597
|
+
check('project_members_role_check', sql`${t.role} IN ('manager', 'editor', 'viewer')`)
|
|
598
|
+
]
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Teams: named groups of the workspace's people, a concept of the chassis on
|
|
603
|
+
* BOTH editions with the same tables, routes and screens; only the SOURCE
|
|
604
|
+
* differs (Romain's rule of 27 September 2026, PRDCT-2813).
|
|
605
|
+
*
|
|
606
|
+
* - On the self-hosted edition, and in a cloud-LOCAL workspace, a team is
|
|
607
|
+
* the tool's own: `hub_team_id` is NULL, owners and admins create, rename
|
|
608
|
+
* and delete it and seat members through `api/teams.ts`. Its slug is
|
|
609
|
+
* unique within the workspace (the partial index below).
|
|
610
|
+
* - In a hub-origin workspace (cloud) a team is the hub's, projected:
|
|
611
|
+
* `hub_team_id` carries the hub's team id (unique: a team belongs to one
|
|
612
|
+
* hub organization, so it projects into one workspace), and its slug and
|
|
613
|
+
* name follow the hub on drift. The reconciler (`identity/hub-reconcile.ts`)
|
|
614
|
+
* writes the organization's whole team list as the signed-in person reads
|
|
615
|
+
* it, and the person's own seats from `GET /orgs`; the routes refuse every
|
|
616
|
+
* local write there (`hub_managed`, the members' pattern). Its slug is
|
|
617
|
+
* unique at the hub, never re-checked here: two projections read at
|
|
618
|
+
* different moments may hold the same slug for an instant.
|
|
619
|
+
*
|
|
620
|
+
* A team is never a principal: it holds no credential and no role of its
|
|
621
|
+
* own. What it does hold is a place on a project (`project_teams`, ADR 026),
|
|
622
|
+
* which its members inherit through the one access rule.
|
|
623
|
+
*/
|
|
624
|
+
export const workspaceTeams = pgTable(
|
|
625
|
+
'workspace_teams',
|
|
626
|
+
{
|
|
627
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
628
|
+
workspaceId: uuid('workspace_id')
|
|
629
|
+
.notNull()
|
|
630
|
+
.references(() => workspaces.id, { onDelete: 'cascade' }),
|
|
631
|
+
/** The hub's team id for a projected team; NULL for the tool's own. */
|
|
632
|
+
hubTeamId: text('hub_team_id'),
|
|
633
|
+
slug: text('slug').notNull(),
|
|
634
|
+
name: text('name').notNull(),
|
|
635
|
+
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
|
636
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
637
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
|
638
|
+
},
|
|
639
|
+
(t) => [
|
|
640
|
+
uniqueIndex('workspace_teams_hub_team_uniq').on(t.hubTeamId),
|
|
641
|
+
// A local team's slug is unique in its workspace; a projected one is the hub's.
|
|
642
|
+
uniqueIndex('workspace_teams_workspace_slug_local_uniq')
|
|
643
|
+
.on(t.workspaceId, t.slug)
|
|
644
|
+
.where(sql`${t.hubTeamId} IS NULL`),
|
|
645
|
+
index('workspace_teams_workspace_idx').on(t.workspaceId)
|
|
646
|
+
]
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* One row per (team, person): a seat. **The seat rides on the workspace
|
|
651
|
+
* membership**, like a project grant: `member_id` references the
|
|
652
|
+
* `workspace_members` row with ON DELETE CASCADE, so a team can only hold
|
|
653
|
+
* its own workspace's people and a membership that goes takes every seat
|
|
654
|
+
* with it. A deactivated membership keeps its seats (a pause; the access
|
|
655
|
+
* predicates refuse it anyway); only an active one can be seated.
|
|
656
|
+
*
|
|
657
|
+
* On a local team the routes write the seats (an owner or admin, `added_by`
|
|
658
|
+
* set). On a projected team the reconciler writes the PERSON's own seats as
|
|
659
|
+
* the hub asserted them at their last pass, and deletes the seats of a team
|
|
660
|
+
* the hub no longer lists for them; the hub sweep deletes the seats with the
|
|
661
|
+
* membership it deactivates (a removal is a removal). A projected roster is
|
|
662
|
+
* therefore the seats of the people who have signed in here, never the
|
|
663
|
+
* hub's whole one.
|
|
664
|
+
*/
|
|
665
|
+
export const workspaceTeamMembers = pgTable(
|
|
666
|
+
'workspace_team_members',
|
|
667
|
+
{
|
|
668
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
669
|
+
teamId: uuid('team_id')
|
|
670
|
+
.notNull()
|
|
671
|
+
.references(() => workspaceTeams.id, { onDelete: 'cascade' }),
|
|
672
|
+
memberId: uuid('member_id')
|
|
673
|
+
.notNull()
|
|
674
|
+
.references(() => workspaceMembers.id, { onDelete: 'cascade' }),
|
|
675
|
+
addedBy: text('added_by').references(() => user.id, { onDelete: 'set null' }),
|
|
676
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
|
677
|
+
},
|
|
678
|
+
(t) => [
|
|
679
|
+
uniqueIndex('workspace_team_members_team_member_uniq').on(t.teamId, t.memberId),
|
|
680
|
+
// "Every seat of this membership" (the sweep's and the pass's walk).
|
|
681
|
+
index('workspace_team_members_member_idx').on(t.memberId)
|
|
682
|
+
]
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* One row per (project, team): a team's place on a project, with a role, like
|
|
687
|
+
* a person's (PRDCT-2794, ADR 026). Every active non-guest member of the
|
|
688
|
+
* team holds the role through it, resolved live by the one access rule
|
|
689
|
+
* (`projects/access.ts`), so a person seated after the grant reads the
|
|
690
|
+
* project at once and a person unseated loses it at once; the effective role
|
|
691
|
+
* is the highest of the person's direct grant and their teams' grants. Both
|
|
692
|
+
* ends cascade: a deleted team, or an archived-then-deleted project, takes
|
|
693
|
+
* the row. The same on both editions: on cloud the team is the hub's
|
|
694
|
+
* projection, on self-hosted the tool's own.
|
|
695
|
+
*/
|
|
696
|
+
export const projectTeams = pgTable(
|
|
697
|
+
'project_teams',
|
|
698
|
+
{
|
|
699
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
700
|
+
projectId: uuid('project_id')
|
|
701
|
+
.notNull()
|
|
702
|
+
.references(() => projects.id, { onDelete: 'cascade' }),
|
|
703
|
+
teamId: uuid('team_id')
|
|
704
|
+
.notNull()
|
|
705
|
+
.references(() => workspaceTeams.id, { onDelete: 'cascade' }),
|
|
706
|
+
role: text('role', { enum: projectRoles }).notNull(),
|
|
707
|
+
addedBy: text('added_by').references(() => user.id, { onDelete: 'set null' }),
|
|
708
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
|
709
|
+
},
|
|
710
|
+
(t) => [
|
|
711
|
+
uniqueIndex('project_teams_project_team_uniq').on(t.projectId, t.teamId),
|
|
712
|
+
// The reverse direction: "every project of this team" (the predicate's walk).
|
|
713
|
+
index('project_teams_team_idx').on(t.teamId),
|
|
714
|
+
// Serves the members list's keyset pagination (people and teams in one order).
|
|
715
|
+
index('project_teams_project_created_id_idx').on(t.projectId, t.createdAt, t.id),
|
|
716
|
+
check('project_teams_role_check', sql`${t.role} IN ('manager', 'editor', 'viewer')`)
|
|
717
|
+
]
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
export type InstanceSettings = typeof instanceSettings.$inferSelect;
|
|
721
|
+
export type Workspace = typeof workspaces.$inferSelect;
|
|
722
|
+
export type WorkspaceMember = typeof workspaceMembers.$inferSelect;
|
|
723
|
+
export type Invitation = typeof invitations.$inferSelect;
|
|
724
|
+
export type DemoPassRow = typeof demoPasses.$inferSelect;
|
|
725
|
+
export type ApiKey = typeof apiKeys.$inferSelect;
|
|
726
|
+
export type IdempotencyKey = typeof idempotencyKeys.$inferSelect;
|
|
727
|
+
export type AuditEntry = typeof auditLog.$inferSelect;
|
|
728
|
+
export type FileRow = typeof files.$inferSelect;
|
|
729
|
+
export type FileUploaderRow = typeof fileUploaders.$inferSelect;
|
|
730
|
+
export type ProjectRow = typeof projects.$inferSelect;
|
|
731
|
+
export type ProjectMemberRow = typeof projectMembers.$inferSelect;
|
|
732
|
+
export type WorkspaceTeamRow = typeof workspaceTeams.$inferSelect;
|
|
733
|
+
export type WorkspaceTeamMemberRow = typeof workspaceTeamMembers.$inferSelect;
|
|
734
|
+
export type ProjectTeamRow = typeof projectTeams.$inferSelect;
|