@corgelabs/corge 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/dist/ai/index.d.ts +3 -0
  2. package/dist/ai/index.d.ts.map +1 -0
  3. package/dist/ai/index.js +3 -0
  4. package/dist/ai/index.js.map +1 -0
  5. package/dist/ai/schema.d.ts +83 -0
  6. package/dist/ai/schema.d.ts.map +1 -0
  7. package/dist/ai/schema.js +19 -0
  8. package/dist/ai/schema.js.map +1 -0
  9. package/dist/ai/service.d.ts +14 -0
  10. package/dist/ai/service.d.ts.map +1 -0
  11. package/dist/ai/service.js +266 -0
  12. package/dist/ai/service.js.map +1 -0
  13. package/dist/ai/utils.d.ts +10 -0
  14. package/dist/ai/utils.d.ts.map +1 -0
  15. package/dist/ai/utils.js +27 -0
  16. package/dist/ai/utils.js.map +1 -0
  17. package/dist/attachments/index.d.ts +4 -0
  18. package/dist/attachments/index.d.ts.map +1 -0
  19. package/dist/attachments/index.js +4 -0
  20. package/dist/attachments/index.js.map +1 -0
  21. package/dist/attachments/routes.d.ts +75 -0
  22. package/dist/attachments/routes.d.ts.map +1 -0
  23. package/dist/attachments/routes.js +18 -0
  24. package/dist/attachments/routes.js.map +1 -0
  25. package/dist/attachments/schema.d.ts +61 -0
  26. package/dist/attachments/schema.d.ts.map +1 -0
  27. package/dist/attachments/schema.js +30 -0
  28. package/dist/attachments/schema.js.map +1 -0
  29. package/dist/attachments/service.d.ts +27 -0
  30. package/dist/attachments/service.d.ts.map +1 -0
  31. package/dist/attachments/service.js +224 -0
  32. package/dist/attachments/service.js.map +1 -0
  33. package/dist/attachments/utils.d.ts +9 -0
  34. package/dist/attachments/utils.d.ts.map +1 -0
  35. package/dist/attachments/utils.js +80 -0
  36. package/dist/attachments/utils.js.map +1 -0
  37. package/dist/auth/index.d.ts +4 -0
  38. package/dist/auth/index.d.ts.map +1 -0
  39. package/dist/auth/index.js +4 -0
  40. package/dist/auth/index.js.map +1 -0
  41. package/dist/auth/oidc.d.ts +25 -0
  42. package/dist/auth/oidc.d.ts.map +1 -0
  43. package/dist/auth/oidc.js +298 -0
  44. package/dist/auth/oidc.js.map +1 -0
  45. package/dist/auth/recovery.d.ts +41 -0
  46. package/dist/auth/recovery.d.ts.map +1 -0
  47. package/dist/auth/recovery.js +250 -0
  48. package/dist/auth/recovery.js.map +1 -0
  49. package/dist/auth/routes.d.ts +226 -0
  50. package/dist/auth/routes.d.ts.map +1 -0
  51. package/dist/auth/routes.js +96 -0
  52. package/dist/auth/routes.js.map +1 -0
  53. package/dist/auth/schema.d.ts +158 -0
  54. package/dist/auth/schema.d.ts.map +1 -0
  55. package/dist/auth/schema.js +82 -0
  56. package/dist/auth/schema.js.map +1 -0
  57. package/dist/auth/service.d.ts +96 -0
  58. package/dist/auth/service.d.ts.map +1 -0
  59. package/dist/auth/service.js +129 -0
  60. package/dist/auth/service.js.map +1 -0
  61. package/dist/auth/session.d.ts +51 -0
  62. package/dist/auth/session.d.ts.map +1 -0
  63. package/dist/auth/session.js +344 -0
  64. package/dist/auth/session.js.map +1 -0
  65. package/dist/auth/utils.d.ts +17 -0
  66. package/dist/auth/utils.d.ts.map +1 -0
  67. package/dist/auth/utils.js +96 -0
  68. package/dist/auth/utils.js.map +1 -0
  69. package/dist/bin/corge.js +547 -0
  70. package/dist/chat/context.d.ts +10 -0
  71. package/dist/chat/context.d.ts.map +1 -0
  72. package/dist/chat/context.js +221 -0
  73. package/dist/chat/context.js.map +1 -0
  74. package/dist/chat/index.d.ts +4 -0
  75. package/dist/chat/index.d.ts.map +1 -0
  76. package/dist/chat/index.js +4 -0
  77. package/dist/chat/index.js.map +1 -0
  78. package/dist/chat/routes.d.ts +160 -0
  79. package/dist/chat/routes.d.ts.map +1 -0
  80. package/dist/chat/routes.js +210 -0
  81. package/dist/chat/routes.js.map +1 -0
  82. package/dist/chat/schema.d.ts +285 -0
  83. package/dist/chat/schema.d.ts.map +1 -0
  84. package/dist/chat/schema.js +74 -0
  85. package/dist/chat/schema.js.map +1 -0
  86. package/dist/chat/service.d.ts +64 -0
  87. package/dist/chat/service.d.ts.map +1 -0
  88. package/dist/chat/service.js +279 -0
  89. package/dist/chat/service.js.map +1 -0
  90. package/dist/chat/stream.d.ts +53 -0
  91. package/dist/chat/stream.d.ts.map +1 -0
  92. package/dist/chat/stream.js +339 -0
  93. package/dist/chat/stream.js.map +1 -0
  94. package/dist/chat/utils.d.ts +61 -0
  95. package/dist/chat/utils.d.ts.map +1 -0
  96. package/dist/chat/utils.js +285 -0
  97. package/dist/chat/utils.js.map +1 -0
  98. package/dist/cli/builder.d.ts +6 -0
  99. package/dist/cli/builder.d.ts.map +1 -0
  100. package/dist/cli/builder.js +91 -0
  101. package/dist/cli/builder.js.map +1 -0
  102. package/dist/cli/deployer.d.ts +2 -0
  103. package/dist/cli/deployer.d.ts.map +1 -0
  104. package/dist/cli/deployer.js +152 -0
  105. package/dist/cli/deployer.js.map +1 -0
  106. package/dist/cli/dev-server.d.ts +2 -0
  107. package/dist/cli/dev-server.d.ts.map +1 -0
  108. package/dist/cli/dev-server.js +70 -0
  109. package/dist/cli/dev-server.js.map +1 -0
  110. package/dist/cli/env.d.ts +4 -0
  111. package/dist/cli/env.d.ts.map +1 -0
  112. package/dist/cli/env.js +68 -0
  113. package/dist/cli/env.js.map +1 -0
  114. package/dist/cli/init.d.ts +6 -0
  115. package/dist/cli/init.d.ts.map +1 -0
  116. package/dist/cli/init.js +103 -0
  117. package/dist/cli/init.js.map +1 -0
  118. package/dist/config.d.ts +262 -0
  119. package/dist/config.d.ts.map +1 -0
  120. package/dist/config.js +212 -0
  121. package/dist/config.js.map +1 -0
  122. package/dist/db/index.d.ts +3 -0
  123. package/dist/db/index.d.ts.map +1 -0
  124. package/dist/db/index.js +3 -0
  125. package/dist/db/index.js.map +1 -0
  126. package/dist/db/schema.d.ts +410 -0
  127. package/dist/db/schema.d.ts.map +1 -0
  128. package/dist/db/schema.js +294 -0
  129. package/dist/db/schema.js.map +1 -0
  130. package/dist/db/service.d.ts +42 -0
  131. package/dist/db/service.d.ts.map +1 -0
  132. package/dist/db/service.js +372 -0
  133. package/dist/db/service.js.map +1 -0
  134. package/dist/db/utils.d.ts +25 -0
  135. package/dist/db/utils.d.ts.map +1 -0
  136. package/dist/db/utils.js +188 -0
  137. package/dist/db/utils.js.map +1 -0
  138. package/dist/emails/index.d.ts +4 -0
  139. package/dist/emails/index.d.ts.map +1 -0
  140. package/dist/emails/index.js +4 -0
  141. package/dist/emails/index.js.map +1 -0
  142. package/dist/emails/routes.d.ts +191 -0
  143. package/dist/emails/routes.d.ts.map +1 -0
  144. package/dist/emails/routes.js +39 -0
  145. package/dist/emails/routes.js.map +1 -0
  146. package/dist/emails/schema.d.ts +237 -0
  147. package/dist/emails/schema.d.ts.map +1 -0
  148. package/dist/emails/schema.js +98 -0
  149. package/dist/emails/schema.js.map +1 -0
  150. package/dist/emails/service.d.ts +22 -0
  151. package/dist/emails/service.d.ts.map +1 -0
  152. package/dist/emails/service.js +337 -0
  153. package/dist/emails/service.js.map +1 -0
  154. package/dist/emails/utils.d.ts +49 -0
  155. package/dist/emails/utils.d.ts.map +1 -0
  156. package/dist/emails/utils.js +341 -0
  157. package/dist/emails/utils.js.map +1 -0
  158. package/dist/files/index.d.ts +4 -0
  159. package/dist/files/index.d.ts.map +1 -0
  160. package/dist/files/index.js +4 -0
  161. package/dist/files/index.js.map +1 -0
  162. package/dist/files/routes.d.ts +72 -0
  163. package/dist/files/routes.d.ts.map +1 -0
  164. package/dist/files/routes.js +38 -0
  165. package/dist/files/routes.js.map +1 -0
  166. package/dist/files/schema.d.ts +57 -0
  167. package/dist/files/schema.d.ts.map +1 -0
  168. package/dist/files/schema.js +39 -0
  169. package/dist/files/schema.js.map +1 -0
  170. package/dist/files/service.d.ts +27 -0
  171. package/dist/files/service.d.ts.map +1 -0
  172. package/dist/files/service.js +175 -0
  173. package/dist/files/service.js.map +1 -0
  174. package/dist/files/utils.d.ts +3 -0
  175. package/dist/files/utils.d.ts.map +1 -0
  176. package/dist/files/utils.js +63 -0
  177. package/dist/files/utils.js.map +1 -0
  178. package/dist/index.d.ts +875 -0
  179. package/dist/index.d.ts.map +1 -0
  180. package/dist/index.js +9919 -0
  181. package/dist/index.js.map +7 -0
  182. package/dist/payments/billing.d.ts +29 -0
  183. package/dist/payments/billing.d.ts.map +1 -0
  184. package/dist/payments/billing.js +378 -0
  185. package/dist/payments/billing.js.map +1 -0
  186. package/dist/payments/index.d.ts +4 -0
  187. package/dist/payments/index.d.ts.map +1 -0
  188. package/dist/payments/index.js +4 -0
  189. package/dist/payments/index.js.map +1 -0
  190. package/dist/payments/routes.d.ts +340 -0
  191. package/dist/payments/routes.d.ts.map +1 -0
  192. package/dist/payments/routes.js +56 -0
  193. package/dist/payments/routes.js.map +1 -0
  194. package/dist/payments/schema.d.ts +412 -0
  195. package/dist/payments/schema.d.ts.map +1 -0
  196. package/dist/payments/schema.js +260 -0
  197. package/dist/payments/schema.js.map +1 -0
  198. package/dist/payments/service.d.ts +31 -0
  199. package/dist/payments/service.d.ts.map +1 -0
  200. package/dist/payments/service.js +322 -0
  201. package/dist/payments/service.js.map +1 -0
  202. package/dist/payments/utils.d.ts +61 -0
  203. package/dist/payments/utils.d.ts.map +1 -0
  204. package/dist/payments/utils.js +369 -0
  205. package/dist/payments/utils.js.map +1 -0
  206. package/dist/router/guard.d.ts +19 -0
  207. package/dist/router/guard.d.ts.map +1 -0
  208. package/dist/router/guard.js +243 -0
  209. package/dist/router/guard.js.map +1 -0
  210. package/dist/router/index.d.ts +3 -0
  211. package/dist/router/index.d.ts.map +1 -0
  212. package/dist/router/index.js +3 -0
  213. package/dist/router/index.js.map +1 -0
  214. package/dist/router/schema.d.ts +103 -0
  215. package/dist/router/schema.d.ts.map +1 -0
  216. package/dist/router/schema.js +282 -0
  217. package/dist/router/schema.js.map +1 -0
  218. package/dist/router/service.d.ts +25 -0
  219. package/dist/router/service.d.ts.map +1 -0
  220. package/dist/router/service.js +339 -0
  221. package/dist/router/service.js.map +1 -0
  222. package/dist/tasks/index.d.ts +4 -0
  223. package/dist/tasks/index.d.ts.map +1 -0
  224. package/dist/tasks/index.js +4 -0
  225. package/dist/tasks/index.js.map +1 -0
  226. package/dist/tasks/routes.d.ts +54 -0
  227. package/dist/tasks/routes.d.ts.map +1 -0
  228. package/dist/tasks/routes.js +195 -0
  229. package/dist/tasks/routes.js.map +1 -0
  230. package/dist/tasks/schema.d.ts +261 -0
  231. package/dist/tasks/schema.d.ts.map +1 -0
  232. package/dist/tasks/schema.js +35 -0
  233. package/dist/tasks/schema.js.map +1 -0
  234. package/dist/tasks/service.d.ts +32 -0
  235. package/dist/tasks/service.d.ts.map +1 -0
  236. package/dist/tasks/service.js +201 -0
  237. package/dist/tasks/service.js.map +1 -0
  238. package/dist/tasks/steps.d.ts +18 -0
  239. package/dist/tasks/steps.d.ts.map +1 -0
  240. package/dist/tasks/steps.js +365 -0
  241. package/dist/tasks/steps.js.map +1 -0
  242. package/dist/tasks/utils.d.ts +75 -0
  243. package/dist/tasks/utils.d.ts.map +1 -0
  244. package/dist/tasks/utils.js +311 -0
  245. package/dist/tasks/utils.js.map +1 -0
  246. package/dist/tasks/watcher.d.ts +13 -0
  247. package/dist/tasks/watcher.d.ts.map +1 -0
  248. package/dist/tasks/watcher.js +255 -0
  249. package/dist/tasks/watcher.js.map +1 -0
  250. package/dist/tasks/worker.d.ts +28 -0
  251. package/dist/tasks/worker.d.ts.map +1 -0
  252. package/dist/tasks/worker.js +326 -0
  253. package/dist/tasks/worker.js.map +1 -0
  254. package/dist/utils/index.d.ts +3 -0
  255. package/dist/utils/index.d.ts.map +1 -0
  256. package/dist/utils/index.js +3 -0
  257. package/dist/utils/index.js.map +1 -0
  258. package/dist/utils/schema.d.ts +79 -0
  259. package/dist/utils/schema.d.ts.map +1 -0
  260. package/dist/utils/schema.js +41 -0
  261. package/dist/utils/schema.js.map +1 -0
  262. package/dist/utils/service.d.ts +32 -0
  263. package/dist/utils/service.d.ts.map +1 -0
  264. package/dist/utils/service.js +384 -0
  265. package/dist/utils/service.js.map +1 -0
  266. package/docs/SDK_SPEC.md +1645 -0
  267. package/infra/firestore.base.indexes.json +138 -0
  268. package/infra/firestore.base.rules +152 -0
  269. package/infra/storage.base.rules +8 -0
  270. package/package.json +59 -13
  271. package/src/ai/index.ts +2 -0
  272. package/src/ai/schema.ts +64 -0
  273. package/src/ai/service.ts +317 -0
  274. package/src/ai/utils.ts +38 -0
  275. package/src/attachments/index.ts +3 -0
  276. package/src/attachments/routes.ts +28 -0
  277. package/src/attachments/schema.ts +45 -0
  278. package/src/attachments/service.ts +315 -0
  279. package/src/attachments/utils.ts +115 -0
  280. package/src/auth/index.ts +3 -0
  281. package/src/auth/oidc.ts +419 -0
  282. package/src/auth/recovery.ts +365 -0
  283. package/src/auth/routes.ts +142 -0
  284. package/src/auth/schema.ts +181 -0
  285. package/src/auth/service.ts +183 -0
  286. package/src/auth/session.ts +414 -0
  287. package/src/auth/utils.ts +106 -0
  288. package/src/chat/context.ts +310 -0
  289. package/src/chat/index.ts +3 -0
  290. package/src/chat/routes.ts +264 -0
  291. package/src/chat/schema.ts +295 -0
  292. package/src/chat/service.ts +360 -0
  293. package/src/chat/stream.ts +481 -0
  294. package/src/chat/utils.ts +370 -0
  295. package/src/cli/builder.ts +93 -0
  296. package/src/cli/deployer.ts +184 -0
  297. package/src/cli/dev-server.ts +77 -0
  298. package/src/cli/env.ts +89 -0
  299. package/src/cli/init.ts +125 -0
  300. package/src/config.ts +447 -0
  301. package/src/db/index.ts +2 -0
  302. package/src/db/schema.ts +409 -0
  303. package/src/db/service.ts +477 -0
  304. package/src/db/utils.ts +250 -0
  305. package/src/emails/index.ts +3 -0
  306. package/src/emails/routes.ts +64 -0
  307. package/src/emails/schema.ts +239 -0
  308. package/src/emails/service.ts +488 -0
  309. package/src/emails/utils.ts +449 -0
  310. package/src/files/index.ts +3 -0
  311. package/src/files/routes.ts +44 -0
  312. package/src/files/schema.ts +76 -0
  313. package/src/files/service.ts +238 -0
  314. package/src/files/utils.ts +77 -0
  315. package/src/index.ts +425 -0
  316. package/src/payments/billing.ts +488 -0
  317. package/src/payments/index.ts +3 -0
  318. package/src/payments/routes.ts +92 -0
  319. package/src/payments/schema.ts +401 -0
  320. package/src/payments/service.ts +445 -0
  321. package/src/payments/utils.ts +472 -0
  322. package/src/router/guard.ts +319 -0
  323. package/src/router/index.ts +2 -0
  324. package/src/router/schema.ts +458 -0
  325. package/src/router/service.ts +453 -0
  326. package/src/tasks/index.ts +11 -0
  327. package/src/tasks/routes.ts +230 -0
  328. package/src/tasks/schema.ts +310 -0
  329. package/src/tasks/service.ts +254 -0
  330. package/src/tasks/steps.ts +472 -0
  331. package/src/tasks/utils.ts +445 -0
  332. package/src/tasks/watcher.ts +307 -0
  333. package/src/tasks/worker.ts +461 -0
  334. package/src/utils/index.ts +2 -0
  335. package/src/utils/schema.ts +69 -0
  336. package/src/utils/service.ts +461 -0
  337. package/tests/e2e.test.ts +2531 -0
  338. package/tsconfig.base.json +12 -0
  339. package/README.md +0 -2
  340. package/index.d.ts +0 -1
  341. package/index.js +0 -1
@@ -0,0 +1,250 @@
1
+ import { FieldPath, Timestamp } from 'firebase-admin/firestore'
2
+ import type { DbAction, DbRole } from '../config'
3
+ import type { Core } from '../index'
4
+ import {
5
+ collectionRegistry,
6
+ getCollectionDef,
7
+ getEffectiveRbac,
8
+ type CollectionName,
9
+ type DbListOptions,
10
+ type PaginatedList,
11
+ type SelectModel,
12
+ type UserRelationConfig
13
+ } from './schema'
14
+
15
+ export const resolveJunction = (collection: string, j: NonNullable<UserRelationConfig['junction']>) => ({
16
+ collection: j.collection,
17
+ userField: j.userField ?? 'userId',
18
+ targetField: j.targetField ?? `${collection}Id`
19
+ })
20
+
21
+ export const hasAction = (actions: readonly DbAction[] | undefined, target: DbAction): boolean =>
22
+ Boolean(actions && (actions.includes('*') || actions.includes(target)))
23
+
24
+ export const resolveAllowedScope = (collection: string, role: DbRole, action: DbAction): 'any' | 'own' | 'none' => {
25
+ const rbac = getEffectiveRbac(collection)
26
+ const roleScopes = rbac[role]
27
+ if (!roleScopes) return 'none'
28
+ if (hasAction(roleScopes.any, action)) return 'any'
29
+ if (hasAction(roleScopes.own, action)) return 'own'
30
+ return 'none'
31
+ }
32
+
33
+ export const normalizeDates = (val: unknown): unknown => {
34
+ if (val instanceof Timestamp) return val.toDate()
35
+ if (Array.isArray(val)) return val.map(normalizeDates)
36
+ if (val && typeof val === 'object' && !(val instanceof Date)) {
37
+ return Object.fromEntries(Object.entries(val).map(([k, v]) => [k, normalizeDates(v)]))
38
+ }
39
+ return val
40
+ }
41
+
42
+ export const fromFirestoreDoc = <T extends CollectionName>(
43
+ c: T,
44
+ id: string,
45
+ raw: Record<string, unknown>,
46
+ partial = false
47
+ ): SelectModel<T> => {
48
+ const norm = normalizeDates({ ...raw, id }) as Record<string, unknown>
49
+ const schema = getCollectionDef(c).schema
50
+ return (partial ? schema.partial().parse(norm) : schema.parse(norm)) as SelectModel<T>
51
+ }
52
+
53
+ export const attachNextCursor = <T extends { id: string }>(items: T[], limit: number): PaginatedList<T> => {
54
+ const nextCursor = items.length >= limit ? (items.at(-1)?.id ?? null) : null
55
+ Object.defineProperty(items, 'nextCursor', { value: nextCursor, enumerable: false, configurable: true })
56
+ return items as PaginatedList<T>
57
+ }
58
+
59
+ export const stripImmutableUpdateFields = (
60
+ collection: string,
61
+ data: unknown,
62
+ allowSystemFields = false
63
+ ): Record<string, unknown> => {
64
+ const def = getCollectionDef(collection)
65
+ const ownerField = def.userRelation?.userField ?? 'userId'
66
+ const raw = (data ?? {}) as Record<string, unknown>
67
+ const {
68
+ id: _id,
69
+ createdAt: _createdAt,
70
+ updatedAt: _updatedAt,
71
+ deleted: _deleted,
72
+ userId: _userId,
73
+ [ownerField]: _ownerField,
74
+ ...mutableData
75
+ } = raw
76
+ if (allowSystemFields && raw.deleted !== undefined) {
77
+ mutableData.deleted = raw.deleted
78
+ }
79
+ const parsed = def.schema.partial().parse(mutableData) as Record<string, unknown>
80
+ return Object.fromEntries(
81
+ Object.keys(mutableData)
82
+ .filter((k) => mutableData[k] !== undefined && k in parsed)
83
+ .map((k) => [k, parsed[k]])
84
+ )
85
+ }
86
+
87
+ export const drainQueryBatchDelete = async (
88
+ db: FirebaseFirestore.Firestore,
89
+ query: FirebaseFirestore.Query,
90
+ batchSize = 400
91
+ ): Promise<number> => {
92
+ let deleted = 0
93
+ while (true) {
94
+ const snap = await query.limit(batchSize).get()
95
+ if (snap.empty) break
96
+ const batch = db.batch()
97
+ for (const doc of snap.docs) batch.delete(doc.ref)
98
+ await batch.commit()
99
+ deleted += snap.size
100
+ if (snap.size < batchSize) break
101
+ }
102
+ return deleted
103
+ }
104
+
105
+ export const executeFilteredListQuery = async <N extends CollectionName>(
106
+ db: FirebaseFirestore.Firestore,
107
+ collection: N,
108
+ baseQuery: FirebaseFirestore.Query,
109
+ opts: DbListOptions<SelectModel<N>> | undefined,
110
+ maxDocs: number,
111
+ hasSelect: boolean,
112
+ defaultListLimit: number
113
+ ): Promise<SelectModel<N>[]> => {
114
+ const def = getCollectionDef(collection)
115
+ const sortField = opts?.orderBy?.field ?? (def.sortByUpdated ? 'updatedAt' : undefined)
116
+ const sortDir = opts?.orderBy?.direction ?? 'desc'
117
+ const cursorSnap = opts?.cursor ? await db.collection(collection).doc(opts.cursor).get() : undefined
118
+ const filteredQuery = !opts?.includeDeleted ? baseQuery.where('deleted', '==', false) : baseQuery
119
+
120
+ let snap: FirebaseFirestore.QuerySnapshot
121
+ let sortedInDb = false
122
+ let appliedCursorInDb = false
123
+ try {
124
+ let q = sortField ? filteredQuery.orderBy(sortField, sortDir) : filteredQuery.orderBy(FieldPath.documentId(), 'asc')
125
+ if (cursorSnap?.exists) q = q.startAfter(cursorSnap)
126
+ snap = await q.limit(maxDocs).get()
127
+ sortedInDb = true
128
+ appliedCursorInDb = Boolean(cursorSnap?.exists)
129
+ } catch (err) {
130
+ console.warn(
131
+ `[db] composite index missing for "${collection}" (falling back to filtered query):`,
132
+ err instanceof Error ? err.message : err
133
+ )
134
+ const fallbackLimit = sortField || opts?.cursor ? Math.max(maxDocs * 5, defaultListLimit) : maxDocs
135
+ try {
136
+ snap = await filteredQuery.limit(fallbackLimit).get()
137
+ } catch {
138
+ snap = await baseQuery.limit(fallbackLimit).get()
139
+ }
140
+ }
141
+
142
+ const docs = snap.docs
143
+ .map((s) => fromFirestoreDoc(collection, s.id, s.data() ?? {}, hasSelect))
144
+ .filter((d) => opts?.includeDeleted || !d.deleted)
145
+
146
+ return sortAndSliceFallbackDocs(docs, sortField, sortDir, opts?.cursor, sortedInDb, appliedCursorInDb)
147
+ }
148
+
149
+ export const sortAndSliceFallbackDocs = <T extends Record<string, any> & { id: string }>(
150
+ docs: T[],
151
+ sortField: string | undefined,
152
+ sortDir: 'asc' | 'desc',
153
+ cursor: string | undefined,
154
+ sortedInDb: boolean,
155
+ appliedCursorInDb: boolean
156
+ ): T[] => {
157
+ let result = docs
158
+ if (!sortedInDb) {
159
+ result.sort((a, b) => {
160
+ if (sortField) {
161
+ const va = a[sortField] instanceof Date ? a[sortField].getTime() : a[sortField]
162
+ const vb = b[sortField] instanceof Date ? b[sortField].getTime() : b[sortField]
163
+ if (va !== vb) return sortDir === 'asc' ? (va > vb ? 1 : -1) : va < vb ? 1 : -1
164
+ }
165
+ return a.id.localeCompare(b.id)
166
+ })
167
+ }
168
+ if (cursor && !appliedCursorInDb) {
169
+ const idx = result.findIndex((d) => d.id === cursor)
170
+ result = idx >= 0 ? result.slice(idx + 1) : []
171
+ }
172
+ return result
173
+ }
174
+
175
+ export const runChunked = async <T>(
176
+ items: readonly T[],
177
+ fn: (item: T) => Promise<unknown>,
178
+ concurrency = 25
179
+ ): Promise<void> => {
180
+ for (let i = 0; i < items.length; i += concurrency) {
181
+ await Promise.all(items.slice(i, i + concurrency).map(fn))
182
+ }
183
+ }
184
+
185
+ export const isCreateOwnScopeAllowed = (
186
+ collection: string,
187
+ userId: string | undefined,
188
+ data: Record<string, any>
189
+ ): boolean => {
190
+ if (!userId) return false
191
+ const def = getCollectionDef(collection)
192
+ const rel = def.userRelation?.type ?? '1:N'
193
+ if (rel === 'global') return false
194
+ if (rel === '1:1' || rel === '1:N') {
195
+ const uf = def.userRelation?.userField ?? (rel === '1:1' ? 'id' : 'userId')
196
+ const ownerVal = data[uf] ?? (uf === 'id' ? data.id : undefined)
197
+ if (ownerVal && ownerVal !== userId) return false
198
+ } else if (rel === 'M:N' && !def.userRelation?.junction) {
199
+ const uif = def.userRelation?.userIdsField ?? 'userIds'
200
+ const ownerIds = data[uif]
201
+ if (Array.isArray(ownerIds) && !ownerIds.includes(userId)) return false
202
+ }
203
+ return true
204
+ }
205
+
206
+ export const buildScopedCollectionQuery = (
207
+ baseQuery: FirebaseFirestore.Query,
208
+ collection: string,
209
+ scopedUserId: string | undefined,
210
+ where: Record<string, unknown> | undefined,
211
+ select: readonly (string | symbol | number)[] | undefined
212
+ ): FirebaseFirestore.Query => {
213
+ const def = getCollectionDef(collection)
214
+ const rel = def.userRelation?.type ?? '1:N'
215
+ let q = baseQuery
216
+ if (scopedUserId && rel !== 'global') {
217
+ q =
218
+ rel === '1:1' || rel === '1:N'
219
+ ? q.where(def.userRelation?.userField ?? 'userId', '==', scopedUserId)
220
+ : q.where(def.userRelation?.userIdsField ?? 'userIds', 'array-contains', scopedUserId)
221
+ }
222
+ if (where) {
223
+ for (const [field, value] of Object.entries(where)) {
224
+ if (value === undefined) continue
225
+ q = Array.isArray(value) ? q.where(field, 'in', value.slice(0, 30)) : q.where(field, '==', value)
226
+ }
227
+ }
228
+ if (select?.length) q = q.select(...(select as string[]))
229
+ return q
230
+ }
231
+
232
+ export const wireCoreCollectionHooks = (): void => {
233
+ const chatDef = collectionRegistry.get('chat')
234
+ if (chatDef && !chatDef.onHardDelete) {
235
+ chatDef.onHardDelete = async (doc: { id?: string }, core: Core) => {
236
+ if (!doc.id) return
237
+ const db = core.db.firestore()
238
+ await core.internals.resilience.acquireLimit('firestore')
239
+ await drainQueryBatchDelete(db, db.collection('chat').doc(doc.id).collection('messages'))
240
+ }
241
+ }
242
+ const fileDef = collectionRegistry.get('file')
243
+ if (fileDef && !fileDef.onHardDelete) {
244
+ fileDef.onHardDelete = async (doc: { id?: string }, core: Core) => {
245
+ if (!doc.id) return
246
+ await core.internals.resilience.acquireLimit('storage')
247
+ await core.files.bucket().file(doc.id).delete({ ignoreNotFound: true }).catch(() => {})
248
+ }
249
+ }
250
+ }
@@ -0,0 +1,3 @@
1
+ export * from './schema'
2
+ export { EmailsService } from './service'
3
+ export { coreEmailRoutes } from './routes'
@@ -0,0 +1,64 @@
1
+ import type { Context } from 'hono'
2
+ import type { AppEnv } from '../config'
3
+ import type { Core } from '../index'
4
+ import {
5
+ emailLogsQuerySchema,
6
+ emailWebhookParamsSchema,
7
+ sendBatchEmailInputSchema,
8
+ sendBatchEmailResultSchema,
9
+ sendEmailInputSchema,
10
+ sendEmailResultSchema,
11
+ type EmailLogsQuery,
12
+ type EmailWebhookParams,
13
+ type SendBatchEmailInput,
14
+ type SendEmailInput
15
+ } from './schema'
16
+
17
+ export const coreEmailRoutes = {
18
+ 'emails.send': {
19
+ input: sendEmailInputSchema,
20
+ output: sendEmailResultSchema,
21
+ handler: async ({ core, input }: { core: Core; input: SendEmailInput }) => core.emails.send(input)
22
+ },
23
+ 'emails.batch': {
24
+ input: sendBatchEmailInputSchema,
25
+ output: sendBatchEmailResultSchema,
26
+ handler: async ({ core, input }: { core: Core; input: SendBatchEmailInput }) => core.emails.sendBatch(input.emails)
27
+ },
28
+ 'emails.logs': {
29
+ query: emailLogsQuerySchema,
30
+ handler: async ({ core, query }: { core: Core; query: EmailLogsQuery }) =>
31
+ core.emails.listLogs({
32
+ ...(query.userId ? { userId: query.userId } : {}),
33
+ ...(query.limit ? { limit: query.limit } : {}),
34
+ ...(query.cursor ? { cursor: query.cursor } : {}),
35
+ where: {
36
+ ...(query.to ? { to: query.to.trim().toLowerCase() } : {}),
37
+ ...(query.template ? { template: query.template } : {}),
38
+ ...(query.purpose ? { purpose: query.purpose } : {}),
39
+ ...(query.status ? { status: query.status } : {})
40
+ }
41
+ })
42
+ },
43
+ 'emails.webhook': {
44
+ params: emailWebhookParamsSchema,
45
+ handler: async ({
46
+ core,
47
+ c,
48
+ params,
49
+ rawBody
50
+ }: {
51
+ core: Core
52
+ c: Context<AppEnv>
53
+ params: EmailWebhookParams
54
+ rawBody?: string
55
+ }) => {
56
+ const headers: Record<string, string | undefined> = {}
57
+ c?.req.raw.headers.forEach((value, key) => {
58
+ headers[key.toLowerCase()] = value
59
+ })
60
+ const body = rawBody ?? (c ? await c.req.text() : '')
61
+ return core.internals.emails.verifyAndProcessWebhook(params.provider, body, headers)
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,239 @@
1
+ import { z } from 'zod'
2
+ import type {
3
+ AuthEmailPurpose,
4
+ AuthEmailVariables,
5
+ AuthFlowConfigSpec,
6
+ AuthFlowEmailHook,
7
+ EmailProvider,
8
+ EmailsConfig,
9
+ TransactionalEmailPayload
10
+ } from '../config'
11
+ import { entryId, fileRef, type EntryId } from '../db'
12
+
13
+ export type {
14
+ AuthEmailPurpose,
15
+ AuthEmailVariables,
16
+ AuthFlowConfigSpec,
17
+ AuthFlowEmailHook,
18
+ EmailProvider,
19
+ EmailsConfig,
20
+ TransactionalEmailPayload
21
+ }
22
+
23
+ export const emailLogStatusSchema = z.enum([
24
+ 'queued',
25
+ 'sent',
26
+ 'delivered',
27
+ 'opened',
28
+ 'clicked',
29
+ 'bounced',
30
+ 'complained',
31
+ 'failed',
32
+ 'suppressed'
33
+ ])
34
+ export type EmailLogStatus = z.infer<typeof emailLogStatusSchema>
35
+
36
+ export interface EmailTemplateProviderSpec<V = Record<string, unknown>> {
37
+ sendgrid?: {
38
+ templateId: string
39
+ }
40
+ resend?: {
41
+ templateId?: string
42
+ subject?: string | ((vars: V) => string)
43
+ html?: string | ((vars: V) => string)
44
+ text?: string | ((vars: V) => string)
45
+ }
46
+ }
47
+
48
+ export interface EmailTemplateDefinition<S extends z.ZodTypeAny = z.ZodType<Record<string, unknown>>> {
49
+ id?: string
50
+ description?: string
51
+ subject?: string | ((vars: z.infer<S>) => string)
52
+ html?: string | ((vars: z.infer<S>) => string)
53
+ text?: string | ((vars: z.infer<S>) => string)
54
+ fromEmail?: string
55
+ fromName?: string
56
+ replyTo?: string
57
+ category?: string
58
+ variables?: S
59
+ providers?: EmailTemplateProviderSpec<z.infer<S>>
60
+ onBeforeSend?: (vars: z.infer<S>, recipient: string) => Promise<z.infer<S> | false>
61
+ }
62
+
63
+ export type EmailTemplatesMap = Record<string, EmailTemplateDefinition<any>>
64
+
65
+ const registeredEmailTemplates: EmailTemplatesMap = {}
66
+
67
+ export const registerEmailTemplates = (templates?: EmailTemplatesMap, reset = false): void => {
68
+ if (reset) {
69
+ for (const k of Object.keys(registeredEmailTemplates)) delete registeredEmailTemplates[k]
70
+ }
71
+ if (!templates) return
72
+ for (const [k, def] of Object.entries(templates)) {
73
+ registeredEmailTemplates[k] = { ...def, id: def.id ?? k }
74
+ }
75
+ }
76
+
77
+ export const clearEmailTemplates = (): void => {
78
+ for (const k of Object.keys(registeredEmailTemplates)) delete registeredEmailTemplates[k]
79
+ }
80
+
81
+ export const defineEmailTemplates = <T extends EmailTemplatesMap>(templates: T): T => {
82
+ const normalized = Object.fromEntries(
83
+ Object.entries(templates).map(([k, def]) => [k, { ...def, id: def.id ?? k }])
84
+ ) as T
85
+ registerEmailTemplates(normalized, false)
86
+ return normalized
87
+ }
88
+
89
+ export const getEmailTemplate = (id: string): EmailTemplateDefinition<any> | undefined =>
90
+ registeredEmailTemplates[id]
91
+
92
+ export const getRegisteredEmailTemplates = (): EmailTemplatesMap => registeredEmailTemplates
93
+
94
+ export const emailAttachmentInputSchema = z
95
+ .object({
96
+ filename: z.string().min(1),
97
+ fileRef: fileRef.optional(),
98
+ contentBase64: z.string().optional(),
99
+ contentType: z.string().optional()
100
+ })
101
+ .refine((v) => Boolean(v.fileRef || v.contentBase64), {
102
+ message: 'Attachment requires either fileRef or contentBase64'
103
+ })
104
+ export type EmailAttachmentInput = z.infer<typeof emailAttachmentInputSchema>
105
+
106
+ export const sendEmailInputSchema = z
107
+ .object({
108
+ to: z.union([z.string().email(), z.array(z.string().email()).min(1).max(50)]),
109
+ template: z.string().optional(),
110
+ providerTemplateId: z.string().optional(),
111
+ variables: z.record(z.string(), z.unknown()).default({}),
112
+ subject: z.string().max(300).optional(),
113
+ html: z.string().optional(),
114
+ text: z.string().optional(),
115
+ fromEmail: z.string().email().optional(),
116
+ fromName: z.string().max(120).optional(),
117
+ replyTo: z.string().email().optional(),
118
+ cc: z.array(z.string().email()).optional(),
119
+ bcc: z.array(z.string().email()).optional(),
120
+ attachments: z.array(emailAttachmentInputSchema).max(10).optional(),
121
+ tags: z.record(z.string(), z.string()).optional(),
122
+ idempotencyKey: z.string().max(200).optional(),
123
+ purpose: z.string().optional()
124
+ })
125
+ .refine((v) => Boolean(v.template || v.providerTemplateId || v.html || v.text), {
126
+ message: 'Provide a template alias, providerTemplateId, or html/text body'
127
+ })
128
+
129
+ export type SendEmailInput<T = Record<string, unknown>> = Omit<z.input<typeof sendEmailInputSchema>, 'variables'> & {
130
+ variables?: T
131
+ }
132
+
133
+ export const sendBatchEmailInputSchema = z.object({
134
+ emails: z.array(sendEmailInputSchema).min(1).max(100)
135
+ })
136
+ export type SendBatchEmailInput = z.input<typeof sendBatchEmailInputSchema>
137
+
138
+ export const emailLogsQuerySchema = z.object({
139
+ to: z.string().optional(),
140
+ template: z.string().optional(),
141
+ purpose: z.string().optional(),
142
+ status: emailLogStatusSchema.optional(),
143
+ userId: entryId.optional(),
144
+ limit: z.coerce.number().int().min(1).max(500).optional(),
145
+ cursor: z.string().optional()
146
+ })
147
+ export type EmailLogsQuery = z.infer<typeof emailLogsQuerySchema>
148
+
149
+ export const emailWebhookParamsSchema = z.object({
150
+ provider: z.enum(['sendgrid', 'resend'])
151
+ })
152
+ export type EmailWebhookParams = z.infer<typeof emailWebhookParamsSchema>
153
+
154
+ export const sendEmailResultSchema = z.object({
155
+ emailId: entryId,
156
+ externalMessageId: z.string().optional(),
157
+ status: emailLogStatusSchema,
158
+ provider: z.enum(['sendgrid', 'resend', 'firebase', 'dev']),
159
+ deduplicated: z.boolean().optional()
160
+ })
161
+ export type SendEmailResult = z.infer<typeof sendEmailResultSchema>
162
+
163
+ export const sendBatchEmailResultSchema = z.object({
164
+ total: z.number().int().nonnegative(),
165
+ sent: z.number().int().nonnegative(),
166
+ suppressed: z.number().int().nonnegative(),
167
+ failed: z.number().int().nonnegative(),
168
+ results: z.array(sendEmailResultSchema)
169
+ })
170
+ export type SendBatchEmailResult = z.infer<typeof sendBatchEmailResultSchema>
171
+
172
+ export interface EmailCallOptions {
173
+ provider?: 'sendgrid' | 'resend'
174
+ userId?: EntryId
175
+ }
176
+
177
+ export interface ResolvedAttachment {
178
+ filename: string
179
+ contentBase64: string
180
+ contentType: string
181
+ }
182
+
183
+ export interface NormalizedEmailRequest {
184
+ emailId: EntryId
185
+ to: string[]
186
+ fromEmail: string
187
+ fromName: string
188
+ replyTo?: string
189
+ cc?: string[]
190
+ bcc?: string[]
191
+ subject?: string
192
+ html?: string
193
+ text?: string
194
+ template?: string
195
+ providerTemplateId?: string
196
+ variables: Record<string, unknown>
197
+ attachments: ResolvedAttachment[]
198
+ tags?: Record<string, string>
199
+ idempotencyKey?: string
200
+ purpose?: string
201
+ userId?: EntryId
202
+ }
203
+
204
+ export interface ResendWebhookEvent {
205
+ type:
206
+ | 'email.sent'
207
+ | 'email.delivered'
208
+ | 'email.delivery_delayed'
209
+ | 'email.complained'
210
+ | 'email.bounced'
211
+ | 'email.opened'
212
+ | 'email.clicked'
213
+ | (string & {})
214
+ created_at?: string
215
+ data: {
216
+ email_id?: string
217
+ to?: string[] | string
218
+ from?: string
219
+ subject?: string
220
+ bounce?: { message?: string; type?: string }
221
+ }
222
+ }
223
+
224
+ export interface SendGridWebhookEvent {
225
+ event:
226
+ | 'processed'
227
+ | 'delivered'
228
+ | 'open'
229
+ | 'click'
230
+ | 'bounce'
231
+ | 'dropped'
232
+ | 'spamreport'
233
+ | (string & {})
234
+ email?: string
235
+ sg_message_id?: string
236
+ timestamp?: number
237
+ reason?: string
238
+ type?: string
239
+ }