创建时间: 2026-05-16最后更新: 2026-05-16

1. 概述

数据库操作本质上也是一种异步行为,因此,我们可以像前端封装接口请求那样,把数据库操作单独封装一下,然后在 service 业务代码中使用,从而简化代码结构。

这里我们以登录相关的功能为例,来展开这个封装方式的学习。

2. Drizzle 基础 API

先看这行导入,它决定了后面查询条件和 SQL 表达式的写法:

repository.ts
1
import { and, eq, isNull, sql } from 'drizzle-orm'

其实这里 4 个东西都不复杂。

eq(a, b) 表示相等条件。你可以把它理解成 SQL 里的 a = b

repository.ts
1
eq(applications.code, 'admin')

and(...) 表示把多个条件拼成 AND

repository.ts
1
and(
2
eq(applications.code, 'admin'),
3
eq(applications.status, 'active'),
4
eq(applicationAuthMethods.provider, 'password'),
5
)

isNull(x) 表示 SQL 里的 x IS NULL

repository.ts
1
isNull(refreshTokens.usedAtMs)

sql 则是写原生 SQL 片段时用的。

repository.ts
1
sql`COALESCE(${authSessions.revokedAtMs}, ${params.revokedAtMs})`

它适合那种 Drizzle 链式 API 不够直接,或者你本来就想写一小段 SQL 表达式的场景。

除了这 4 个条件工具,后面还反复出现这些查询 API:

  • .select(...):查什么列
  • .from(...):从哪张表开始查
  • .innerJoin(...):和哪张表做内连接
  • .where(...):过滤条件
  • .limit(1):只取一条
  • .get():拿一条结果
  • .insert(...).values(...):插入数据
  • .update(...).set(...):更新数据
  • .returning(...):把更新后的结果带回来
  • .batch([...]):把多条写操作一起执行

你可以先把整套链式写法看成“在 TypeScript 里写 SQL”。只不过不是直接拼字符串,而是把 selectjoinwhere 这些步骤拆成链式方法。

3. isPasswordLoginEnabledForAdmin

repository.ts
01
export async function isPasswordLoginEnabledForAdmin(db: ApiDb): Promise<boolean> {
02
const row = await db
03
.select({ enabled: applicationAuthMethods.enabled })
04
.from(applicationAuthMethods)
05
.innerJoin(applications, eq(applications.id, applicationAuthMethods.applicationId))
06
.where(
07
and(
08
eq(applications.code, 'admin'),
09
eq(applications.status, 'active'),
10
eq(applicationAuthMethods.provider, 'password'),
11
),
12
)
13
.limit(1)
14
.get()
15
16
return row?.enabled === 1
17
}

这个方法的目标很单纯:查 admin 这条线有没有启用密码登录。

顺着链往下读就行。

.select({ enabled: applicationAuthMethods.enabled }) 表示只查 enabled 这一列,并把它取名成 enabled

.from(applicationAuthMethods) 表示从 application_auth_methods 这张表开始。

.innerJoin(applications, eq(applications.id, applicationAuthMethods.applicationId)) 表示把 applications 表连进来,连接条件是 application id 对上。

.where(...) 里又拼了 3 个条件:

  • application code 是 admin
  • application status 是 active
  • provider 是 password

.limit(1).get() 连起来看,意思就是:只取一条,并把结果当成单条记录拿出来。

最后:

repository.ts
1
return row?.enabled === 1

这里把数据库里的 1 / 0 转成真正的布尔值。

4. findLoginUserByNormalizedEmail

repository.ts
01
export async function findLoginUserByNormalizedEmail(
02
db: ApiDb,
03
normalizedEmail: string,
04
): Promise<LoginUserRecord | null> {
05
const row = await db
06
.select({
07
userId: users.id,
08
emailId: userEmails.id,
09
email: userEmails.email,
10
userStatus: users.status,
11
passwordHash: passwordCredentials.passwordHash,
12
passwordAlgo: passwordCredentials.passwordAlgo,
13
})
14
.from(userEmails)
15
.innerJoin(users, eq(users.id, userEmails.userId))
16
.innerJoin(
17
passwordCredentials,
18
and(
19
eq(passwordCredentials.userId, users.id),
20
eq(passwordCredentials.emailId, userEmails.id),
21
),
22
)
23
.where(eq(userEmails.normalizedEmail, normalizedEmail))
24
.limit(1)
25
.get()
26
27
return row
28
? {
29
...row,
30
userStatus: row.userStatus as LoginUserRecord['userStatus'],
31
passwordAlgo: row.passwordAlgo as LoginUserRecord['passwordAlgo'],
32
}
33
: null
34
}

这个方法是登录入口里最典型的读取工具方法。

它不是只查一张表,而是把:

  • user_emails
  • users
  • password_credentials

三张表串起来,一次把登录要用到的核心信息拿全。

这里的关键在 .select({...})

它不是查整行,而是只挑当前登录流程真正要用的字段:

  • user id
  • email id
  • 原始邮箱
  • 用户状态
  • 密码 hash
  • 密码算法

这样返回值就会很聚焦,不会把没用的列也一股脑带出来。

再看 join。

第一段:

repository.ts
1
.innerJoin(users, eq(users.id, userEmails.userId))

这是通过 userEmails.userId 找到对应的 users

第二段:

repository.ts
1
.innerJoin(
2
passwordCredentials,
3
and(
4
eq(passwordCredentials.userId, users.id),
5
eq(passwordCredentials.emailId, userEmails.id),
6
),
7
)

这里的连接条件比上一段更严。它同时要求:

  • credential 属于这个 user
  • credential 对应这条 email

最后 .where(eq(userEmails.normalizedEmail, normalizedEmail)) 表示按标准化邮箱去查。

整个方法返回 LoginUserRecord | null,所以查不到时会明确返回 null

5. getAdminRolesForUser

repository.ts
01
export async function getAdminRolesForUser(
02
db: ApiDb,
03
userId: string,
04
): Promise<string[]> {
05
const rows = await db
06
.select({ code: roles.code })
07
.from(userRoleBindings)
08
.innerJoin(roles, eq(roles.id, userRoleBindings.roleId))
09
.innerJoin(applications, eq(applications.id, roles.applicationId))
10
.where(
11
and(
12
eq(userRoleBindings.userId, userId),
13
eq(userRoleBindings.status, 'active'),
14
eq(applications.code, 'admin'),
15
),
16
)
17
18
return rows.map((row) => row.code)
19
}

这个方法查的是 admin 角色。

它从 user_role_bindings 起步,再一路连到 rolesapplications,最后把条件收窄成:

  • 这个用户的绑定
  • 绑定状态是 active
  • application 是 admin

注意它的返回值不是整行对象,而是:

repository.ts
1
return rows.map((row) => row.code)

也就是直接返回角色 code 数组。

这说明这个工具方法已经帮上层做了一层结果整理,调用方不用再自己去 map

6. getAdminApplicationId

repository.ts
01
export async function getAdminApplicationId(db: ApiDb): Promise<string> {
02
const row = await db
03
.select({ id: applications.id })
04
.from(applications)
05
.where(eq(applications.code, 'admin'))
06
.limit(1)
07
.get()
08
09
if (!row) {
10
throw new Error('Admin application is missing')
11
}
12
13
return row.id
14
}

这个方法很适合拿来理解 .limit(1).get()

它的意图就是:按 code 查 admin 这条 application,只拿一条。

如果查不到,直接抛错。

所以这类方法很像“配置读取器”:它预期这条数据就该存在,不存在就是异常状态。

7. createAdminSession

repository.ts
01
export async function createAdminSession(params: {
02
db: ApiDb
03
userId: string
04
applicationId: string
05
userAgent: string | null
06
ip: string | null
07
nowMs: number
08
expiresAtMs: number
09
roles: string[]
10
}): Promise<SessionContext> {
11
const sessionId = uuidv7()
12
13
await params.db.batch([
14
params.db.insert(authSessions).values({
15
id: sessionId,
16
userId: params.userId,
17
applicationId: params.applicationId,
18
sessionType: 'admin',
19
deviceName: null,
20
userAgent: params.userAgent,
21
ip: params.ip,
22
lastSeenAtMs: params.nowMs,
23
createdAtMs: params.nowMs,
24
expiresAtMs: params.expiresAtMs,
25
revokedAtMs: null,
26
revokeReason: null,
27
}),
28
params.db
29
.update(users)
30
.set({
31
lastLoginAtMs: params.nowMs,
32
updatedAtMs: params.nowMs,
33
})
34
.where(eq(users.id, params.userId)),
35
])
36
37
return {
38
sessionId,
39
userId: params.userId,
40
app: 'admin',
41
roles: params.roles,
42
expiresAtMs: params.expiresAtMs,
43
}
44
}

这个方法开始进入写操作。

第一眼先看两件事:

  • const sessionId = uuidv7()
  • await params.db.batch([...])

先生成 session id,再把两条写操作一起执行。

batch([...]) 里的第一条是插入 auth_sessions

repository.ts
1
params.db.insert(authSessions).values({ ... })

这就是最标准的 Drizzle 插入写法:

  • .insert(table) 指定插入哪张表
  • .values({...}) 指定写入哪些字段

第二条是更新 users

repository.ts
1
params.db
2
.update(users)
3
.set({
4
lastLoginAtMs: params.nowMs,
5
updatedAtMs: params.nowMs,
6
})
7
.where(eq(users.id, params.userId))

这就是最标准的更新写法:

  • .update(table) 选表
  • .set({...}) 设新值
  • .where(...) 限定更新哪一行

最后方法返回的不是数据库原始结果,而是业务层更想要的 SessionContext

所以它已经不只是“写库”,还顺手把业务层后面要继续传递的 session 信息整理好了。

8. insertRefreshToken

repository.ts
01
export async function insertRefreshToken(params: {
02
db: ApiDb
03
tokenId: string
04
sessionId: string
05
jtiHash: string
06
parentTokenId: string | null
07
issuedAtMs: number
08
expiresAtMs: number
09
}): Promise<void> {
10
await params.db.insert(refreshTokens).values({
11
id: params.tokenId,
12
sessionId: params.sessionId,
13
jtiHash: params.jtiHash,
14
parentTokenId: params.parentTokenId,
15
issuedAtMs: params.issuedAtMs,
16
expiresAtMs: params.expiresAtMs,
17
usedAtMs: null,
18
revokedAtMs: null,
19
replacedByTokenId: null,
20
})
21
}

这个方法几乎就是“纯插入”。

它没有 join,没有查询,没有额外整理,主要就是把 refresh token 这条记录写进去。

这种方法很适合单独抽出来,因为调用方只需要说一句“插入 refresh token”,不用反复关心表字段长什么样。

9. findRefreshTokenForSession

repository.ts
01
export async function findRefreshTokenForSession(params: {
02
db: ApiDb
03
jtiHash: string
04
sessionId: string
05
}): Promise<RefreshTokenRecord | null> {
06
const row = await params.db
07
.select({
08
tokenId: refreshTokens.id,
09
sessionId: refreshTokens.sessionId,
10
userId: authSessions.userId,
11
applicationCode: applications.code,
12
expiresAtMs: refreshTokens.expiresAtMs,
13
usedAtMs: refreshTokens.usedAtMs,
14
revokedAtMs: refreshTokens.revokedAtMs,
15
sessionRevokedAtMs: authSessions.revokedAtMs,
16
})
17
.from(refreshTokens)
18
.innerJoin(authSessions, eq(authSessions.id, refreshTokens.sessionId))
19
.innerJoin(applications, eq(applications.id, authSessions.applicationId))
20
.where(
21
and(
22
eq(refreshTokens.jtiHash, params.jtiHash),
23
eq(refreshTokens.sessionId, params.sessionId),
24
),
25
)
26
.limit(1)
27
.get()
28
29
return row ?? null
30
}

这个方法和前面的登录查询很像,也是“多表拼起来,一次拿全刷新流程要用的信息”。

它一口气查出了:

  • 当前 refresh token 自己的状态
  • 它属于哪个 session
  • 这个 session 对应哪个 user
  • application code 是什么
  • session 是否已撤销

这就是抽数据库工具方法的一个现实好处:service 层不需要自己分两三次查,工具方法内部可以一次把后面要判断的字段凑齐。

10. markRefreshTokenUsed

repository.ts
01
export async function markRefreshTokenUsed(params: {
02
db: ApiDb
03
tokenId: string
04
usedAtMs: number
05
}): Promise<boolean> {
06
const updated = await params.db
07
.update(refreshTokens)
08
.set({ usedAtMs: params.usedAtMs })
09
.where(
10
and(
11
eq(refreshTokens.id, params.tokenId),
12
isNull(refreshTokens.usedAtMs),
13
isNull(refreshTokens.revokedAtMs),
14
),
15
)
16
.returning({ id: refreshTokens.id })
17
18
return updated.length === 1
19
}

这个方法最值得看的地方是 .where(...).returning(...)

先看条件:

  • id 必须对上
  • usedAtMs 还必须是 NULL
  • revokedAtMs 也必须是 NULL

也就是说,它不是无脑更新,而是“只有当前 token 还没被用过、也没被撤销时,才允许把它标记成已使用”。

再看:

repository.ts
1
.returning({ id: refreshTokens.id })

这里表示把更新成功的结果带回来。

最后:

repository.ts
1
return updated.length === 1

这就把数据库层的更新结果,转换成了一个很适合上层判断的布尔值。

11. updateRefreshRotation

repository.ts
01
export async function updateRefreshRotation(params: {
02
db: ApiDb
03
oldTokenId: string
04
newTokenId: string
05
sessionId: string
06
lastSeenAtMs: number
07
}): Promise<void> {
08
await params.db.batch([
09
params.db
10
.update(refreshTokens)
11
.set({ replacedByTokenId: params.newTokenId })
12
.where(eq(refreshTokens.id, params.oldTokenId)),
13
params.db
14
.update(authSessions)
15
.set({ lastSeenAtMs: params.lastSeenAtMs })
16
.where(eq(authSessions.id, params.sessionId)),
17
])
18
}

这个方法没有查询,只有两条更新。

第一条把旧 token 指向新 token:

  • replacedByTokenId = newTokenId

第二条更新 session 的 lastSeenAtMs

因为两条更新是一起发生的,所以继续用 batch([...]) 收在一个方法里很自然。

12. revokeSession

repository.ts
01
export async function revokeSession(params: {
02
db: ApiDb
03
sessionId: string
04
revokedAtMs: number
05
reason: string
06
}): Promise<void> {
07
await params.db.batch([
08
params.db
09
.update(authSessions)
10
.set({
11
revokedAtMs: sql`COALESCE(${authSessions.revokedAtMs}, ${params.revokedAtMs})`,
12
revokeReason: sql`COALESCE(${authSessions.revokeReason}, ${params.reason})`,
13
})
14
.where(eq(authSessions.id, params.sessionId)),
15
params.db
16
.update(refreshTokens)
17
.set({
18
revokedAtMs: sql`COALESCE(${refreshTokens.revokedAtMs}, ${params.revokedAtMs})`,
19
})
20
.where(
21
and(
22
eq(refreshTokens.sessionId, params.sessionId),
23
isNull(refreshTokens.revokedAtMs),
24
),
25
),
26
])
27
}

这个方法最适合拿来理解 sql

看这句:

repository.ts
1
sql`COALESCE(${authSessions.revokedAtMs}, ${params.revokedAtMs})`

COALESCE(a, b) 的意思可以先记成:如果 a 不是 NULL,就用 a;否则用 b

所以这里的作用就是:

  • 如果 revokedAtMs 本来已经有值,就保留原值
  • 如果原来是 NULL,才写入这次的撤销时间

revokeReason 也是同样的思路。

这时链式 API 不够直接,sql 就很合适。它允许你在 Drizzle 语句里嵌一段原生 SQL 表达式。

后半段更新 refreshTokens 时,又结合了:

  • eq(...)
  • and(...)
  • isNull(...)

也就是只撤销当前 session 下、且还没被撤销的 refresh token。

13. 抽离数据库方法的意义

读完这些方法之后,再回头看这篇的核心思想,其实已经很清楚了。

这些方法如果散在 route 或 service 里,业务主线会被大量数据库细节打断。你一边想看登录流程,一边却总要切去读 joinwherebatchreturning

单独抽出来之后,层次就顺了:

  • route 负责接请求
  • service 负责业务流程
  • 数据库工具方法负责具体读写

而且抽出来还有两个很实在的好处。

第一,重复查询模式只写一遍。以后谁要按标准化邮箱查登录用户,就直接调 findLoginUserByNormalizedEmail(...)

第二,service 层会更像业务代码。你读 service 时看到的是“检查密码登录是否启用”“查登录用户”“创建 session”“插入 refresh token”,而不是每一步都重新展开 SQL 细节。

所以这组方法的真正价值,不只是“把代码拆文件”,而是把数据库读写从业务主线里剥出来,让上层代码更容易顺着读。

14. 完整代码

repository.ts
001
import { and, eq, isNull, sql } from 'drizzle-orm'
002
import { uuidv7 } from 'uuidv7'
003
import type { ApiDb } from '@/db/client'
004
import {
005
applicationAuthMethods,
006
applications,
007
authSessions,
008
passwordCredentials,
009
refreshTokens,
010
roles,
011
userEmails,
012
userRoleBindings,
013
users,
014
} from '@/db/schema'
015
import type {
016
LoginUserRecord,
017
RefreshTokenRecord,
018
SessionContext,
019
} from './types'
020
021
export async function isPasswordLoginEnabledForAdmin(db: ApiDb): Promise<boolean> {
022
const row = await db
023
.select({ enabled: applicationAuthMethods.enabled })
024
.from(applicationAuthMethods)
025
.innerJoin(applications, eq(applications.id, applicationAuthMethods.applicationId))
026
.where(
027
and(
028
eq(applications.code, 'admin'),
029
eq(applications.status, 'active'),
030
eq(applicationAuthMethods.provider, 'password'),
031
),
032
)
033
.limit(1)
034
.get()
035
036
return row?.enabled === 1
037
}
038
039
export async function findLoginUserByNormalizedEmail(
040
db: ApiDb,
041
normalizedEmail: string,
042
): Promise<LoginUserRecord | null> {
043
const row = await db
044
.select({
045
userId: users.id,
046
emailId: userEmails.id,
047
email: userEmails.email,
048
userStatus: users.status,
049
passwordHash: passwordCredentials.passwordHash,
050
passwordAlgo: passwordCredentials.passwordAlgo,
051
})
052
.from(userEmails)
053
.innerJoin(users, eq(users.id, userEmails.userId))
054
.innerJoin(
055
passwordCredentials,
056
and(
057
eq(passwordCredentials.userId, users.id),
058
eq(passwordCredentials.emailId, userEmails.id),
059
),
060
)
061
.where(eq(userEmails.normalizedEmail, normalizedEmail))
062
.limit(1)
063
.get()
064
065
return row
066
? {
067
...row,
068
userStatus: row.userStatus as LoginUserRecord['userStatus'],
069
passwordAlgo: row.passwordAlgo as LoginUserRecord['passwordAlgo'],
070
}
071
: null
072
}
073
074
export async function getAdminRolesForUser(
075
db: ApiDb,
076
userId: string,
077
): Promise<string[]> {
078
const rows = await db
079
.select({ code: roles.code })
080
.from(userRoleBindings)
081
.innerJoin(roles, eq(roles.id, userRoleBindings.roleId))
082
.innerJoin(applications, eq(applications.id, roles.applicationId))
083
.where(
084
and(
085
eq(userRoleBindings.userId, userId),
086
eq(userRoleBindings.status, 'active'),
087
eq(applications.code, 'admin'),
088
),
089
)
090
091
return rows.map((row) => row.code)
092
}
093
094
export async function getAdminApplicationId(db: ApiDb): Promise<string> {
095
const row = await db
096
.select({ id: applications.id })
097
.from(applications)
098
.where(eq(applications.code, 'admin'))
099
.limit(1)
100
.get()
101
102
if (!row) {
103
throw new Error('Admin application is missing')
104
}
105
106
return row.id
107
}
108
109
export async function createAdminSession(params: {
110
db: ApiDb
111
userId: string
112
applicationId: string
113
userAgent: string | null
114
ip: string | null
115
nowMs: number
116
expiresAtMs: number
117
roles: string[]
118
}): Promise<SessionContext> {
119
const sessionId = uuidv7()
120
121
await params.db.batch([
122
params.db.insert(authSessions).values({
123
id: sessionId,
124
userId: params.userId,
125
applicationId: params.applicationId,
126
sessionType: 'admin',
127
deviceName: null,
128
userAgent: params.userAgent,
129
ip: params.ip,
130
lastSeenAtMs: params.nowMs,
131
createdAtMs: params.nowMs,
132
expiresAtMs: params.expiresAtMs,
133
revokedAtMs: null,
134
revokeReason: null,
135
}),
136
params.db
137
.update(users)
138
.set({
139
lastLoginAtMs: params.nowMs,
140
updatedAtMs: params.nowMs,
141
})
142
.where(eq(users.id, params.userId)),
143
])
144
145
return {
146
sessionId,
147
userId: params.userId,
148
app: 'admin',
149
roles: params.roles,
150
expiresAtMs: params.expiresAtMs,
151
}
152
}
153
154
export async function insertRefreshToken(params: {
155
db: ApiDb
156
tokenId: string
157
sessionId: string
158
jtiHash: string
159
parentTokenId: string | null
160
issuedAtMs: number
161
expiresAtMs: number
162
}): Promise<void> {
163
await params.db.insert(refreshTokens).values({
164
id: params.tokenId,
165
sessionId: params.sessionId,
166
jtiHash: params.jtiHash,
167
parentTokenId: params.parentTokenId,
168
issuedAtMs: params.issuedAtMs,
169
expiresAtMs: params.expiresAtMs,
170
usedAtMs: null,
171
revokedAtMs: null,
172
replacedByTokenId: null,
173
})
174
}
175
176
export async function findRefreshTokenForSession(params: {
177
db: ApiDb
178
jtiHash: string
179
sessionId: string
180
}): Promise<RefreshTokenRecord | null> {
181
const row = await params.db
182
.select({
183
tokenId: refreshTokens.id,
184
sessionId: refreshTokens.sessionId,
185
userId: authSessions.userId,
186
applicationCode: applications.code,
187
expiresAtMs: refreshTokens.expiresAtMs,
188
usedAtMs: refreshTokens.usedAtMs,
189
revokedAtMs: refreshTokens.revokedAtMs,
190
sessionRevokedAtMs: authSessions.revokedAtMs,
191
})
192
.from(refreshTokens)
193
.innerJoin(authSessions, eq(authSessions.id, refreshTokens.sessionId))
194
.innerJoin(applications, eq(applications.id, authSessions.applicationId))
195
.where(
196
and(
197
eq(refreshTokens.jtiHash, params.jtiHash),
198
eq(refreshTokens.sessionId, params.sessionId),
199
),
200
)
201
.limit(1)
202
.get()
203
204
return row ?? null
205
}
206
207
export async function markRefreshTokenUsed(params: {
208
db: ApiDb
209
tokenId: string
210
usedAtMs: number
211
}): Promise<boolean> {
212
const updated = await params.db
213
.update(refreshTokens)
214
.set({ usedAtMs: params.usedAtMs })
215
.where(
216
and(
217
eq(refreshTokens.id, params.tokenId),
218
isNull(refreshTokens.usedAtMs),
219
isNull(refreshTokens.revokedAtMs),
220
),
221
)
222
.returning({ id: refreshTokens.id })
223
224
return updated.length === 1
225
}
226
227
export async function updateRefreshRotation(params: {
228
db: ApiDb
229
oldTokenId: string
230
newTokenId: string
231
sessionId: string
232
lastSeenAtMs: number
233
}): Promise<void> {
234
await params.db.batch([
235
params.db
236
.update(refreshTokens)
237
.set({ replacedByTokenId: params.newTokenId })
238
.where(eq(refreshTokens.id, params.oldTokenId)),
239
params.db
240
.update(authSessions)
241
.set({ lastSeenAtMs: params.lastSeenAtMs })
242
.where(eq(authSessions.id, params.sessionId)),
243
])
244
}
245
246
export async function revokeSession(params: {
247
db: ApiDb
248
sessionId: string
249
revokedAtMs: number
250
reason: string
251
}): Promise<void> {
252
await params.db.batch([
253
params.db
254
.update(authSessions)
255
.set({
256
revokedAtMs: sql`COALESCE(${authSessions.revokedAtMs}, ${params.revokedAtMs})`,
257
revokeReason: sql`COALESCE(${authSessions.revokeReason}, ${params.reason})`,
258
})
259
.where(eq(authSessions.id, params.sessionId)),
260
params.db
261
.update(refreshTokens)
262
.set({
263
revokedAtMs: sql`COALESCE(${refreshTokens.revokedAtMs}, ${params.revokedAtMs})`,
264
})
265
.where(
266
and(
267
eq(refreshTokens.sessionId, params.sessionId),
268
isNull(refreshTokens.revokedAtMs),
269
),
270
),
271
])
272
}