@flow-industries/id 0.23.4 → 0.24.1

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.
@@ -0,0 +1,782 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { bigint, bigserial, boolean, date, doublePrecision, index, integer, jsonb, pgTable, primaryKey, real, text, timestamp, uniqueIndex, } from "drizzle-orm/pg-core";
3
+ export const user = pgTable("user", {
4
+ id: text("id").primaryKey(),
5
+ username: text("username").notNull().unique(),
6
+ name: text("name"),
7
+ email: text("email").unique(),
8
+ emailVerified: boolean("email_verified").notNull().default(false),
9
+ // A guest user has no passkey, no email and no wallet address yet; the row
10
+ // exists only so consumer apps can attach data to a stable user.id before
11
+ // the visitor signs up. Signing up upgrades this row in place (clears the
12
+ // flag, adds the passkey/email/wallet) rather than creating a second row.
13
+ isGuest: boolean("is_guest").notNull().default(false),
14
+ // Global permission tier (user | moderator | admin). Plain text like
15
+ // room_member.role so future tiers are new values, never a migration;
16
+ // narrowGlobalRole() collapses unknown text to 'user' so a stray value
17
+ // can never escalate. Written only by the grant script / admin API.
18
+ role: text("role").notNull().default("user"),
19
+ image: text("image"),
20
+ // The one free-text field a user controls. Canonicalized and length-capped
21
+ // on write (src/bio.ts); null is "unset", which is also what clearing it
22
+ // stores — an empty string is never written, so readers test for null only.
23
+ bio: text("bio"),
24
+ walletAddress: text("wallet_address").unique(),
25
+ subscribedToNews: boolean("subscribed_to_news").notNull().default(false),
26
+ // Per-user session epoch — the per-account sibling of the per-audience
27
+ // lever in origins.currentEpoch. Refresh lineages record the value in
28
+ // force at mint (refresh_token.user_epoch); "sign out everywhere" bumps
29
+ // this and every lineage stamped with an older value fails its next
30
+ // verify, including rotations already in flight when the bump ran.
31
+ sessionEpoch: integer("session_epoch").notNull().default(1),
32
+ createdAt: timestamp("created_at").notNull().defaultNow(),
33
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
34
+ }, (table) => [
35
+ // Functional index for the case-insensitive lookups in the OTP/register hot
36
+ // paths (`where lower(email) = $1`). The unique constraint on `email` is
37
+ // case-sensitive and Postgres can't use it for a lower() predicate, so
38
+ // without this every send-otp and register sequentially scans `user`.
39
+ index("user_lower_email_idx").on(sql `lower(${table.email})`),
40
+ // Partial index over the guest subset, keyed on the timestamp a future TTL
41
+ // reaper (DELETE ... WHERE is_guest = true AND created_at < cutoff) ranges
42
+ // on — so that sweep is an index range scan over guests, not a heap scan of
43
+ // every guest row.
44
+ index("user_guest_created_at_idx")
45
+ .on(table.createdAt)
46
+ .where(sql `${table.isGuest} = true`),
47
+ ]);
48
+ export const account = pgTable("account", {
49
+ id: text("id").primaryKey(),
50
+ userId: text("user_id")
51
+ .notNull()
52
+ .references(() => user.id, { onDelete: "cascade" }),
53
+ accountId: text("account_id").notNull(),
54
+ providerId: text("provider_id").notNull(),
55
+ displayName: text("display_name"),
56
+ accessToken: text("access_token"),
57
+ refreshToken: text("refresh_token"),
58
+ accessTokenExpiresAt: timestamp("access_token_expires_at"),
59
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
60
+ scope: text("scope"),
61
+ idToken: text("id_token"),
62
+ password: text("password"),
63
+ createdAt: timestamp("created_at").notNull().defaultNow(),
64
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
65
+ }, (table) => [
66
+ index("account_userId_idx").on(table.userId),
67
+ uniqueIndex("account_provider_subject_idx").on(table.providerId, table.accountId),
68
+ ]);
69
+ export const session = pgTable("session", {
70
+ id: text("id").primaryKey(),
71
+ userId: text("user_id")
72
+ .notNull()
73
+ .references(() => user.id, { onDelete: "cascade" }),
74
+ token: text("token").notNull().unique(),
75
+ expiresAt: timestamp("expires_at").notNull(),
76
+ ipAddress: text("ip_address"),
77
+ userAgent: text("user_agent"),
78
+ audience: text("audience"),
79
+ createdAt: timestamp("created_at").notNull().defaultNow(),
80
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
81
+ }, (table) => [index("session_userId_idx").on(table.userId)]);
82
+ // First-party session persistence: consumer apps store the opaque token in their
83
+ // OWN origin and mint access JWTs via POST /api/session/refresh — no third-party
84
+ // id.flow.industries cookie, so it survives iOS ITP. Rotated on every use.
85
+ export const refreshToken = pgTable("refresh_token", {
86
+ id: text("id").primaryKey(),
87
+ userId: text("user_id")
88
+ .notNull()
89
+ .references(() => user.id, { onDelete: "cascade" }),
90
+ // sha256 hex of the opaque 256-bit token; the plaintext is returned to the
91
+ // client once at mint and never stored, so a DB read can't recover a usable
92
+ // token. Unique so refresh is a single indexed equality on the hash.
93
+ tokenHash: text("token_hash").notNull().unique(),
94
+ // Mints access JWTs only for this audience — an exfiltrated flow.game token
95
+ // cannot be replayed to mint for another app.
96
+ audience: text("audience").notNull(),
97
+ // Snapshot at mint; /refresh re-reads user.isGuest live so a guest->full
98
+ // upgrade heals even on an outstanding guest-minted token.
99
+ guest: boolean("guest").notNull(),
100
+ // Session epoch in force at mint (origins.currentEpoch). /refresh rejects a
101
+ // token whose epoch is behind its audience's current epoch, so bumping that
102
+ // epoch retires every past session for the audience on its next refresh.
103
+ epoch: integer("epoch").notNull().default(1),
104
+ // Per-user epoch in force at mint (user.session_epoch), same mechanism one
105
+ // scope down: carried through rotation unchanged, so a lineage minted
106
+ // before a "sign out everywhere" bump dies at its next verify even if a
107
+ // rotation raced the bump. Exempted (current-session) lineages are
108
+ // re-stamped to the new value instead.
109
+ userEpoch: integer("user_epoch").notNull().default(1),
110
+ // Rotation family: each /refresh rotates within the lineage. Replaying a
111
+ // token it rotated past (`replaced_by` set) while the lineage still holds
112
+ // a live row revokes all of it — two holders imply theft. Revoked any
113
+ // other way (sign-out, epoch bump, expiry) there is no `replaced_by`, and
114
+ // the token is merely dead, not replayed.
115
+ lineageId: text("lineage_id").notNull(),
116
+ // The better-auth session whose sign-in minted this lineage (carried
117
+ // through rotation and additional-audience mints). Groups an app's
118
+ // lineages under the device/browser entry in the account session list and
119
+ // is the revocation handle for "sign out that device". Plain text, no FK:
120
+ // the session row is deleted on sign-out/revocation while the lineage
121
+ // history must survive for reuse detection. Null on lineages minted
122
+ // before this column existed.
123
+ sessionId: text("session_id"),
124
+ replacedBy: text("replaced_by"),
125
+ // AES-256-GCM ciphertext of the successor's plaintext (key derived from
126
+ // BETTER_AUTH_SECRET), kept only for REFRESH_GRACE_MS after rotation so
127
+ // concurrent presenters (multi-tab SSR, multi-replica) converge on one
128
+ // successor instead of tripping reuse detection into a lineage revocation.
129
+ // Encrypted so a DB read still can't recover a usable token.
130
+ graceToken: text("grace_token"),
131
+ expiresAt: timestamp("expires_at").notNull(),
132
+ createdAt: timestamp("created_at").notNull().defaultNow(),
133
+ revokedAt: timestamp("revoked_at"),
134
+ }, (table) => [
135
+ index("refresh_token_user_id_idx").on(table.userId),
136
+ index("refresh_token_lineage_id_idx").on(table.lineageId),
137
+ index("refresh_token_expires_at_idx").on(table.expiresAt),
138
+ index("refresh_token_session_id_idx").on(table.sessionId),
139
+ ]);
140
+ export const passkey = pgTable("passkey", {
141
+ id: text("id").primaryKey(),
142
+ name: text("name"),
143
+ publicKey: text("public_key").notNull(),
144
+ userId: text("user_id")
145
+ .notNull()
146
+ .references(() => user.id, { onDelete: "cascade" }),
147
+ credentialId: text("credential_id").notNull().unique(),
148
+ counter: integer("counter").notNull().default(0),
149
+ deviceType: text("device_type"),
150
+ backedUp: boolean("backed_up").default(false),
151
+ transports: text("transports"),
152
+ aaguid: text("aaguid"),
153
+ createdAt: timestamp("created_at").notNull().defaultNow(),
154
+ }, (table) => [index("passkey_userId_idx").on(table.userId)]);
155
+ export const verification = pgTable("verification", {
156
+ id: text("id").primaryKey(),
157
+ identifier: text("identifier").notNull(),
158
+ value: text("value").notNull(),
159
+ expiresAt: timestamp("expires_at").notNull(),
160
+ createdAt: timestamp("created_at").notNull().defaultNow(),
161
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
162
+ }, (table) => [
163
+ index("verification_identifier_idx").on(table.identifier),
164
+ // better-auth bulk-deletes expired rows (WHERE expires_at < now) on every
165
+ // findVerificationValue call; the OTP flow multiplies that traffic.
166
+ index("verification_expires_at_idx").on(table.expiresAt),
167
+ ]);
168
+ // One row per (user, surface) holding that surface's settings in a single jsonb
169
+ // blob. `surface` namespaces independent setting sets (e.g. "game"), so a new
170
+ // surface (site, notifications, …) is new rows here plus a registry entry —
171
+ // never a new table or column. Keyed on user.id (the JWT sub) so rows survive
172
+ // the in-place guest->full upgrade, and cascade on user delete (incl. guest TTL
173
+ // reaping). Storing a surface's settings in one blob means adding/removing/
174
+ // renaming a setting never alters this table's columns — no migration per
175
+ // setting and a user's other saved values are never touched. `version` stamps
176
+ // the registry shape the blob was last normalized to (per surface), so a future
177
+ // rename/retype can upgrade old blobs on read.
178
+ export const userSettings = pgTable("user_settings", {
179
+ userId: text("user_id")
180
+ .notNull()
181
+ .references(() => user.id, { onDelete: "cascade" }),
182
+ surface: text("surface").notNull(),
183
+ data: jsonb("data").$type().notNull().default({}),
184
+ // The handler always writes the surface's current registry version; this
185
+ // constant is only a fallback for a row inserted without one.
186
+ version: integer("version").notNull().default(1),
187
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
188
+ }, (table) => [primaryKey({ columns: [table.userId, table.surface] })]);
189
+ // Per-user XP state shared by every Flow surface. `total_xp` is a materialized
190
+ // sum maintained transactionally with each xp_ledger insert so profile reads
191
+ // never scan the ledger. `flow_score` is the gentle daily streak (0..10: +1 per
192
+ // visited day, -1 per missed day, decay applied lazily on the next visit — no
193
+ // cron). Keyed on user.id (the JWT sub) so rows survive the in-place
194
+ // guest->full upgrade and cascade with the guest TTL reaper.
195
+ export const userXp = pgTable("user_xp", {
196
+ userId: text("user_id")
197
+ .primaryKey()
198
+ .references(() => user.id, { onDelete: "cascade" }),
199
+ totalXp: bigint("total_xp", { mode: "number" }).notNull().default(0),
200
+ flowScore: integer("flow_score").notNull().default(0),
201
+ lastVisitDate: date("last_visit_date", { mode: "string" }),
202
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
203
+ });
204
+ // Append-only record of every XP grant. A new XP source never alters this
205
+ // table — it is a new `source` id in src/xp/registry.ts. `flow_score` is the
206
+ // score at grant time, so the applied multiplier stays derivable (auditable)
207
+ // after the economy is retuned. `idempotency_key` collapses retried reports
208
+ // (game-server HTTP retries, overlapping visit hooks) into exactly one grant.
209
+ export const xpLedger = pgTable("xp_ledger", {
210
+ id: text("id").primaryKey(),
211
+ userId: text("user_id")
212
+ .notNull()
213
+ .references(() => user.id, { onDelete: "cascade" }),
214
+ source: text("source").notNull(),
215
+ baseAmount: integer("base_amount").notNull(),
216
+ flowScore: integer("flow_score").notNull().default(0),
217
+ amount: integer("amount").notNull(),
218
+ metadata: jsonb("metadata").$type(),
219
+ idempotencyKey: text("idempotency_key").unique(),
220
+ createdAt: timestamp("created_at").notNull().defaultNow(),
221
+ }, (table) => [
222
+ // Serves both a recent-grants view and the per-day source cap sum
223
+ // (WHERE user_id = ? AND created_at >= start-of-day).
224
+ index("xp_ledger_user_created_at_idx").on(table.userId, table.createdAt),
225
+ ]);
226
+ // One row per discrete player action (meditation, future actions) — the durable
227
+ // authority for the in-game action timer and disconnect crediting. `id` is the
228
+ // game-generated session id (also the XP idempotency key). Duration derives
229
+ // from the lifecycle timestamps and action_event transitions; `xp_granted` is
230
+ // filled once on finish and remains zero when the janitor abandons an orphan.
231
+ export const actionSession = pgTable("action_session", {
232
+ id: text("id").primaryKey(),
233
+ userId: text("user_id")
234
+ .notNull()
235
+ .references(() => user.id, { onDelete: "cascade" }),
236
+ source: text("source").notNull(),
237
+ state: text("state").notNull().default("active"),
238
+ // The study subject a session is attributed to; null for sources without
239
+ // one and for a Study started where nothing could be chosen. The subject
240
+ // never enters the XP math — it is what per-subject time sums on. SET NULL
241
+ // so a hard-deleted subject leaves its history readable as plain Study.
242
+ subjectId: text("subject_id").references(() => studySubject.id, {
243
+ onDelete: "set null",
244
+ }),
245
+ // Final durations, written once when the session ends (finish, abandon,
246
+ // or the sweeper's finish-at-cap) from the event log, so per-day and
247
+ // per-subject stats sum them without replaying action_event. Zero while
248
+ // active, and on rows that ended before they were materialized.
249
+ accruedSeconds: doublePrecision("accrued_seconds").notNull().default(0),
250
+ focusedSeconds: doublePrecision("focused_seconds").notNull().default(0),
251
+ xpGranted: integer("xp_granted"),
252
+ metadata: jsonb("metadata").$type(),
253
+ startedAt: timestamp("started_at").notNull().defaultNow(),
254
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
255
+ finishedAt: timestamp("finished_at"),
256
+ }, (table) => [
257
+ index("action_session_user_started_at_idx").on(table.userId, table.startedAt),
258
+ // Compatibility index for pre-AUTH-70 pods; the current janitor uses
259
+ // action_session_active_started_at_idx.
260
+ index("action_session_open_updated_at_idx")
261
+ .on(table.updatedAt)
262
+ .where(sql `${table.state} = 'active'`),
263
+ index("action_session_active_user_started_at_idx")
264
+ .on(table.userId, table.startedAt)
265
+ .where(sql `${table.state} = 'active'`),
266
+ index("action_session_active_started_at_idx")
267
+ .on(table.startedAt)
268
+ .where(sql `${table.state} = 'active'`),
269
+ // Per-subject range sums, the keyset-paged per-subject session list and
270
+ // the delete-time reference check all filter on subject_id first; partial
271
+ // because most sessions (Meditate) carry none.
272
+ index("action_session_subject_started_at_idx")
273
+ .on(table.subjectId, table.startedAt)
274
+ .where(sql `${table.subjectId} is not null`),
275
+ ]);
276
+ // Append-only lifecycle transitions for an action session — the history layer
277
+ // for duration and focus stats. `kind` is
278
+ // start|pause|resume|focus_lost|focus_gained|finish|abandon.
279
+ export const actionEvent = pgTable("action_event", {
280
+ id: text("id").primaryKey(),
281
+ sessionId: text("session_id")
282
+ .notNull()
283
+ .references(() => actionSession.id, { onDelete: "cascade" }),
284
+ kind: text("kind").notNull(),
285
+ // Compatibility field for lifecycle rows written by pre-AUTH-70 pods.
286
+ accruedSeconds: integer("accrued_seconds").notNull().default(0),
287
+ at: timestamp("at").notNull().defaultNow(),
288
+ }, (table) => [
289
+ index("action_event_session_at_idx").on(table.sessionId, table.at),
290
+ ]);
291
+ // Coarse "record everything" rollup: accumulated seconds per character
292
+ // state-machine state per UTC day (grounded, air, wall_run_left, …, action).
293
+ // The game accumulates in-memory and flushes deltas periodically, so 60Hz state
294
+ // churn never becomes one row per transition — one row per (user, day, state).
295
+ export const characterStateTime = pgTable("character_state_time", {
296
+ userId: text("user_id")
297
+ .notNull()
298
+ .references(() => user.id, { onDelete: "cascade" }),
299
+ day: date("day", { mode: "string" }).notNull(),
300
+ state: text("state").notNull(),
301
+ seconds: integer("seconds").notNull().default(0),
302
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
303
+ }, (table) => [primaryKey({ columns: [table.userId, table.day, table.state] })]);
304
+ // The persistent room registry: rooms are the shared social primitive across
305
+ // every Flow surface (game world routing, future text chat, future voice,
306
+ // cross-product presence). `id` is the immutable internal key every FK and
307
+ // service report uses; `slug` is the mutable user-facing URL handle
308
+ // (flow.game/<slug>), lowercase-unique so casing can't mint squattable
309
+ // duplicates. Ownership references a full (non-guest) Flow account; deleting
310
+ // the owner's account intentionally orphans the room (ownerId null) instead of
311
+ // cascading — rooms outlive their creators and a moderator can claim them.
312
+ export const room = pgTable("room", {
313
+ id: text("id").primaryKey(),
314
+ slug: text("slug").notNull().unique(),
315
+ displayName: text("display_name").notNull(),
316
+ description: text("description"),
317
+ ownerId: text("owner_id").references(() => user.id, {
318
+ onDelete: "set null",
319
+ }),
320
+ visibility: text("visibility").notNull().default("public"),
321
+ // The seeded default room ("global"): cannot be deleted, renamed, or made
322
+ // non-public, so the fleet always has a valid landing destination.
323
+ isSystem: boolean("is_system").notNull().default(false),
324
+ createdAt: timestamp("created_at").notNull().defaultNow(),
325
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
326
+ }, (table) => [
327
+ index("room_owner_id_idx").on(table.ownerId),
328
+ // Sized for the public browse listing's keyset scan; unlisted/private rooms
329
+ // never appear there so the partial keeps it dense.
330
+ index("room_public_created_at_idx")
331
+ .on(table.createdAt)
332
+ .where(sql `${table.visibility} = 'public'`),
333
+ ]);
334
+ // Durable membership (my-rooms, roles). Presence (who is connected right now)
335
+ // is deliberately a separate table: public rooms are drop-in joinable without
336
+ // membership. The owner is NOT duplicated here — `room.ownerId` is the single
337
+ // authority, so ownership transfer is one UPDATE; `role` holds moderator|member
338
+ // and future roles are new text values, never a migration.
339
+ export const roomMember = pgTable("room_member", {
340
+ roomId: text("room_id")
341
+ .notNull()
342
+ .references(() => room.id, { onDelete: "cascade" }),
343
+ userId: text("user_id")
344
+ .notNull()
345
+ .references(() => user.id, { onDelete: "cascade" }),
346
+ role: text("role").notNull().default("member"),
347
+ joinedAt: timestamp("joined_at").notNull().defaultNow(),
348
+ }, (table) => [
349
+ primaryKey({ columns: [table.roomId, table.userId] }),
350
+ index("room_member_user_id_idx").on(table.userId),
351
+ ]);
352
+ // Per-room moderation state, kind = ban|mute. Active = expires_at is null (permanent)
353
+ // or in the future; lifting a restriction deletes the row, re-issuing upserts
354
+ // it, so there is at most one live row per (room, user, kind). Enforced at
355
+ // join/connect (ban) and by the room's realtime surfaces (mute).
356
+ export const roomRestriction = pgTable("room_restriction", {
357
+ id: text("id").primaryKey(),
358
+ roomId: text("room_id")
359
+ .notNull()
360
+ .references(() => room.id, { onDelete: "cascade" }),
361
+ userId: text("user_id")
362
+ .notNull()
363
+ .references(() => user.id, { onDelete: "cascade" }),
364
+ kind: text("kind").notNull(),
365
+ reason: text("reason"),
366
+ issuedBy: text("issued_by").references(() => user.id, {
367
+ onDelete: "set null",
368
+ }),
369
+ expiresAt: timestamp("expires_at"),
370
+ createdAt: timestamp("created_at").notNull().defaultNow(),
371
+ }, (table) => [
372
+ uniqueIndex("room_restriction_room_user_kind_idx").on(table.roomId, table.userId, table.kind),
373
+ ]);
374
+ // Per-room settings, one jsonb blob per surface — the room-scoped sibling of
375
+ // user_settings. "game" is the first surface; text chat, voice, and the
376
+ // Discord bridge attach later as new surfaces (rows + a registry entry in
377
+ // src/rooms/settings-registry.ts), never as columns. Delivered to trusted
378
+ // services via the session-verify room context — a surface blob may hold
379
+ // secret-ish values (webhook URLs), so it is never exposed in public listings.
380
+ export const roomSettings = pgTable("room_settings", {
381
+ roomId: text("room_id")
382
+ .notNull()
383
+ .references(() => room.id, { onDelete: "cascade" }),
384
+ surface: text("surface").notNull(),
385
+ data: jsonb("data").$type().notNull().default({}),
386
+ version: integer("version").notNull().default(1),
387
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
388
+ }, (table) => [primaryKey({ columns: [table.roomId, table.surface] })]);
389
+ // Live occupancy, one row per (room, user, channel) with channel = game|voice|
390
+ // text. Written only by trusted services (declarative snapshot reports under a
391
+ // service token); a row is alive while its updated_at is within the staleness
392
+ // cutoff — reads always filter on it and a reaper deletes what the cutoff has
393
+ // already hidden, so a crashed reporter never shows ghosts. The userId index
394
+ // serves the cross-product "where is this user right now" read.
395
+ export const roomPresence = pgTable("room_presence", {
396
+ roomId: text("room_id")
397
+ .notNull()
398
+ .references(() => room.id, { onDelete: "cascade" }),
399
+ userId: text("user_id")
400
+ .notNull()
401
+ .references(() => user.id, { onDelete: "cascade" }),
402
+ channel: text("channel").notNull(),
403
+ joinedAt: timestamp("joined_at").notNull().defaultNow(),
404
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
405
+ }, (table) => [
406
+ primaryKey({ columns: [table.roomId, table.userId, table.channel] }),
407
+ index("room_presence_updated_at_idx").on(table.updatedAt),
408
+ index("room_presence_user_id_idx").on(table.userId),
409
+ ]);
410
+ // Where a player was standing when they last left a room, so rejoining resumes
411
+ // instead of restarting at the spawn ring. Written only by trusted services
412
+ // (the game server's position relay under the same token presence uses) and
413
+ // read back on the session-verify room context for the room being joined, so
414
+ // it never becomes a cross-room "where is this user" oracle. Accumulate-only:
415
+ // unlike room_presence this must survive the player's absence, which is the
416
+ // whole point, so it is deliberately not a field on the declarative presence
417
+ // snapshot. real (float4) carries ~7 significant digits — sub-millimetre a
418
+ // kilometre from origin, far past what a 2m terrain step needs.
419
+ //
420
+ // No TTL and no reaper. The coordinate is validated against the live world on
421
+ // every restore, so a year-old row is exactly as usable as yesterday's, and
422
+ // ageing rows out would only send the longest-absent player to spawn. Deletion
423
+ // is entirely by cascade: dropping a room, an account or a never-upgraded guest
424
+ // takes its positions with it.
425
+ export const roomPlayerState = pgTable("room_player_state", {
426
+ roomId: text("room_id")
427
+ .notNull()
428
+ .references(() => room.id, { onDelete: "cascade" }),
429
+ userId: text("user_id")
430
+ .notNull()
431
+ .references(() => user.id, { onDelete: "cascade" }),
432
+ x: real("x").notNull(),
433
+ y: real("y").notNull(),
434
+ z: real("z").notNull(),
435
+ yaw: real("yaw").notNull(),
436
+ worldId: text("world_id").notNull(),
437
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
438
+ }, (table) => [primaryKey({ columns: [table.roomId, table.userId] })]);
439
+ // A scheduled happening in a room — the series, not the dated instance. One-off
440
+ // events carry a null `recurrence` and materialize exactly one occurrence;
441
+ // recurring ones store the rule and let the expander produce instances.
442
+ // `timezone` is the IANA zone the series was authored in and is load-bearing,
443
+ // not decoration: recurrence expands in local time so a 20:00 weekly race stays
444
+ // at 20:00 across a DST shift, and only the resulting instants are stored UTC.
445
+ // Deleting the author orphans the event (createdBy null) the way a room outlives
446
+ // its owner; deleting the room takes its events with it.
447
+ export const roomEvent = pgTable("room_event", {
448
+ id: text("id").primaryKey(),
449
+ roomId: text("room_id")
450
+ .notNull()
451
+ .references(() => room.id, { onDelete: "cascade" }),
452
+ createdBy: text("created_by").references(() => user.id, {
453
+ onDelete: "set null",
454
+ }),
455
+ title: text("title").notNull(),
456
+ description: text("description"),
457
+ coverUrl: text("cover_url"),
458
+ startsAt: timestamp("starts_at").notNull(),
459
+ endsAt: timestamp("ends_at"),
460
+ timezone: text("timezone").notNull().default("UTC"),
461
+ recurrence: jsonb("recurrence").$type(),
462
+ // The one Discord scheduled event standing for the WHOLE series, set only
463
+ // when the rule is one Discord can repeat on its own (see
464
+ // `discordRecurrence`). While it is set the instances below are not
465
+ // mirrored individually — that is the difference between one repeating
466
+ // Discord event and ninety identical one-offs.
467
+ discordEventId: text("discord_event_id"),
468
+ discordSyncedAt: timestamp("discord_synced_at"),
469
+ // Minutes before the start at which to remind subscribers; 0 is "at the
470
+ // start". Per event so a long-planned tournament can warn a day ahead while
471
+ // a nightly race does not nag.
472
+ reminderOffsets: jsonb("reminder_offsets").$type(),
473
+ status: text("status").notNull().default("scheduled"),
474
+ createdAt: timestamp("created_at").notNull().defaultNow(),
475
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
476
+ },
477
+ // Only the room FK's cascade reads this table by room — every room-scoped
478
+ // event read, including the live-event cap, goes through the occurrence table
479
+ // where the dates live.
480
+ (table) => [index("room_event_room_id_idx").on(table.roomId)]);
481
+ // One concrete dated instance of a series. Everything a player touches attaches
482
+ // here rather than to the series — interest, reminders, attendance history — so
483
+ // a weekly race's third week is addressable on its own. `roomId` is duplicated
484
+ // from the parent so the by-room time-window read and the scheduler's due scan
485
+ // never need the join. `overridden` marks an instance moved or cancelled on its
486
+ // own, so a later series-wide edit leaves it alone.
487
+ //
488
+ // `recurrenceId` is the instant the recurrence rule produced, and it is the
489
+ // row's identity: (eventId, recurrenceId) is what makes rolling
490
+ // re-materialization an idempotent upsert. It is deliberately NOT `startsAt` —
491
+ // a per-instance override moves `startsAt`, and keying on a column the product
492
+ // lets users change would leave the materializer unable to tell "this instant
493
+ // already has a row, relocated" from "this instant has no row yet", so it would
494
+ // resurrect a duplicate at the original time. RFC 5545 splits these for the
495
+ // same reason (RECURRENCE-ID vs DTSTART). A one-off's `recurrenceId` is simply
496
+ // its own start.
497
+ export const roomEventOccurrence = pgTable("room_event_occurrence", {
498
+ id: text("id").primaryKey(),
499
+ eventId: text("event_id")
500
+ .notNull()
501
+ .references(() => roomEvent.id, { onDelete: "cascade" }),
502
+ roomId: text("room_id")
503
+ .notNull()
504
+ .references(() => room.id, { onDelete: "cascade" }),
505
+ recurrenceId: timestamp("recurrence_id").notNull(),
506
+ startsAt: timestamp("starts_at").notNull(),
507
+ endsAt: timestamp("ends_at"),
508
+ status: text("status").notNull().default("scheduled"),
509
+ overridden: boolean("overridden").notNull().default(false),
510
+ // Attendance summary, written once when the instance completes so the
511
+ // history view stays durable after raw lifecycle edges expire.
512
+ attendeeCount: integer("attendee_count"),
513
+ peakConcurrent: integer("peak_concurrent"),
514
+ // The mirrored Discord scheduled event, so every later edit updates that
515
+ // event instead of creating a second one. `discordSyncedAt` is compared
516
+ // against `updatedAt` to decide whether the mirror has drifted — the worker
517
+ // reconciles it, so authoring never waits on a Discord round trip.
518
+ discordEventId: text("discord_event_id"),
519
+ discordSyncedAt: timestamp("discord_synced_at"),
520
+ // When players were present, derived from lifecycle edges merged with the
521
+ // sampled fallback at completion: the FIRST and LAST player seen, not the
522
+ // run window. An occurrence nobody attended leaves both null, which is
523
+ // exactly why they cannot stand in for the columns above.
524
+ actualStartedAt: timestamp("actual_started_at"),
525
+ actualEndedAt: timestamp("actual_ended_at"),
526
+ // The authoritative run window, stamped by the scheduler at the instant each
527
+ // transition happens rather than derived from anything. This is what records
528
+ // that an occurrence ran at all — the attendance pair above cannot, because a
529
+ // run with zero attendees produces two nulls, and with attendees it reports
530
+ // when people were in the room, which is a different question.
531
+ //
532
+ // A window the worker slept through goes scheduled -> completed in one step,
533
+ // so `completedAt` is stamped and `activatedAt` stays null: nobody observed
534
+ // it start, and inventing a value would be worse than admitting that.
535
+ activatedAt: timestamp("activated_at"),
536
+ completedAt: timestamp("completed_at"),
537
+ cancelledAt: timestamp("cancelled_at"),
538
+ createdAt: timestamp("created_at").notNull().defaultNow(),
539
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
540
+ }, (table) => [
541
+ uniqueIndex("room_event_occurrence_event_recurrence_idx").on(table.eventId, table.recurrenceId),
542
+ index("room_event_occurrence_event_starts_at_idx").on(table.eventId, table.startsAt),
543
+ index("room_event_occurrence_room_starts_at_idx").on(table.roomId, table.startsAt),
544
+ // Serves the fleet-wide upcoming listing and the scheduler's "what is due
545
+ // now" scan, both of which are time-ordered across every room.
546
+ index("room_event_occurrence_starts_at_idx").on(table.startsAt),
547
+ ]);
548
+ // "Interested" on one dated instance — the social proof on the card and the
549
+ // subscriber list a reminder fans out to, which is why it hangs off the
550
+ // occurrence and not the series. Cascades with the occurrence, so pruning a
551
+ // stale instance or deleting a series takes its interest with it. The userId
552
+ // index serves the future "what am I interested in" read.
553
+ export const roomEventRsvp = pgTable("room_event_rsvp", {
554
+ occurrenceId: text("occurrence_id")
555
+ .notNull()
556
+ .references(() => roomEventOccurrence.id, { onDelete: "cascade" }),
557
+ userId: text("user_id")
558
+ .notNull()
559
+ .references(() => user.id, { onDelete: "cascade" }),
560
+ createdAt: timestamp("created_at").notNull().defaultNow(),
561
+ }, (table) => [
562
+ primaryKey({ columns: [table.occurrenceId, table.userId] }),
563
+ index("room_event_rsvp_user_id_idx").on(table.userId),
564
+ ]);
565
+ // Following a whole series, the opt-in above per-instance interest. Kept as its
566
+ // own row rather than a fan-out of RSVPs so that following a weekly race covers
567
+ // instances that do not exist yet: the horizon materializes week 14 long after
568
+ // the player opted in, and it arrives already subscribed.
569
+ export const roomEventFollow = pgTable("room_event_follow", {
570
+ eventId: text("event_id")
571
+ .notNull()
572
+ .references(() => roomEvent.id, { onDelete: "cascade" }),
573
+ userId: text("user_id")
574
+ .notNull()
575
+ .references(() => user.id, { onDelete: "cascade" }),
576
+ createdAt: timestamp("created_at").notNull().defaultNow(),
577
+ }, (table) => [
578
+ primaryKey({ columns: [table.eventId, table.userId] }),
579
+ index("room_event_follow_user_id_idx").on(table.userId),
580
+ ]);
581
+ // The exactly-once gate for reminders. A tick claims a row BEFORE delivering,
582
+ // so a crash mid-fan-out re-runs safely: the claim either succeeds (nobody has
583
+ // sent this yet) or conflicts (somebody has). The unique key is what makes that
584
+ // true — never the clock, which can fire the same window twice across a restart
585
+ // or a rolling deploy. `kind` names the reminder (`reminder_60m`, `start`, …)
586
+ // and `channel` the delivery route, so adding Discord in AUTH-163 is a new
587
+ // channel value and not a second ledger.
588
+ export const roomEventDispatch = pgTable("room_event_dispatch", {
589
+ id: text("id").primaryKey(),
590
+ occurrenceId: text("occurrence_id")
591
+ .notNull()
592
+ .references(() => roomEventOccurrence.id, { onDelete: "cascade" }),
593
+ kind: text("kind").notNull(),
594
+ channel: text("channel").notNull(),
595
+ outcome: text("outcome").notNull().default("claimed"),
596
+ recipients: integer("recipients").notNull().default(0),
597
+ createdAt: timestamp("created_at").notNull().defaultNow(),
598
+ }, (table) => [
599
+ uniqueIndex("room_event_dispatch_occurrence_kind_channel_idx").on(table.occurrenceId, table.kind, table.channel),
600
+ ]);
601
+ // The fallback record of who was in the room while an instance ran.
602
+ // `room_presence` is a live snapshot with no history, so the scheduler still
603
+ // samples it for servers or batches that produce no lifecycle edges. These rows
604
+ // merge with exact game_event intervals when the occurrence completes.
605
+ export const roomEventAttendance = pgTable("room_event_attendance", {
606
+ occurrenceId: text("occurrence_id")
607
+ .notNull()
608
+ .references(() => roomEventOccurrence.id, { onDelete: "cascade" }),
609
+ userId: text("user_id")
610
+ .notNull()
611
+ .references(() => user.id, { onDelete: "cascade" }),
612
+ firstSeenAt: timestamp("first_seen_at").notNull().defaultNow(),
613
+ lastSeenAt: timestamp("last_seen_at").notNull().defaultNow(),
614
+ }, (table) => [primaryKey({ columns: [table.occurrenceId, table.userId] })]);
615
+ // Append-only game-server lifecycle history: server boot/shutdown, room
616
+ // open/close, and the join/leave edges of every play session. Nothing here is a
617
+ // `room_event` — that table is a *scheduled* happening, this one is what the
618
+ // dedicated server actually did.
619
+ //
620
+ // It exists because the ops copy cannot answer product questions: the
621
+ // OpenObserve `game_events` stream this mirrors has a retention window, is
622
+ // absent in dev, and is ingest-only, exactly as `security_event` exists beside
623
+ // `auth_events`. And `room_presence` cannot either — a declarative snapshot
624
+ // deletes a player's row the moment they leave, taking their `joined_at` with
625
+ // it, which is why the attendance fallback still samples every 30s.
626
+ //
627
+ // `session_id` is minted by the game server per admitted player and carried on
628
+ // BOTH edges, so time-present is a self-join on it rather than a heuristic
629
+ // pairing over ephemeral peer ids that restart and get reused. `id` is the
630
+ // reporter's idempotency key, which is what makes a retried batch safe.
631
+ // Server-scoped events (boot/shutdown, room open/close) carry no user and no
632
+ // session, so both FKs are nullable; every player-scoped row cascades with the
633
+ // account and with the room, because this is per-player history and must not
634
+ // outlive either.
635
+ export const gameEvent = pgTable("game_event", {
636
+ id: text("id").primaryKey(),
637
+ event: text("event").notNull(),
638
+ occurredAt: timestamp("occurred_at").notNull(),
639
+ recordedAt: timestamp("recorded_at").notNull().defaultNow(),
640
+ serverId: text("server_id").notNull(),
641
+ sessionId: text("session_id"),
642
+ roomId: text("room_id").references(() => room.id, { onDelete: "cascade" }),
643
+ roomSlug: text("room_slug"),
644
+ userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
645
+ reason: text("reason"),
646
+ detail: jsonb("detail").$type(),
647
+ }, (table) => [
648
+ // The session self-join that pairs a join with its leave.
649
+ index("game_event_session_id_idx").on(table.sessionId),
650
+ index("game_event_room_occurred_at_idx").on(table.roomId, table.occurredAt),
651
+ index("game_event_user_occurred_at_idx").on(table.userId, table.occurredAt),
652
+ // Sized for the retention sweep's range delete.
653
+ index("game_event_occurred_at_idx").on(table.occurredAt),
654
+ index("game_event_recorded_id_idx").on(table.recordedAt, table.id),
655
+ ]);
656
+ // Serializes product-feed boundaries independently of application clocks.
657
+ // Writers advance this row inside the same transaction as their event batch;
658
+ // tail bootstrap requests lock it before recording their immutable boundary.
659
+ export const gameEventFeedClock = pgTable("game_event_feed_clock", {
660
+ id: text("id").primaryKey(),
661
+ recordedAt: timestamp("recorded_at").notNull(),
662
+ });
663
+ // `acknowledgedAt` is proof that the consumer durably committed the cursor.
664
+ // Unacknowledged boundaries never expire: future-dated retained events may
665
+ // still depend on them even after the ordinary retention interval passes.
666
+ export const gameEventFeedTail = pgTable("game_event_feed_tail", {
667
+ id: text("id").primaryKey(),
668
+ recordedAt: timestamp("recorded_at").notNull(),
669
+ eventId: text("event_id").notNull(),
670
+ createdAt: timestamp("created_at").notNull().defaultNow(),
671
+ acknowledgedAt: timestamp("acknowledged_at"),
672
+ });
673
+ // User-visible security activity — the durable feed behind the dialog's
674
+ // Security view. Written from emit() for the small allow-set in
675
+ // src/security-events.ts; the OpenObserve auth_events stream can't back a
676
+ // product surface (ops-facing, ingest-only, absent in dev), so the feed keeps
677
+ // its own owner-scoped table. `ip` is stored for future "was this you?" flows
678
+ // but never exposed by the read endpoint (country + coarse device only). Rows
679
+ // cascade with the user and are pruned past the retention window on read —
680
+ // the index serves both that range delete and the recency listing.
681
+ export const securityEvent = pgTable("security_event", {
682
+ id: text("id").primaryKey(),
683
+ userId: text("user_id")
684
+ .notNull()
685
+ .references(() => user.id, { onDelete: "cascade" }),
686
+ event: text("event").notNull(),
687
+ audience: text("audience"),
688
+ ip: text("ip"),
689
+ country: text("country"),
690
+ userAgent: text("user_agent"),
691
+ createdAt: timestamp("created_at").notNull().defaultNow(),
692
+ }, (table) => [
693
+ index("security_event_user_created_at_idx").on(table.userId, table.createdAt),
694
+ ]);
695
+ // The erasure ledger: one row per deleted account, appended in the same
696
+ // transaction that deletes the user row (src/account-erasure.ts). `user_id` is
697
+ // the sub every downstream service keyed its data on and is deliberately NOT a
698
+ // foreign key — the row exists precisely because the user row no longer does.
699
+ // Served ascending by `seq` to trusted consumers (talk) that poll
700
+ // GET /api/account/erasures with their own cursor and purge what they hold;
701
+ // Unacknowledged rows survive consumer outages; acknowledged rows retain a
702
+ // minimum replay window before reaping.
703
+ export const accountErasure = pgTable("account_erasure", {
704
+ seq: bigserial("seq", { mode: "number" }).primaryKey(),
705
+ userId: text("user_id").notNull(),
706
+ deletedRoomIds: jsonb("deleted_room_ids")
707
+ .$type()
708
+ .notNull()
709
+ .default([]),
710
+ avatarUrl: text("avatar_url"),
711
+ talkAcknowledgedAt: timestamp("talk_acknowledged_at", {
712
+ withTimezone: true,
713
+ }),
714
+ erasedAt: timestamp("erased_at", { withTimezone: true })
715
+ .notNull()
716
+ .defaultNow(),
717
+ }, (table) => [index("account_erasure_erased_at_idx").on(table.erasedAt)]);
718
+ export const jwks = pgTable("jwks", {
719
+ id: text("id").primaryKey(),
720
+ publicKey: text("public_key").notNull(),
721
+ privateKey: text("private_key").notNull(),
722
+ createdAt: timestamp("created_at").notNull().defaultNow(),
723
+ expiresAt: timestamp("expires_at"),
724
+ });
725
+ // Backing store for better-auth's rate limiter (rateLimit.storage: "database").
726
+ // The export key, and the `key`/`count`/`lastRequest` JS field names, must match
727
+ // better-auth's `rateLimit` model exactly — the drizzle adapter resolves the
728
+ // model by export key and columns by field name. SQL names follow the repo's
729
+ // snake_case convention. lastRequest holds an epoch-millis timestamp, so it
730
+ // needs bigint, not int4. Persisting to Postgres (not memory) keeps the limiter
731
+ // correct across pod restarts/deploys and any future multi-replica scale-out.
732
+ export const rateLimit = pgTable("rate_limit", {
733
+ id: text("id").primaryKey(),
734
+ key: text("key").notNull(),
735
+ count: integer("count").notNull().default(0),
736
+ lastRequest: bigint("last_request", { mode: "number" }).notNull().default(0),
737
+ });
738
+ // What a user studies, in their own words: "Math for my test", "Chinese",
739
+ // "Beekeeping". `field` anchors a row to the study catalog (src/study/catalog.ts)
740
+ // — a `category/field` slug, a bare category slug for a custom subject that
741
+ // still rolls up under one, or null when the catalog has no home for it. The
742
+ // catalog is a code registry, not a table, so this is a plain text slug with no
743
+ // FK; renaming a label never touches these rows. Keyed on user.id (the JWT sub)
744
+ // so rows survive the in-place guest->full upgrade and cascade with the
745
+ // account, including the guest TTL reaper.
746
+ //
747
+ // A subject is never renamed away from its history: archiving (archived_at set)
748
+ // hides it from the picker while any session that referenced it keeps
749
+ // resolving. The live-name uniqueness is therefore partial — an archived
750
+ // "Chinese" must not block a fresh one — and case-insensitive, so "chinese" and
751
+ // "Chinese" are one subject. The last-used index serves the picker's
752
+ // recent-first listing and the "most recently studied" fallback a game start
753
+ // carries no subject for.
754
+ export const studySubject = pgTable("study_subject", {
755
+ id: text("id").primaryKey(),
756
+ userId: text("user_id")
757
+ .notNull()
758
+ .references(() => user.id, { onDelete: "cascade" }),
759
+ field: text("field"),
760
+ name: text("name").notNull(),
761
+ archivedAt: timestamp("archived_at"),
762
+ createdAt: timestamp("created_at").notNull().defaultNow(),
763
+ lastUsedAt: timestamp("last_used_at").notNull().defaultNow(),
764
+ }, (table) => [
765
+ uniqueIndex("study_subject_user_live_name_idx")
766
+ .on(table.userId, sql `lower(${table.name})`)
767
+ .where(sql `${table.archivedAt} is null`),
768
+ index("study_subject_user_last_used_at_idx").on(table.userId, table.lastUsedAt.desc()),
769
+ ]);
770
+ export const oauthSession = pgTable("oauth_session", {
771
+ providerId: text("provider_id").notNull(),
772
+ accountId: text("account_id").notNull(),
773
+ data: text("data").notNull(),
774
+ formatVersion: integer("format_version").notNull().default(2),
775
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
776
+ }, (table) => [primaryKey({ columns: [table.providerId, table.accountId] })]);
777
+ export const oauthState = pgTable("oauth_state", {
778
+ providerId: text("provider_id").notNull(),
779
+ id: text("id").notNull(),
780
+ data: text("data").notNull(),
781
+ expiresAt: timestamp("expires_at").notNull(),
782
+ }, (table) => [primaryKey({ columns: [table.providerId, table.id] })]);