@flow-industries/id 0.24.0 → 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.
- package/README.md +76 -0
- package/dist/sdk/browser-contract.d.ts +1 -0
- package/dist/sdk/browser-contract.js +1 -0
- package/dist/sdk/browser-session-route.js +3 -3
- package/dist/sdk/client/atproto.d.ts +5 -0
- package/dist/sdk/client/atproto.js +49 -0
- package/dist/sdk/client/create-flow.js +2 -1
- package/dist/sdk/db/handle.d.ts +11 -0
- package/dist/sdk/db/handle.js +0 -0
- package/dist/sdk/db/schema.d.ts +4626 -0
- package/dist/sdk/db/schema.js +782 -0
- package/dist/sdk/oauth/encryption.d.ts +4 -0
- package/dist/sdk/oauth/encryption.js +29 -0
- package/dist/sdk/oauth/stores.d.ts +16 -0
- package/dist/sdk/oauth/stores.js +159 -0
- package/dist/sdk/settings/appearance.d.ts +3 -0
- package/dist/sdk/settings/appearance.js +167 -0
- package/dist/sdk/settings/cosmetics.d.ts +149 -0
- package/dist/sdk/settings/cosmetics.js +408 -0
- package/dist/sdk/settings/hair-items.json +312 -0
- package/dist/sdk/settings/registry.d.ts +11 -0
- package/dist/sdk/settings/registry.js +136 -0
- package/dist/sdk/settings/surfaces.d.ts +4 -0
- package/dist/sdk/settings/surfaces.js +37 -0
- package/dist/sdk/types/account-data.d.ts +11 -0
- package/dist/sdk/types/atproto-storage.d.ts +9 -0
- package/dist/sdk/types/atproto-storage.js +0 -0
- package/dist/sdk/types/atproto.d.ts +25 -0
- package/dist/sdk/types/atproto.js +0 -0
- package/dist/sdk/types/auth.d.ts +1 -0
- package/dist/sdk/types/index.d.ts +1 -0
- package/package.json +6 -2
|
@@ -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] })]);
|