@kelpie/server 0.6.0 → 0.8.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 (266) hide show
  1. package/dist/app.d.ts +13 -0
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +9 -0
  4. package/dist/app.js.map +1 -1
  5. package/dist/boot.d.ts +42 -0
  6. package/dist/boot.d.ts.map +1 -0
  7. package/dist/boot.js +57 -0
  8. package/dist/boot.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +5 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/config.d.ts +7 -0
  14. package/dist/lib/config.d.ts.map +1 -1
  15. package/dist/lib/config.js +2 -0
  16. package/dist/lib/config.js.map +1 -1
  17. package/dist/lib/consumerEmailDomains.d.ts +2 -0
  18. package/dist/lib/consumerEmailDomains.d.ts.map +1 -0
  19. package/dist/lib/consumerEmailDomains.js +62 -0
  20. package/dist/lib/consumerEmailDomains.js.map +1 -0
  21. package/dist/lib/email.d.ts +7 -0
  22. package/dist/lib/email.d.ts.map +1 -1
  23. package/dist/lib/email.js.map +1 -1
  24. package/dist/lib/emailContent.d.ts +33 -0
  25. package/dist/lib/emailContent.d.ts.map +1 -0
  26. package/dist/lib/emailContent.js +54 -0
  27. package/dist/lib/emailContent.js.map +1 -0
  28. package/dist/lib/emailDomainAutoLink.d.ts +27 -0
  29. package/dist/lib/emailDomainAutoLink.d.ts.map +1 -0
  30. package/dist/lib/emailDomainAutoLink.js +119 -0
  31. package/dist/lib/emailDomainAutoLink.js.map +1 -0
  32. package/dist/lib/ids.d.ts +2 -0
  33. package/dist/lib/ids.d.ts.map +1 -1
  34. package/dist/lib/ids.js +2 -0
  35. package/dist/lib/ids.js.map +1 -1
  36. package/dist/lib/kelpieConfigFile.d.ts +6 -0
  37. package/dist/lib/kelpieConfigFile.d.ts.map +1 -1
  38. package/dist/lib/kelpieConfigFile.js +1 -0
  39. package/dist/lib/kelpieConfigFile.js.map +1 -1
  40. package/dist/modules/attachedRecords.d.ts.map +1 -1
  41. package/dist/modules/attachedRecords.js +4 -2
  42. package/dist/modules/attachedRecords.js.map +1 -1
  43. package/dist/modules/auth/preferences.d.ts +11 -1
  44. package/dist/modules/auth/preferences.d.ts.map +1 -1
  45. package/dist/modules/auth/preferences.js +11 -0
  46. package/dist/modules/auth/preferences.js.map +1 -1
  47. package/dist/modules/auth/routes.d.ts.map +1 -1
  48. package/dist/modules/auth/routes.js +18 -0
  49. package/dist/modules/auth/routes.js.map +1 -1
  50. package/dist/modules/auth/schema.d.ts +20 -0
  51. package/dist/modules/auth/schema.d.ts.map +1 -1
  52. package/dist/modules/auth/schema.js +11 -1
  53. package/dist/modules/auth/schema.js.map +1 -1
  54. package/dist/modules/auth/service.d.ts.map +1 -1
  55. package/dist/modules/auth/service.js +27 -3
  56. package/dist/modules/auth/service.js.map +1 -1
  57. package/dist/modules/companies/repository.d.ts +2 -0
  58. package/dist/modules/companies/repository.d.ts.map +1 -1
  59. package/dist/modules/companies/repository.js +1 -0
  60. package/dist/modules/companies/repository.js.map +1 -1
  61. package/dist/modules/companies/routes.d.ts +2 -0
  62. package/dist/modules/companies/routes.d.ts.map +1 -1
  63. package/dist/modules/companies/routes.js +11 -0
  64. package/dist/modules/companies/routes.js.map +1 -1
  65. package/dist/modules/companies/schema.d.ts +17 -0
  66. package/dist/modules/companies/schema.d.ts.map +1 -1
  67. package/dist/modules/companies/schema.js +5 -1
  68. package/dist/modules/companies/schema.js.map +1 -1
  69. package/dist/modules/companies/service.d.ts +1 -0
  70. package/dist/modules/companies/service.d.ts.map +1 -1
  71. package/dist/modules/companies/service.js +22 -0
  72. package/dist/modules/companies/service.js.map +1 -1
  73. package/dist/modules/core.d.ts.map +1 -1
  74. package/dist/modules/core.js +11 -0
  75. package/dist/modules/core.js.map +1 -1
  76. package/dist/modules/email-domain-linker/index.d.ts +12 -0
  77. package/dist/modules/email-domain-linker/index.d.ts.map +1 -0
  78. package/dist/modules/email-domain-linker/index.js +34 -0
  79. package/dist/modules/email-domain-linker/index.js.map +1 -0
  80. package/dist/modules/email-domain-linker/routes.d.ts +14 -0
  81. package/dist/modules/email-domain-linker/routes.d.ts.map +1 -0
  82. package/dist/modules/email-domain-linker/routes.js +15 -0
  83. package/dist/modules/email-domain-linker/routes.js.map +1 -0
  84. package/dist/modules/email-domain-linker/service.d.ts +37 -0
  85. package/dist/modules/email-domain-linker/service.d.ts.map +1 -0
  86. package/dist/modules/email-domain-linker/service.js +63 -0
  87. package/dist/modules/email-domain-linker/service.js.map +1 -0
  88. package/dist/modules/eventCatalogs.d.ts +36 -0
  89. package/dist/modules/eventCatalogs.d.ts.map +1 -0
  90. package/dist/modules/eventCatalogs.js +36 -0
  91. package/dist/modules/eventCatalogs.js.map +1 -0
  92. package/dist/modules/import-export/drafts.d.ts +9 -0
  93. package/dist/modules/import-export/drafts.d.ts.map +1 -1
  94. package/dist/modules/import-export/drafts.js +25 -0
  95. package/dist/modules/import-export/drafts.js.map +1 -1
  96. package/dist/modules/import-export/exportRows.d.ts +9 -0
  97. package/dist/modules/import-export/exportRows.d.ts.map +1 -1
  98. package/dist/modules/import-export/exportRows.js +10 -1
  99. package/dist/modules/import-export/exportRows.js.map +1 -1
  100. package/dist/modules/import-export/plan.d.ts +23 -1
  101. package/dist/modules/import-export/plan.d.ts.map +1 -1
  102. package/dist/modules/import-export/plan.js +62 -3
  103. package/dist/modules/import-export/plan.js.map +1 -1
  104. package/dist/modules/import-export/presets.d.ts.map +1 -1
  105. package/dist/modules/import-export/presets.js +25 -0
  106. package/dist/modules/import-export/presets.js.map +1 -1
  107. package/dist/modules/import-export/repository.d.ts +17 -0
  108. package/dist/modules/import-export/repository.d.ts.map +1 -1
  109. package/dist/modules/import-export/repository.js +21 -1
  110. package/dist/modules/import-export/repository.js.map +1 -1
  111. package/dist/modules/import-export/routes.d.ts.map +1 -1
  112. package/dist/modules/import-export/routes.js +5 -1
  113. package/dist/modules/import-export/routes.js.map +1 -1
  114. package/dist/modules/import-export/schema.d.ts +55 -0
  115. package/dist/modules/import-export/schema.d.ts.map +1 -1
  116. package/dist/modules/import-export/schema.js +13 -1
  117. package/dist/modules/import-export/schema.js.map +1 -1
  118. package/dist/modules/import-export/service.d.ts +4 -1
  119. package/dist/modules/import-export/service.d.ts.map +1 -1
  120. package/dist/modules/import-export/service.js +51 -3
  121. package/dist/modules/import-export/service.js.map +1 -1
  122. package/dist/modules/import-export/tools.d.ts.map +1 -1
  123. package/dist/modules/import-export/tools.js +7 -1
  124. package/dist/modules/import-export/tools.js.map +1 -1
  125. package/dist/modules/import-export/writes.d.ts +16 -0
  126. package/dist/modules/import-export/writes.d.ts.map +1 -1
  127. package/dist/modules/import-export/writes.js +77 -2
  128. package/dist/modules/import-export/writes.js.map +1 -1
  129. package/dist/modules/lists/events.d.ts +42 -0
  130. package/dist/modules/lists/events.d.ts.map +1 -0
  131. package/dist/modules/lists/events.js +16 -0
  132. package/dist/modules/lists/events.js.map +1 -0
  133. package/dist/modules/lists/index.d.ts +10 -0
  134. package/dist/modules/lists/index.d.ts.map +1 -0
  135. package/dist/modules/lists/index.js +35 -0
  136. package/dist/modules/lists/index.js.map +1 -0
  137. package/dist/modules/lists/repository.d.ts +69 -0
  138. package/dist/modules/lists/repository.d.ts.map +1 -0
  139. package/dist/modules/lists/repository.js +165 -0
  140. package/dist/modules/lists/repository.js.map +1 -0
  141. package/dist/modules/lists/routes.d.ts +51 -0
  142. package/dist/modules/lists/routes.d.ts.map +1 -0
  143. package/dist/modules/lists/routes.js +160 -0
  144. package/dist/modules/lists/routes.js.map +1 -0
  145. package/dist/modules/lists/schema.d.ts +264 -0
  146. package/dist/modules/lists/schema.d.ts.map +1 -0
  147. package/dist/modules/lists/schema.js +73 -0
  148. package/dist/modules/lists/schema.js.map +1 -0
  149. package/dist/modules/lists/service.d.ts +70 -0
  150. package/dist/modules/lists/service.d.ts.map +1 -0
  151. package/dist/modules/lists/service.js +214 -0
  152. package/dist/modules/lists/service.js.map +1 -0
  153. package/dist/modules/lists/tools.d.ts +4 -0
  154. package/dist/modules/lists/tools.d.ts.map +1 -0
  155. package/dist/modules/lists/tools.js +86 -0
  156. package/dist/modules/lists/tools.js.map +1 -0
  157. package/dist/modules/people/repository.d.ts +11 -0
  158. package/dist/modules/people/repository.d.ts.map +1 -1
  159. package/dist/modules/people/repository.js +16 -0
  160. package/dist/modules/people/repository.js.map +1 -1
  161. package/dist/modules/people/service.d.ts.map +1 -1
  162. package/dist/modules/people/service.js +18 -0
  163. package/dist/modules/people/service.js.map +1 -1
  164. package/dist/modules/positions/routes.d.ts.map +1 -1
  165. package/dist/modules/positions/routes.js +4 -2
  166. package/dist/modules/positions/routes.js.map +1 -1
  167. package/dist/modules/sample-data/fixture.d.ts +139 -0
  168. package/dist/modules/sample-data/fixture.d.ts.map +1 -0
  169. package/dist/modules/sample-data/fixture.js +461 -0
  170. package/dist/modules/sample-data/fixture.js.map +1 -0
  171. package/dist/modules/sample-data/index.d.ts +11 -0
  172. package/dist/modules/sample-data/index.d.ts.map +1 -0
  173. package/dist/modules/sample-data/index.js +43 -0
  174. package/dist/modules/sample-data/index.js.map +1 -0
  175. package/dist/modules/sample-data/routes.d.ts +15 -0
  176. package/dist/modules/sample-data/routes.d.ts.map +1 -0
  177. package/dist/modules/sample-data/routes.js +24 -0
  178. package/dist/modules/sample-data/routes.js.map +1 -0
  179. package/dist/modules/sample-data/service.d.ts +38 -0
  180. package/dist/modules/sample-data/service.d.ts.map +1 -0
  181. package/dist/modules/sample-data/service.js +412 -0
  182. package/dist/modules/sample-data/service.js.map +1 -0
  183. package/dist/modules/sample-data/tools.d.ts +4 -0
  184. package/dist/modules/sample-data/tools.d.ts.map +1 -0
  185. package/dist/modules/sample-data/tools.js +22 -0
  186. package/dist/modules/sample-data/tools.js.map +1 -0
  187. package/dist/modules/smtp-email/index.d.ts +1 -0
  188. package/dist/modules/smtp-email/index.d.ts.map +1 -1
  189. package/dist/modules/smtp-email/index.js +1 -0
  190. package/dist/modules/smtp-email/index.js.map +1 -1
  191. package/dist/modules/workspace/service.d.ts.map +1 -1
  192. package/dist/modules/workspace/service.js +10 -1
  193. package/dist/modules/workspace/service.js.map +1 -1
  194. package/dist/schema/index.d.ts +1 -0
  195. package/dist/schema/index.d.ts.map +1 -1
  196. package/dist/schema/index.js +1 -0
  197. package/dist/schema/index.js.map +1 -1
  198. package/dist/testing/app.d.ts +5 -1
  199. package/dist/testing/app.d.ts.map +1 -1
  200. package/dist/testing/app.js +2 -0
  201. package/dist/testing/app.js.map +1 -1
  202. package/migrations/0019_sharp_red_skull.sql +4 -0
  203. package/migrations/0020_early_nemesis.sql +2 -0
  204. package/migrations/0021_certain_maggott.sql +30 -0
  205. package/migrations/0022_stormy_manta.sql +1 -0
  206. package/migrations/0023_funny_maddog.sql +1 -0
  207. package/migrations/meta/0019_snapshot.json +5138 -0
  208. package/migrations/meta/0020_snapshot.json +5138 -0
  209. package/migrations/meta/0021_snapshot.json +5389 -0
  210. package/migrations/meta/0022_snapshot.json +5396 -0
  211. package/migrations/meta/0023_snapshot.json +5403 -0
  212. package/migrations/meta/_journal.json +35 -0
  213. package/package.json +3 -2
  214. package/src/app.ts +26 -0
  215. package/src/boot.ts +89 -0
  216. package/src/index.ts +8 -0
  217. package/src/lib/config.ts +9 -0
  218. package/src/lib/consumerEmailDomains.ts +62 -0
  219. package/src/lib/email.ts +7 -0
  220. package/src/lib/emailContent.ts +91 -0
  221. package/src/lib/emailDomainAutoLink.ts +165 -0
  222. package/src/lib/ids.ts +2 -0
  223. package/src/lib/kelpieConfigFile.ts +8 -0
  224. package/src/modules/attachedRecords.ts +9 -2
  225. package/src/modules/auth/preferences.ts +13 -1
  226. package/src/modules/auth/routes.ts +22 -0
  227. package/src/modules/auth/schema.ts +12 -2
  228. package/src/modules/auth/service.ts +37 -3
  229. package/src/modules/companies/repository.ts +3 -0
  230. package/src/modules/companies/routes.ts +13 -0
  231. package/src/modules/companies/schema.ts +5 -1
  232. package/src/modules/companies/service.ts +25 -0
  233. package/src/modules/core.ts +11 -0
  234. package/src/modules/email-domain-linker/index.ts +40 -0
  235. package/src/modules/email-domain-linker/routes.ts +40 -0
  236. package/src/modules/email-domain-linker/service.ts +122 -0
  237. package/src/modules/eventCatalogs.ts +36 -0
  238. package/src/modules/import-export/drafts.ts +27 -0
  239. package/src/modules/import-export/exportRows.ts +10 -1
  240. package/src/modules/import-export/plan.ts +107 -5
  241. package/src/modules/import-export/presets.ts +25 -0
  242. package/src/modules/import-export/repository.ts +35 -1
  243. package/src/modules/import-export/routes.ts +5 -0
  244. package/src/modules/import-export/schema.ts +13 -0
  245. package/src/modules/import-export/service.ts +70 -4
  246. package/src/modules/import-export/tools.ts +9 -0
  247. package/src/modules/import-export/writes.ts +116 -3
  248. package/src/modules/lists/events.ts +41 -0
  249. package/src/modules/lists/index.ts +41 -0
  250. package/src/modules/lists/repository.ts +313 -0
  251. package/src/modules/lists/routes.ts +247 -0
  252. package/src/modules/lists/schema.ts +85 -0
  253. package/src/modules/lists/service.ts +404 -0
  254. package/src/modules/lists/tools.ts +128 -0
  255. package/src/modules/people/repository.ts +26 -0
  256. package/src/modules/people/service.ts +20 -0
  257. package/src/modules/positions/routes.ts +4 -2
  258. package/src/modules/sample-data/fixture.ts +627 -0
  259. package/src/modules/sample-data/index.ts +50 -0
  260. package/src/modules/sample-data/routes.ts +49 -0
  261. package/src/modules/sample-data/service.ts +590 -0
  262. package/src/modules/sample-data/tools.ts +32 -0
  263. package/src/modules/smtp-email/index.ts +8 -1
  264. package/src/modules/workspace/service.ts +13 -1
  265. package/src/schema/index.ts +1 -0
  266. package/src/testing/app.ts +7 -1
@@ -11,7 +11,7 @@ import * as dealRepository from '../deals/repository.ts'
11
11
  import * as peopleRepository from '../people/repository.ts'
12
12
  import * as positionRepository from '../positions/repository.ts'
13
13
  import { IMPORT_DEAL_CURRENCY, NEW_COMPANY_DEFAULTS, NEW_PERSON_DEFAULTS } from './drafts.ts'
14
- import type { ImportWrite, RowPlan } from './plan.ts'
14
+ import type { ImportWrite, PlannedAffiliation, RowPlan } from './plan.ts'
15
15
 
16
16
  /**
17
17
  * Applying one planned row: the insert or update, and the timeline entry that
@@ -34,6 +34,21 @@ export interface WriteDependencies {
34
34
  readonly sourceName: string
35
35
  }
36
36
 
37
+ /**
38
+ * A record a row wrote besides its primary one.
39
+ *
40
+ * A People row with an affiliation makes a Position, and under
41
+ * `on_missing_company: create` a Company as well. Each needs its own module
42
+ * event, which the service emits from these.
43
+ */
44
+ export interface SideRecord {
45
+ readonly objectType: RecordObjectType
46
+ readonly recordId: string
47
+ readonly created: boolean
48
+ /** The columns an update moved. Empty on a create. */
49
+ readonly changedFields: readonly string[]
50
+ }
51
+
37
52
  export interface WriteOutcome {
38
53
  readonly recordId: string
39
54
  readonly objectType: RecordObjectType
@@ -43,6 +58,8 @@ export interface WriteOutcome {
43
58
  * nothing at all.
44
59
  */
45
60
  readonly changedFields: readonly string[]
61
+ /** Records the row wrote besides its primary one: a People affiliation's company and position. */
62
+ readonly sideRecords?: readonly SideRecord[]
46
63
  }
47
64
 
48
65
  /** A create or update plan. `skip` and `error` never reach here. */
@@ -102,6 +119,92 @@ async function writeCompany(
102
119
  return { recordId: id, objectType: 'company', changedFields }
103
120
  }
104
121
 
122
+ /**
123
+ * Writes the position a People row named, and the company it belongs to when the
124
+ * job was told to create one.
125
+ *
126
+ * The company is created before the position so the link has something to point
127
+ * at. The position is renamed in place when the person already holds one at that
128
+ * company, and created otherwise, which is the two-column-key behaviour the
129
+ * People affiliation chose. Files the `created Company` and `linked to company`
130
+ * activities as it goes, the same sentences the Companies and Positions imports
131
+ * write.
132
+ */
133
+ async function applyAffiliation(
134
+ dependencies: WriteDependencies,
135
+ personId: string,
136
+ affiliation: PlannedAffiliation,
137
+ ): Promise<readonly SideRecord[]> {
138
+ const { tx, workspaceId, actor, sourceName } = dependencies
139
+ const sideRecords: SideRecord[] = []
140
+
141
+ const companyId = await (async (): Promise<string> => {
142
+ if (affiliation.kind === 'link') {
143
+ return affiliation.companyId
144
+ }
145
+
146
+ const created = await companyRepository.insertCompany(tx, {
147
+ ...NEW_COMPANY_DEFAULTS,
148
+ ...affiliation.company,
149
+ tags: affiliation.company.tags === undefined ? undefined : [...affiliation.company.tags],
150
+ id: dependencies.createId('company'),
151
+ workspaceId,
152
+ name: affiliation.company.name ?? '',
153
+ })
154
+
155
+ sideRecords.push({ objectType: 'company', recordId: created.id, created: true, changedFields: [] })
156
+ await dependencies.recordActivity(tx, workspaceId, actor, {
157
+ targetType: 'company',
158
+ targetId: created.id,
159
+ kind: 'created',
160
+ ...describeCreationVia('Company', sourceName),
161
+ })
162
+
163
+ return created.id
164
+ })()
165
+
166
+ const held = await positionRepository.listPositionsAt(tx, workspaceId, personId, companyId)
167
+ const current = held[0]
168
+
169
+ if (current === undefined) {
170
+ const created = await positionRepository.insertPosition(tx, {
171
+ id: dependencies.createId('position'),
172
+ workspaceId,
173
+ personId,
174
+ companyId,
175
+ title: affiliation.title,
176
+ })
177
+
178
+ sideRecords.push({ objectType: 'position', recordId: created.id, created: true, changedFields: [] })
179
+
180
+ for (const end of [
181
+ { targetType: 'person', targetId: personId, related: 'company' },
182
+ { targetType: 'company', targetId: companyId, related: 'person' },
183
+ ] as const) {
184
+ await dependencies.recordActivity(tx, workspaceId, actor, {
185
+ targetType: end.targetType,
186
+ targetId: end.targetId,
187
+ kind: 'linked',
188
+ ...describeLink(end.related, sourceName),
189
+ })
190
+ }
191
+
192
+ return sideRecords
193
+ }
194
+
195
+ const changedFields = changedKeys(current, { title: affiliation.title })
196
+
197
+ if (changedFields.length > 0) {
198
+ await positionRepository.updatePosition(tx, workspaceId, current.id, {
199
+ title: affiliation.title,
200
+ updatedAt: dependencies.now,
201
+ })
202
+ sideRecords.push({ objectType: 'position', recordId: current.id, created: false, changedFields })
203
+ }
204
+
205
+ return sideRecords
206
+ }
207
+
105
208
  async function writePerson(
106
209
  dependencies: WriteDependencies,
107
210
  plan: WritablePlan,
@@ -120,7 +223,12 @@ async function writePerson(
120
223
  name: write.draft.name ?? '',
121
224
  })
122
225
 
123
- return { recordId: created.id, objectType: 'person', changedFields: [] }
226
+ const sideRecords =
227
+ write.affiliation === undefined
228
+ ? []
229
+ : await applyAffiliation(dependencies, created.id, write.affiliation)
230
+
231
+ return { recordId: created.id, objectType: 'person', changedFields: [], sideRecords }
124
232
  }
125
233
 
126
234
  const id = requireTarget(plan)
@@ -144,7 +252,12 @@ async function writePerson(
144
252
  })
145
253
  }
146
254
 
147
- return { recordId: id, objectType: 'person', changedFields }
255
+ const sideRecords =
256
+ write.affiliation === undefined
257
+ ? []
258
+ : await applyAffiliation(dependencies, id, write.affiliation)
259
+
260
+ return { recordId: id, objectType: 'person', changedFields, sideRecords }
148
261
  }
149
262
 
150
263
  /**
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod'
2
+
3
+ import type { ModuleEventCatalog } from '../../runtime/module.ts'
4
+
5
+ /**
6
+ * Domain events published by the lists module.
7
+ *
8
+ * `target` on a `lists.list.*` event is the list itself. `lists.member.*` events
9
+ * name the record that changed membership, so a consumer watching records hears
10
+ * about a list touching it without a second lookup.
11
+ */
12
+
13
+ export const listsEvents = {
14
+ 'lists.list.created': z.object({}).strict(),
15
+ 'lists.list.updated': z.object({ changed: z.array(z.string()).readonly() }),
16
+ 'lists.list.deleted': z.object({}).strict(),
17
+ 'lists.member.added': z.object({ listId: z.string() }),
18
+ 'lists.member.removed': z.object({ listId: z.string() }),
19
+ } satisfies ModuleEventCatalog
20
+
21
+ export type ListCreatedData = Record<string, never>
22
+ export interface ListUpdatedData {
23
+ readonly changed: readonly string[]
24
+ }
25
+ export type ListDeletedData = Record<string, never>
26
+ export interface ListMemberAddedData {
27
+ readonly listId: string
28
+ }
29
+ export interface ListMemberRemovedData {
30
+ readonly listId: string
31
+ }
32
+
33
+ declare module '../../runtime/events.ts' {
34
+ interface KelpieEventMap {
35
+ 'lists.list.created': ListCreatedData
36
+ 'lists.list.updated': ListUpdatedData
37
+ 'lists.list.deleted': ListDeletedData
38
+ 'lists.member.added': ListMemberAddedData
39
+ 'lists.member.removed': ListMemberRemovedData
40
+ }
41
+ }
@@ -0,0 +1,41 @@
1
+ import type { KelpieModule } from '../../runtime/module.ts'
2
+ import { listsEvents } from './events.ts'
3
+ import { mountListsRoutes } from './routes.ts'
4
+ import * as schema from './schema.ts'
5
+ import { createListsService } from './service.ts'
6
+ import { registerListsTools } from './tools.ts'
7
+
8
+ /**
9
+ * Lists: named collections of records of one type.
10
+ *
11
+ * Requires `workspace` for the tenancy column. Every other record type is
12
+ * addressed polymorphically through `target_type` + `target_id`, so this module
13
+ * does not depend on the modules whose records it references.
14
+ */
15
+ export function createListsModule(migrationsDirectory: string): KelpieModule {
16
+ return {
17
+ id: 'lists',
18
+ requires: ['workspace'],
19
+ structural: true,
20
+ events: listsEvents,
21
+
22
+ register(context) {
23
+ const service = createListsService({
24
+ db: context.db,
25
+ transaction: context.transaction,
26
+ createId: context.createId,
27
+ now: context.now,
28
+ })
29
+
30
+ context.schema(schema, migrationsDirectory)
31
+
32
+ context.routes((router) => {
33
+ mountListsRoutes(router, { db: context.db, now: context.now, service })
34
+ })
35
+
36
+ registerListsTools(context.mcp, service)
37
+
38
+ return Promise.resolve()
39
+ },
40
+ }
41
+ }
@@ -0,0 +1,313 @@
1
+ import { and, eq, ilike, inArray, sql } from 'drizzle-orm'
2
+ import type { SQL } from 'drizzle-orm'
3
+
4
+ import { keysetCondition, orderByWindow, textSort, timestampSort } from '../../lib/pagination.ts'
5
+ import type { ListWindow, SortableFields } from '../../lib/pagination.ts'
6
+ import { containsPattern } from '../../lib/search.ts'
7
+ import type { Queryable } from '../../runtime/transaction.ts'
8
+ import type { RecordTargetType } from '../recordTargets.ts'
9
+ import { listMembers, lists } from './schema.ts'
10
+
11
+ export type ListRecord = typeof lists.$inferSelect
12
+ export type ListColumns = typeof lists.$inferInsert
13
+
14
+ export type ListMemberRecord = typeof listMembers.$inferSelect
15
+ export type ListMemberColumns = typeof listMembers.$inferInsert
16
+
17
+ /**
18
+ * The stored row with the rollup a UI index page needs. `member_count` is
19
+ * computed at read time rather than maintained on write so an unrelated bug
20
+ * cannot leave the count out of step with the actual rows.
21
+ */
22
+ export type ListWithCount = ListRecord & { readonly memberCount: number }
23
+
24
+ export const LIST_SORTS: SortableFields<ListWithCount> = {
25
+ name: textSort(lists.name, (list) => list.name),
26
+ created_at: timestampSort(lists.createdAt, (list) => list.createdAt),
27
+ updated_at: timestampSort(lists.updatedAt, (list) => list.updatedAt),
28
+ }
29
+
30
+ export const DEFAULT_LIST_SORT = '-updated_at'
31
+
32
+ export interface ListFilters {
33
+ readonly term?: string | undefined
34
+ readonly targetType?: RecordTargetType | undefined
35
+ }
36
+
37
+ function conditionsFor(workspaceId: string, filters: ListFilters): (SQL | undefined)[] {
38
+ const term = filters.term
39
+ const targetType = filters.targetType
40
+
41
+ return [
42
+ eq(lists.workspaceId, workspaceId),
43
+ term === undefined ? undefined : ilike(lists.name, containsPattern(term)),
44
+ targetType === undefined ? undefined : eq(lists.targetType, targetType),
45
+ ]
46
+ }
47
+
48
+ /**
49
+ * @returns Up to `window.fetchLimit` rows: one more than the page, so the
50
+ * caller can tell there is a next one. Each row carries the count of members
51
+ * attached to it.
52
+ */
53
+ export async function listLists(
54
+ db: Queryable,
55
+ workspaceId: string,
56
+ filters: ListFilters,
57
+ window: ListWindow<ListWithCount>,
58
+ ): Promise<ListWithCount[]> {
59
+ const rows = await db
60
+ .select()
61
+ .from(lists)
62
+ .where(and(...conditionsFor(workspaceId, filters), keysetCondition(window, lists.id)))
63
+ .orderBy(...orderByWindow(window, lists.id))
64
+ .limit(window.fetchLimit)
65
+
66
+ const counts = await countMembersFor(db, rows.map((row) => row.id))
67
+
68
+ return rows.map((row) => ({ ...row, memberCount: counts.get(row.id) ?? 0 }))
69
+ }
70
+
71
+ async function countMembersFor(
72
+ db: Queryable,
73
+ listIds: readonly string[],
74
+ ): Promise<ReadonlyMap<string, number>> {
75
+ if (listIds.length === 0) {
76
+ return new Map()
77
+ }
78
+
79
+ const rows = await db
80
+ .select({ listId: listMembers.listId, count: sql<number>`count(*)::int` })
81
+ .from(listMembers)
82
+ .where(inArray(listMembers.listId, [...listIds]))
83
+ .groupBy(listMembers.listId)
84
+
85
+ return new Map(rows.map((row) => [row.listId, Number(row.count)]))
86
+ }
87
+
88
+ export async function findList(
89
+ db: Queryable,
90
+ workspaceId: string,
91
+ id: string,
92
+ ): Promise<ListWithCount | undefined> {
93
+ const [found] = await db
94
+ .select()
95
+ .from(lists)
96
+ .where(and(eq(lists.workspaceId, workspaceId), eq(lists.id, id)))
97
+ .limit(1)
98
+
99
+ if (found === undefined) {
100
+ return undefined
101
+ }
102
+
103
+ return { ...found, memberCount: await countMembers(db, id) }
104
+ }
105
+
106
+ async function countMembers(db: Queryable, listId: string): Promise<number> {
107
+ const [row] = await db
108
+ .select({ count: sql<number>`count(*)::int` })
109
+ .from(listMembers)
110
+ .where(eq(listMembers.listId, listId))
111
+
112
+ return Number(row?.count ?? 0)
113
+ }
114
+
115
+ export async function insertList(db: Queryable, values: ListColumns): Promise<ListRecord> {
116
+ const [created] = await db.insert(lists).values(values).returning()
117
+
118
+ if (created === undefined) {
119
+ throw new Error(`Inserting list ${values.id} returned no row`)
120
+ }
121
+
122
+ return created
123
+ }
124
+
125
+ export async function updateList(
126
+ db: Queryable,
127
+ workspaceId: string,
128
+ id: string,
129
+ changes: Partial<ListColumns>,
130
+ ): Promise<ListRecord | undefined> {
131
+ const [updated] = await db
132
+ .update(lists)
133
+ .set(changes)
134
+ .where(and(eq(lists.workspaceId, workspaceId), eq(lists.id, id)))
135
+ .returning()
136
+
137
+ return updated
138
+ }
139
+
140
+ export async function deleteList(db: Queryable, workspaceId: string, id: string): Promise<number> {
141
+ const deleted = await db
142
+ .delete(lists)
143
+ .where(and(eq(lists.workspaceId, workspaceId), eq(lists.id, id)))
144
+ .returning({ id: lists.id })
145
+
146
+ return deleted.length
147
+ }
148
+
149
+ /** Members: no cursor pagination yet. A list's members render on one page today. */
150
+
151
+ export const LIST_MEMBER_SORTS: SortableFields<ListMemberRecord> = {
152
+ added_at: timestampSort(listMembers.addedAt, (row) => row.addedAt),
153
+ }
154
+
155
+ export const DEFAULT_LIST_MEMBER_SORT = '-added_at'
156
+
157
+ export interface ListMemberFilters {
158
+ readonly listId: string
159
+ readonly targetType?: RecordTargetType | undefined
160
+ }
161
+
162
+ function memberConditionsFor(
163
+ workspaceId: string,
164
+ filters: ListMemberFilters,
165
+ ): (SQL | undefined)[] {
166
+ const targetType = filters.targetType
167
+
168
+ return [
169
+ eq(listMembers.workspaceId, workspaceId),
170
+ eq(listMembers.listId, filters.listId),
171
+ targetType === undefined ? undefined : eq(listMembers.targetType, targetType),
172
+ ]
173
+ }
174
+
175
+ export function listListMembers(
176
+ db: Queryable,
177
+ workspaceId: string,
178
+ filters: ListMemberFilters,
179
+ window: ListWindow<ListMemberRecord>,
180
+ ): Promise<ListMemberRecord[]> {
181
+ return db
182
+ .select()
183
+ .from(listMembers)
184
+ .where(and(...memberConditionsFor(workspaceId, filters), keysetCondition(window, listMembers.id)))
185
+ .orderBy(...orderByWindow(window, listMembers.id))
186
+ .limit(window.fetchLimit)
187
+ }
188
+
189
+ export async function findListMember(
190
+ db: Queryable,
191
+ workspaceId: string,
192
+ listId: string,
193
+ id: string,
194
+ ): Promise<ListMemberRecord | undefined> {
195
+ const [found] = await db
196
+ .select()
197
+ .from(listMembers)
198
+ .where(
199
+ and(
200
+ eq(listMembers.workspaceId, workspaceId),
201
+ eq(listMembers.listId, listId),
202
+ eq(listMembers.id, id),
203
+ ),
204
+ )
205
+ .limit(1)
206
+
207
+ return found
208
+ }
209
+
210
+ export async function insertListMember(
211
+ db: Queryable,
212
+ values: ListMemberColumns,
213
+ ): Promise<ListMemberRecord> {
214
+ const [created] = await db.insert(listMembers).values(values).returning()
215
+
216
+ if (created === undefined) {
217
+ throw new Error(`Inserting list_member ${values.id} returned no row`)
218
+ }
219
+
220
+ return created
221
+ }
222
+
223
+ export async function deleteListMember(
224
+ db: Queryable,
225
+ workspaceId: string,
226
+ listId: string,
227
+ id: string,
228
+ ): Promise<number> {
229
+ const deleted = await db
230
+ .delete(listMembers)
231
+ .where(
232
+ and(
233
+ eq(listMembers.workspaceId, workspaceId),
234
+ eq(listMembers.listId, listId),
235
+ eq(listMembers.id, id),
236
+ ),
237
+ )
238
+ .returning({ id: listMembers.id })
239
+
240
+ return deleted.length
241
+ }
242
+
243
+ /** A membership row enriched with the list it points at. */
244
+ export interface MembershipWithList {
245
+ readonly id: string
246
+ readonly listId: string
247
+ readonly listName: string
248
+ readonly listTargetType: string
249
+ readonly targetType: string
250
+ readonly targetId: string
251
+ readonly addedAt: Date
252
+ }
253
+
254
+ /**
255
+ * "Which lists is this record on?"
256
+ *
257
+ * Ordered by list name so a record's Lists tab reads alphabetically and the
258
+ * order does not shift as memberships are added.
259
+ */
260
+ export async function listMembershipsForTarget(
261
+ db: Queryable,
262
+ workspaceId: string,
263
+ targetType: string,
264
+ targetId: string,
265
+ ): Promise<MembershipWithList[]> {
266
+ return db
267
+ .select({
268
+ id: listMembers.id,
269
+ listId: listMembers.listId,
270
+ listName: lists.name,
271
+ listTargetType: lists.targetType,
272
+ targetType: listMembers.targetType,
273
+ targetId: listMembers.targetId,
274
+ addedAt: listMembers.addedAt,
275
+ })
276
+ .from(listMembers)
277
+ .innerJoin(lists, eq(listMembers.listId, lists.id))
278
+ .where(
279
+ and(
280
+ eq(listMembers.workspaceId, workspaceId),
281
+ eq(listMembers.targetType, targetType),
282
+ eq(listMembers.targetId, targetId),
283
+ ),
284
+ )
285
+ .orderBy(lists.name)
286
+ }
287
+
288
+ /**
289
+ * Removes every list membership for one target.
290
+ *
291
+ * The target is polymorphic and carries no foreign key of its own, so nothing
292
+ * in the database deletes these when the target goes. The service that deletes
293
+ * the target calls this inside the same transaction.
294
+ */
295
+ export async function deleteMembershipsForTarget(
296
+ db: Queryable,
297
+ workspaceId: string,
298
+ targetType: string,
299
+ targetId: string,
300
+ ): Promise<number> {
301
+ const deleted = await db
302
+ .delete(listMembers)
303
+ .where(
304
+ and(
305
+ eq(listMembers.workspaceId, workspaceId),
306
+ eq(listMembers.targetType, targetType),
307
+ eq(listMembers.targetId, targetId),
308
+ ),
309
+ )
310
+ .returning({ id: listMembers.id })
311
+
312
+ return deleted.length
313
+ }