@kelpie/server 0.6.0 → 0.7.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 (88) 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/kelpieConfigFile.d.ts +6 -0
  18. package/dist/lib/kelpieConfigFile.d.ts.map +1 -1
  19. package/dist/lib/kelpieConfigFile.js +1 -0
  20. package/dist/lib/kelpieConfigFile.js.map +1 -1
  21. package/dist/modules/eventCatalogs.d.ts +35 -0
  22. package/dist/modules/eventCatalogs.d.ts.map +1 -0
  23. package/dist/modules/eventCatalogs.js +35 -0
  24. package/dist/modules/eventCatalogs.js.map +1 -0
  25. package/dist/modules/import-export/drafts.d.ts +9 -0
  26. package/dist/modules/import-export/drafts.d.ts.map +1 -1
  27. package/dist/modules/import-export/drafts.js +25 -0
  28. package/dist/modules/import-export/drafts.js.map +1 -1
  29. package/dist/modules/import-export/exportRows.d.ts +9 -0
  30. package/dist/modules/import-export/exportRows.d.ts.map +1 -1
  31. package/dist/modules/import-export/exportRows.js +10 -1
  32. package/dist/modules/import-export/exportRows.js.map +1 -1
  33. package/dist/modules/import-export/plan.d.ts +23 -1
  34. package/dist/modules/import-export/plan.d.ts.map +1 -1
  35. package/dist/modules/import-export/plan.js +62 -3
  36. package/dist/modules/import-export/plan.js.map +1 -1
  37. package/dist/modules/import-export/presets.d.ts.map +1 -1
  38. package/dist/modules/import-export/presets.js +25 -0
  39. package/dist/modules/import-export/presets.js.map +1 -1
  40. package/dist/modules/import-export/repository.d.ts +17 -0
  41. package/dist/modules/import-export/repository.d.ts.map +1 -1
  42. package/dist/modules/import-export/repository.js +21 -1
  43. package/dist/modules/import-export/repository.js.map +1 -1
  44. package/dist/modules/import-export/routes.d.ts.map +1 -1
  45. package/dist/modules/import-export/routes.js +5 -1
  46. package/dist/modules/import-export/routes.js.map +1 -1
  47. package/dist/modules/import-export/schema.d.ts +55 -0
  48. package/dist/modules/import-export/schema.d.ts.map +1 -1
  49. package/dist/modules/import-export/schema.js +13 -1
  50. package/dist/modules/import-export/schema.js.map +1 -1
  51. package/dist/modules/import-export/service.d.ts +4 -1
  52. package/dist/modules/import-export/service.d.ts.map +1 -1
  53. package/dist/modules/import-export/service.js +51 -3
  54. package/dist/modules/import-export/service.js.map +1 -1
  55. package/dist/modules/import-export/tools.d.ts.map +1 -1
  56. package/dist/modules/import-export/tools.js +7 -1
  57. package/dist/modules/import-export/tools.js.map +1 -1
  58. package/dist/modules/import-export/writes.d.ts +16 -0
  59. package/dist/modules/import-export/writes.d.ts.map +1 -1
  60. package/dist/modules/import-export/writes.js +77 -2
  61. package/dist/modules/import-export/writes.js.map +1 -1
  62. package/dist/testing/app.d.ts +5 -1
  63. package/dist/testing/app.d.ts.map +1 -1
  64. package/dist/testing/app.js +2 -0
  65. package/dist/testing/app.js.map +1 -1
  66. package/migrations/0019_sharp_red_skull.sql +4 -0
  67. package/migrations/0020_early_nemesis.sql +2 -0
  68. package/migrations/meta/0019_snapshot.json +5138 -0
  69. package/migrations/meta/0020_snapshot.json +5138 -0
  70. package/migrations/meta/_journal.json +14 -0
  71. package/package.json +2 -2
  72. package/src/app.ts +26 -0
  73. package/src/boot.ts +89 -0
  74. package/src/index.ts +8 -0
  75. package/src/lib/config.ts +9 -0
  76. package/src/lib/kelpieConfigFile.ts +8 -0
  77. package/src/modules/eventCatalogs.ts +35 -0
  78. package/src/modules/import-export/drafts.ts +27 -0
  79. package/src/modules/import-export/exportRows.ts +10 -1
  80. package/src/modules/import-export/plan.ts +107 -5
  81. package/src/modules/import-export/presets.ts +25 -0
  82. package/src/modules/import-export/repository.ts +35 -1
  83. package/src/modules/import-export/routes.ts +5 -0
  84. package/src/modules/import-export/schema.ts +13 -0
  85. package/src/modules/import-export/service.ts +70 -4
  86. package/src/modules/import-export/tools.ts +9 -0
  87. package/src/modules/import-export/writes.ts +116 -3
  88. package/src/testing/app.ts +7 -1
@@ -4,6 +4,7 @@ import {
4
4
  IMPORT_OBJECTS,
5
5
  IMPORT_ROW_ACTIONS,
6
6
  IMPORT_SOURCES,
7
+ ON_MISSING_COMPANY,
7
8
  } from '@kelpie/schemas'
8
9
  import type {
9
10
  ImportColumnMap,
@@ -68,12 +69,21 @@ export const importJobs = pgTable(
68
69
  object: text('object').notNull(),
69
70
  status: text('status').notNull(),
70
71
  conflictMode: text('conflict_mode').notNull(),
72
+ /**
73
+ * What a People import does with a row naming a company that is not here
74
+ * yet: `skip` leaves the affiliation unlinked and warns, `create` invents
75
+ * the company. Defaulted so a job predating this reads as `skip`, which is
76
+ * the behaviour every other object already has.
77
+ */
78
+ onMissingCompany: text('on_missing_company').notNull().default('skip'),
71
79
  matchKey: text('match_key').notNull(),
72
80
  columnMap: jsonb('column_map').$type<ImportColumnMap>().notNull().default({}),
73
81
  sourceHeaders: jsonb('source_headers').$type<readonly string[]>().notNull().default([]),
74
82
  counts: jsonb('counts').$type<ImportCounts>().notNull(),
75
83
  /** The first `IMPORT_REPORTED_ERRORS` failing rows. `counts.error` is the true number. */
76
84
  errors: jsonb('errors').$type<readonly ImportRowError[]>().notNull().default([]),
85
+ /** The first `IMPORT_REPORTED_ERRORS` non-fatal notes, e.g. a person imported with the position skipped. */
86
+ warnings: jsonb('warnings').$type<readonly ImportRowError[]>().notNull().default([]),
77
87
  /** The first `IMPORT_PREVIEW_ROWS` rows, mapped as Kelpie read them. */
78
88
  preview: jsonb('preview').$type<readonly ImportPreviewRow[]>().notNull().default([]),
79
89
  /**
@@ -97,6 +107,7 @@ export const importJobs = pgTable(
97
107
  checkOneOf('import_jobs_source_check', table.source, IMPORT_SOURCES),
98
108
  checkOneOf('import_jobs_object_check', table.object, IMPORT_OBJECTS),
99
109
  checkOneOf('import_jobs_conflict_mode_check', table.conflictMode, IMPORT_CONFLICT_MODES),
110
+ checkOneOf('import_jobs_on_missing_company_check', table.onMissingCompany, ON_MISSING_COMPANY),
100
111
  checkOneOf('import_jobs_status_check', table.status, IMPORT_JOB_STATUSES),
101
112
  ],
102
113
  )
@@ -128,6 +139,8 @@ export const importJobRows = pgTable(
128
139
  values: jsonb('values').$type<Readonly<Record<string, string>>>().notNull().default({}),
129
140
  action: text('action').notNull(),
130
141
  errors: jsonb('errors').$type<readonly StoredRowError[]>().notNull().default([]),
142
+ /** Non-fatal notes about a row that was applied anyway, e.g. an unlinked position. */
143
+ warnings: jsonb('warnings').$type<readonly StoredRowError[]>().notNull().default([]),
131
144
  createdAt: createdAt(),
132
145
  updatedAt: updatedAt(),
133
146
  },
@@ -17,6 +17,7 @@ import type {
17
17
  ImportRowError,
18
18
  ImportSource,
19
19
  MatchKeyOption,
20
+ OnMissingCompany,
20
21
  } from '@kelpie/schemas'
21
22
 
22
23
  import type { Database } from '../../lib/database.ts'
@@ -142,12 +143,14 @@ export interface ImportJobView {
142
143
  readonly object: ImportObject
143
144
  readonly status: string
144
145
  readonly conflictMode: ImportConflictMode
146
+ readonly onMissingCompany: OnMissingCompany
145
147
  readonly matchKey: string
146
148
  readonly columnMap: ImportColumnMap
147
149
  readonly sourceHeaders: readonly string[]
148
150
  readonly fileName: string | null
149
151
  readonly counts: ImportCounts
150
152
  readonly errors: readonly ImportRowError[]
153
+ readonly warnings: readonly ImportRowError[]
151
154
  readonly preview: readonly ImportPreviewRow[]
152
155
  readonly createdAt: Date
153
156
  readonly updatedAt: Date
@@ -157,6 +160,7 @@ export interface CreateImportJobInput {
157
160
  readonly source: ImportSource
158
161
  readonly object: ImportObject
159
162
  readonly conflictMode: ImportConflictMode
163
+ readonly onMissingCompany: OnMissingCompany
160
164
  readonly matchKeyId: string
161
165
  /** Absent means "derive one from the source preset and the file's own headers". */
162
166
  readonly columnMap: ImportColumnMap | undefined
@@ -213,6 +217,7 @@ function toView(job: ImportJobRecord): ImportJobView {
213
217
  source: rest.source as ImportSource,
214
218
  object: rest.object as ImportObject,
215
219
  conflictMode: rest.conflictMode as ImportConflictMode,
220
+ onMissingCompany: rest.onMissingCompany as OnMissingCompany,
216
221
  }
217
222
  }
218
223
 
@@ -358,11 +363,37 @@ export function createImportExportService(
358
363
  ): Promise<ImportLookups> {
359
364
  const existing = await findExistingKeys(db, workspaceId, object, matchKey, rows)
360
365
 
361
- if (object === 'companies' || object === 'people') {
366
+ if (object === 'companies') {
362
367
  return {
363
368
  existing,
364
369
  personIdByEmail: new Map(),
365
370
  companyIdByDomain: new Map(),
371
+ companyIdByName: new Map(),
372
+ memberIdByEmail: new Map(),
373
+ dealStageIdByName: new Map(),
374
+ }
375
+ }
376
+
377
+ // People only need companies, and only when a row carries an affiliation.
378
+ // Both a domain map and a name map, because a row may identify its company
379
+ // either way.
380
+ if (object === 'people') {
381
+ const domains = distinct(rows.map((row) => normaliseDomain(row.mapped.company_domain ?? '')))
382
+ const names = distinct(
383
+ rows.map((row) => (row.mapped.company_name ?? '').trim().toLowerCase() || null),
384
+ )
385
+ const [byDomain, byName] = await Promise.all([
386
+ repository.findCompanyIdsByDomain(db, workspaceId, domains),
387
+ repository.findCompanyIdsByName(db, workspaceId, names),
388
+ ])
389
+
390
+ return {
391
+ existing,
392
+ personIdByEmail: new Map(),
393
+ companyIdByDomain: new Map(
394
+ byDomain.flatMap((row) => (row.domain === null ? [] : [[row.domain, row.id] as const])),
395
+ ),
396
+ companyIdByName: new Map(byName.map((row) => [row.name.toLowerCase(), row.id] as const)),
366
397
  memberIdByEmail: new Map(),
367
398
  dealStageIdByName: new Map(),
368
399
  }
@@ -389,6 +420,7 @@ export function createImportExportService(
389
420
  companyIdByDomain: new Map(
390
421
  companyRows.flatMap((row) => (row.domain === null ? [] : [[row.domain, row.id] as const])),
391
422
  ),
423
+ companyIdByName: new Map(),
392
424
  memberIdByEmail: new Map(),
393
425
  dealStageIdByName: new Map(),
394
426
  }
@@ -416,6 +448,7 @@ export function createImportExportService(
416
448
  companyIdByDomain: new Map(
417
449
  companyRows.flatMap((row) => (row.domain === null ? [] : [[row.domain, row.id] as const])),
418
450
  ),
451
+ companyIdByName: new Map(),
419
452
  memberIdByEmail: new Map(members.map((member) => [member.email.toLowerCase(), member.id])),
420
453
  dealStageIdByName: stagesByName,
421
454
  }
@@ -426,6 +459,7 @@ export function createImportExportService(
426
459
  object: job.object as ImportObject,
427
460
  matchKey: requireMatchKey(job.object as ImportObject, job.matchKey),
428
461
  conflictMode: job.conflictMode as ImportConflictMode,
462
+ onMissingCompany: job.onMissingCompany as OnMissingCompany,
429
463
  lookups,
430
464
  }
431
465
  }
@@ -469,10 +503,13 @@ export function createImportExportService(
469
503
  }
470
504
 
471
505
  function toOutcome(planned: PlannedRow): repository.RowOutcome {
506
+ const { plan } = planned
507
+
472
508
  return {
473
509
  rowNumber: planned.row,
474
- action: planned.plan.action,
475
- errors: planned.plan.action === 'error' ? planned.plan.errors : [],
510
+ action: plan.action,
511
+ errors: plan.action === 'error' ? plan.errors : [],
512
+ warnings: plan.action === 'create' || plan.action === 'update' ? (plan.warnings ?? []) : [],
476
513
  }
477
514
  }
478
515
 
@@ -486,7 +523,11 @@ export function createImportExportService(
486
523
  function reportOf(
487
524
  outcomes: readonly repository.RowOutcome[],
488
525
  mapped: readonly MappedRow[],
489
- ): { errors: readonly ImportRowError[]; preview: readonly ImportPreviewRow[] } {
526
+ ): {
527
+ errors: readonly ImportRowError[]
528
+ warnings: readonly ImportRowError[]
529
+ preview: readonly ImportPreviewRow[]
530
+ } {
490
531
  const byRow = new Map(mapped.map((row) => [row.row, row.mapped]))
491
532
 
492
533
  return {
@@ -500,6 +541,16 @@ export function createImportExportService(
500
541
  message: problem.message,
501
542
  })),
502
543
  ),
544
+ warnings: outcomes
545
+ .filter((outcome) => outcome.warnings.length > 0)
546
+ .slice(0, IMPORT_REPORTED_ERRORS)
547
+ .flatMap((outcome) =>
548
+ outcome.warnings.map((problem) => ({
549
+ row: outcome.rowNumber,
550
+ field: problem.field,
551
+ message: problem.message,
552
+ })),
553
+ ),
503
554
  preview: outcomes.slice(0, IMPORT_PREVIEW_ROWS).map((outcome) => ({
504
555
  row: outcome.rowNumber,
505
556
  action: outcome.action,
@@ -601,6 +652,8 @@ export function createImportExportService(
601
652
  rowNumber: line.number,
602
653
  action: plan.action,
603
654
  errors: plan.action === 'error' ? plan.errors : [],
655
+ warnings:
656
+ plan.action === 'create' || plan.action === 'update' ? (plan.warnings ?? []) : [],
604
657
  }
605
658
 
606
659
  if (plan.action !== 'error' && plan.action !== 'skip') {
@@ -629,6 +682,16 @@ export function createImportExportService(
629
682
  written.changedFields,
630
683
  )
631
684
  }
685
+
686
+ // A People affiliation makes a Position, and maybe a Company. Each is its
687
+ // own record with its own module event, whatever the person row did.
688
+ for (const side of written.sideRecords ?? []) {
689
+ if (side.created) {
690
+ emitImportedRecordCreated(events, side.objectType, side.recordId)
691
+ } else if (side.changedFields.length > 0) {
692
+ emitImportedRecordUpdated(events, side.objectType, side.recordId, side.changedFields)
693
+ }
694
+ }
632
695
  }
633
696
 
634
697
  // In the same transaction as the record it wrote, so the account of the
@@ -682,6 +745,7 @@ export function createImportExportService(
682
745
  rowNumber: row.row,
683
746
  action: 'error' as const,
684
747
  errors: [{ field: '', message: describeThrown(error) }],
748
+ warnings: [],
685
749
  }
686
750
 
687
751
  // Its own statement: the transaction that would have carried this one
@@ -786,11 +850,13 @@ export function createImportExportService(
786
850
  object: input.object,
787
851
  status: 'pending',
788
852
  conflictMode: input.conflictMode,
853
+ onMissingCompany: input.onMissingCompany,
789
854
  matchKey: matchKey.id,
790
855
  columnMap,
791
856
  sourceHeaders: parsed.headers,
792
857
  counts: { ...EMPTY_COUNTS, total: parsed.rows.length },
793
858
  errors: [],
859
+ warnings: [],
794
860
  preview: [],
795
861
  fileSha256: fileDigest(input.csv),
796
862
  fileName: input.fileName,
@@ -3,6 +3,7 @@ import {
3
3
  IMPORT_OBJECTS,
4
4
  IMPORT_SOURCES,
5
5
  MAX_IMPORT_FILE_BYTES,
6
+ ON_MISSING_COMPANY,
6
7
  defaultMatchKeyId,
7
8
  } from '@kelpie/schemas'
8
9
  import { z } from 'zod'
@@ -59,6 +60,13 @@ const previewArgs = z.strictObject({
59
60
  .enum(IMPORT_CONFLICT_MODES)
60
61
  .default('skip')
61
62
  .describe('What to do with a row that matches an existing record.'),
63
+ on_missing_company: z
64
+ .enum(ON_MISSING_COMPANY)
65
+ .default('skip')
66
+ .describe(
67
+ 'People import only: what to do when a row names a company that is not here yet. ' +
68
+ "'skip' imports the person and leaves the position unlinked; 'create' invents the company.",
69
+ ),
62
70
  match_key: z.string().min(1).optional().describe('Which field decides that match.'),
63
71
  column_map: columnMapArg.optional(),
64
72
  file_name: z.string().min(1).nullable().default(null).describe('Recorded on the job, for the log.'),
@@ -124,6 +132,7 @@ export function registerImportExportTools(mcp: McpToolRegistry, service: ImportE
124
132
  source: args.source,
125
133
  object: args.object,
126
134
  conflictMode: args.conflict_mode,
135
+ onMissingCompany: args.on_missing_company,
127
136
  matchKeyId: args.match_key ?? defaultMatchKeyId(args.object),
128
137
  columnMap: args.column_map,
129
138
  fileName: args.file_name,
@@ -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
  /**
@@ -3,7 +3,7 @@ import type { Context, Hono } from 'hono'
3
3
  import { createApp } from '../app.ts'
4
4
  import type { AppBindings } from '../app.ts'
5
5
  import type { Actor } from '../lib/actor.ts'
6
- import type { Environment } from '../lib/config.ts'
6
+ import type { Environment, RuntimeMode } from '../lib/config.ts'
7
7
  import type { DatabaseProbe } from '../lib/database.ts'
8
8
  import { createCaptureTransport, createLogger } from '../lib/logger.ts'
9
9
  import { rateLimitConfigFrom, rateLimitConfigSchema } from '../lib/rateLimit.ts'
@@ -57,6 +57,10 @@ export interface TestAppOptions {
57
57
  readonly rateLimit?: RateLimitConfig
58
58
  /** Defaults to reading `X-Forwarded-For`, so a test can simulate distinct callers. */
59
59
  readonly resolveClientIp?: (context: Context) => string
60
+ /** Reported through `GET /v1/public/config`. Defaults to `'production'`. */
61
+ readonly runtimeMode?: RuntimeMode
62
+ /** Reported through `GET /v1/public/config`. Defaults to unset. */
63
+ readonly siteName?: string
60
64
  }
61
65
 
62
66
  export interface TestApp {
@@ -102,6 +106,8 @@ export async function createTestApp(options: TestAppOptions = {}): Promise<TestA
102
106
  createId: services.createId,
103
107
  rateLimit: options.rateLimit ?? DEFAULT_TEST_RATE_LIMIT,
104
108
  resolveClientIp: options.resolveClientIp ?? testClientIp,
109
+ ...(options.runtimeMode === undefined ? {} : { runtimeMode: options.runtimeMode }),
110
+ ...(options.siteName === undefined ? {} : { siteName: options.siteName }),
105
111
  })
106
112
 
107
113
  return { app, contributions, logLines, services }