Skip to content

Commit a6a92a8

Browse files
authored
feat: add schema-aligned db types and test factory primitives (#4326)
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 6fb17af commit a6a92a8

58 files changed

Lines changed: 1603 additions & 356 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/src/api/public/v1/members/createMember.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { z } from 'zod'
33

44
import { captureApiChange, memberCreateAction, memberEditIdentitiesAction } from '@crowd/audit-logs'
55
import { getProperDisplayName } from '@crowd/common'
6-
import { insertManyMemberIdentities, createMember as insertMember } from '@crowd/data-access-layer'
6+
import { createMember as insertMember, insertMemberIdentities } from '@crowd/data-access-layer'
77
import { MemberIdentityType } from '@crowd/types'
88

99
import { optionsQx } from '@/database/sequelizeQueryExecutor'
@@ -49,7 +49,7 @@ export async function createMember(req: Request, res: Response): Promise<void> {
4949
manuallyCreated: true,
5050
})
5151

52-
const dbIdentities = await insertManyMemberIdentities(
52+
const dbIdentities = await insertMemberIdentities(
5353
tx,
5454
identities.map((identity) => ({
5555
...identity,

backend/src/api/public/v1/members/identities/createMemberIdentity.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
MemberField,
88
findMemberById,
99
findMemberIdentitiesByValue,
10-
createMemberIdentity as insertMemberIdentity,
10+
insertMemberIdentities,
1111
touchMemberUpdatedAt,
1212
updateMemberIdentity,
1313
} from '@crowd/data-access-layer'
@@ -69,20 +69,23 @@ export async function createMemberIdentity(req: Request, res: Response): Promise
6969
alreadyExisted = true
7070
result = exactMatch
7171
} else {
72-
result = await insertMemberIdentity(
72+
const [created] = await insertMemberIdentities(
7373
tx,
74-
{
75-
memberId,
76-
platform: data.platform,
77-
value: normalizedValue,
78-
type: data.type,
79-
source: data.source,
80-
verified: data.verified,
81-
verifiedBy: data.verifiedBy,
82-
},
74+
[
75+
{
76+
memberId,
77+
platform: data.platform,
78+
value: normalizedValue,
79+
type: data.type,
80+
source: data.source,
81+
verified: data.verified,
82+
verifiedBy: data.verifiedBy,
83+
},
84+
],
8385
true,
8486
true,
8587
)
88+
result = created
8689
}
8790

8891
// A verified identity confirms the same value for this member, so keep same-value

backend/src/api/public/v1/members/project-affiliations/patchProjectAffiliation.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,16 @@ export async function patchProjectAffiliation(req: Request, res: Response): Prom
8585
if (affiliations.length > 0) {
8686
await insertMemberSegmentAffiliations(
8787
tx,
88-
memberId,
89-
projectId,
9088
affiliations.map((a) => ({
89+
memberId,
90+
segmentId: projectId,
9191
organizationId: a.organizationId,
9292
dateStart: a.dateStart.toISOString(),
9393
dateEnd: a.dateEnd?.toISOString() ?? null,
94+
verified: true,
9495
verifiedBy: verifiedBy!,
9596
})),
97+
true,
9698
)
9799
}
98100
})

backend/src/database/repositories/member/memberAffiliationsRepository.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { OrganizationField, queryOrgs } from '@crowd/data-access-layer'
22
import {
33
deleteMemberSegmentAffiliations,
44
fetchMemberAffiliations,
5-
insertMemberAffiliations,
5+
insertMemberSegmentAffiliations,
66
} from '@crowd/data-access-layer/src/member_segment_affiliations'
77
import { fetchManySegments } from '@crowd/data-access-layer/src/segments'
88
import { IMemberAffiliation, IOrganization, SegmentData } from '@crowd/types'
@@ -89,8 +89,17 @@ class MemberAffiliationsRepository {
8989
await deleteMemberSegmentAffiliations(qx, { memberId })
9090

9191
if (data?.length > 0) {
92-
// Insert multiple member affiliations
93-
await insertMemberAffiliations(qx, memberId, data)
92+
await insertMemberSegmentAffiliations(
93+
qx,
94+
data.map((item) => ({
95+
memberId,
96+
segmentId: item.segmentId,
97+
organizationId: item.organizationId,
98+
dateStart: item.dateStart || null,
99+
dateEnd: item.dateEnd || null,
100+
})),
101+
true,
102+
)
94103
}
95104

96105
await SequelizeRepository.commitTransaction(transaction)

backend/src/database/repositories/memberRepository.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ import {
1818
import { BotDetectionService, CommonMemberService } from '@crowd/common_services'
1919
import {
2020
OrganizationField,
21-
createMemberIdentity,
2221
deleteMemberIdentities,
2322
deleteMemberIdentitiesByCombinations,
2423
findAlreadyExistingVerifiedIdentities,
2524
getLastActivitiesForMembers,
25+
insertMemberIdentities,
2626
queryActivityRelations,
2727
queryOrgs,
2828
updateVerifiedFlag,
@@ -33,7 +33,7 @@ import { addMemberNoMerge, removeMemberToMerge } from '@crowd/data-access-layer/
3333
import {
3434
deleteMemberSegmentAffiliations,
3535
findMemberAffiliations,
36-
insertMemberAffiliations,
36+
insertMemberSegmentAffiliations,
3737
} from '@crowd/data-access-layer/src/member_segment_affiliations'
3838
import {
3939
MemberField,
@@ -159,8 +159,9 @@ class MemberRepository {
159159
const subprojectIds = await getSegmentSubprojectIds(qx, currentSegments)
160160

161161
if (data.identities) {
162-
for (const i of data.identities as IMemberIdentity[]) {
163-
await createMemberIdentity(qx, {
162+
await insertMemberIdentities(
163+
qx,
164+
(data.identities as IMemberIdentity[]).map((i) => ({
164165
memberId: record.id,
165166
platform: i.platform,
166167
type: i.type,
@@ -169,15 +170,16 @@ class MemberRepository {
169170
integrationId: i.integrationId || null,
170171
verified: i.verified,
171172
source: i.source,
172-
})
173-
}
173+
})),
174+
)
174175
} else if (data.username) {
175176
const username: PlatformIdentities = mapUsernameToIdentities(data.username)
176177

178+
const identitiesToInsert = []
177179
for (const platform of Object.keys(username) as PlatformType[]) {
178180
const identities: any[] = username[platform]
179181
for (const identity of identities) {
180-
await createMemberIdentity(qx, {
182+
identitiesToInsert.push({
181183
memberId: record.id,
182184
platform,
183185
value: identity.value ? identity.value : identity.username,
@@ -189,6 +191,10 @@ class MemberRepository {
189191
})
190192
}
191193
}
194+
195+
if (identitiesToInsert.length > 0) {
196+
await insertMemberIdentities(qx, identitiesToInsert)
197+
}
192198
}
193199

194200
await includeMemberToSegments(qx, record.id, subprojectIds)
@@ -1049,8 +1055,9 @@ class MemberRepository {
10491055
}
10501056

10511057
if (data.identitiesToCreate && data.identitiesToCreate.length > 0) {
1052-
for (const i of data.identitiesToCreate) {
1053-
await createMemberIdentity(qx, {
1058+
await insertMemberIdentities(
1059+
qx,
1060+
data.identitiesToCreate.map((i) => ({
10541061
memberId: record.id,
10551062
platform: i.platform,
10561063
value: i.value,
@@ -1059,8 +1066,8 @@ class MemberRepository {
10591066
integrationId: i.integrationId || null,
10601067
verified: i.verified !== undefined ? i.verified : !!manualChange,
10611068
source: i.source,
1062-
})
1063-
}
1069+
})),
1070+
)
10641071
}
10651072

10661073
if (data.identitiesToUpdate && data.identitiesToUpdate.length > 0) {
@@ -1094,6 +1101,7 @@ class MemberRepository {
10941101
const platformsToDelete: string[] = []
10951102
const valuesToDelete: string[] = []
10961103
const typesToDelete: MemberIdentityType[] = []
1104+
const identitiesToInsert = []
10971105

10981106
for (const platform of platforms) {
10991107
const identities = data.username[platform]
@@ -1112,7 +1120,7 @@ class MemberRepository {
11121120
(identity.username && identity.username !== '') ||
11131121
(identity.value && identity.value !== '')
11141122
) {
1115-
await createMemberIdentity(qx, {
1123+
identitiesToInsert.push({
11161124
memberId: record.id,
11171125
platform,
11181126
value: identity.value ? identity.value : identity.username,
@@ -1126,6 +1134,10 @@ class MemberRepository {
11261134
}
11271135
}
11281136

1137+
if (identitiesToInsert.length > 0) {
1138+
await insertMemberIdentities(qx, identitiesToInsert)
1139+
}
1140+
11291141
if (platformsToDelete.length > 0) {
11301142
await deleteMemberIdentitiesByCombinations(qx, {
11311143
memberId: record.id,
@@ -1207,7 +1219,17 @@ class MemberRepository {
12071219
return
12081220
}
12091221

1210-
await insertMemberAffiliations(qx, memberId, data)
1222+
await insertMemberSegmentAffiliations(
1223+
qx,
1224+
data.map((item) => ({
1225+
memberId,
1226+
segmentId: item.segmentId,
1227+
organizationId: item.organizationId,
1228+
dateStart: item.dateStart || null,
1229+
dateEnd: item.dateEnd || null,
1230+
})),
1231+
true,
1232+
)
12111233
}),
12121234
)
12131235
}

backend/src/database/repositories/organizationRepository.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
IDbOrganization,
1717
OrgIdentityField,
1818
OrganizationField,
19-
addOrgIdentity,
2019
addOrgsToSegments,
2120
cleanUpOrgIdentities,
2221
cleanupForOganization,
@@ -27,6 +26,7 @@ import {
2726
findManyOrgAttributes,
2827
findOrgAttributes,
2928
findOrgById,
29+
insertOrganizationIdentities,
3030
markOrgAttributeDefault,
3131
queryOrgIdentities,
3232
updateOrgIdentityVerifiedFlag,
@@ -619,16 +619,22 @@ class OrganizationRepository {
619619
): Promise<void> {
620620
const qx = SequelizeRepository.getQueryExecutor(options)
621621

622-
await addOrgIdentity(qx, {
623-
organizationId,
624-
platform: identity.platform,
625-
source: identity.source,
626-
sourceId: identity.sourceId || null,
627-
value: identity.value,
628-
type: identity.type,
629-
verified: identity.verified,
630-
integrationId: identity.integrationId || null,
631-
})
622+
await insertOrganizationIdentities(
623+
qx,
624+
[
625+
{
626+
organizationId,
627+
platform: identity.platform,
628+
source: identity.source,
629+
sourceId: identity.sourceId || null,
630+
value: identity.value,
631+
type: identity.type,
632+
verified: identity.verified,
633+
integrationId: identity.integrationId || null,
634+
},
635+
],
636+
false,
637+
)
632638
}
633639

634640
static async getIdentities(

backend/src/services/member/memberIdentityService.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import lodash from 'lodash'
33

44
import { captureApiChange, memberEditIdentitiesAction } from '@crowd/audit-logs'
55
import { Error404, Error409 } from '@crowd/common'
6-
import { createMemberIdentity, findIdentitiesForMembers } from '@crowd/data-access-layer'
6+
import { findIdentitiesForMembers, insertMemberIdentities } from '@crowd/data-access-layer'
77
import {
88
deleteMemberIdentity,
99
fetchMemberIdentities,
@@ -77,7 +77,7 @@ export default class MemberIdentityService extends LoggerBase {
7777
}
7878

7979
// Create member identity
80-
await createMemberIdentity(qx, { ...data, memberId })
80+
await insertMemberIdentities(qx, [{ ...data, memberId }])
8181

8282
await touchMemberUpdatedAt(qx, memberId)
8383

@@ -151,9 +151,10 @@ export default class MemberIdentityService extends LoggerBase {
151151
}
152152

153153
// Create member identities
154-
for (const identity of data) {
155-
await createMemberIdentity(qx, { ...identity, memberId })
156-
}
154+
await insertMemberIdentities(
155+
qx,
156+
data.map((identity) => ({ ...identity, memberId })),
157+
)
157158

158159
await touchMemberUpdatedAt(qx, memberId)
159160

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# ADR-0006: Database schema types as the source of truth
2+
3+
**Date**: 2026-07-09
4+
**Status**: accepted
5+
**Deciders**: Yeganathan S
6+
7+
## Context
8+
9+
Working with entity types in CDP is messy. The same concepts appear as overlapping shapes across `@crowd/types`, DAL inputs, merge helpers, and API/domain models — often incomplete relative to the database, with inconsistent nullability and no single source of truth. That makes it hard to change data access safely, reuse types across packages, and keep tests aligned with production.
10+
11+
We need a durable way to model persisted data that the rest of the codebase can build on, without inventing yet another parallel type family per feature.
12+
13+
## Decision
14+
15+
Treat the database schema as the source of truth for persisted entity shapes. Introduce a dedicated `db/` area under `@crowd/types` for schema-aligned row types, and derive narrower types (create/update payloads, factory options, domain views) from those via composition (`Pick`, `Partial`, `Omit`, intersections) rather than hand-maintaining duplicates.
16+
17+
These types live in `@crowd/types`, not in the data-access layer: they are shared vocabulary for backend, workers, libraries, and test utilities. DAL remains responsible for queries and persistence; it consumes and returns these types instead of owning the canonical shapes.
18+
19+
This applies to entities generally (members, identities, organizations, and others as we touch them), not only the first tables we migrate.
20+
21+
## Conventions
22+
23+
These rules exist so people (and tools) do not confuse the row type with write payloads, or nullability with “field may be omitted.”
24+
25+
1. **One row type per table** — describes the entity as stored. Field nullability matches the schema. Database defaults do not make read fields optional.
26+
2. **Write payloads are derived from the row type** — use `Pick`, `Partial`, `Omit`, and intersections instead of duplicating the full interface.
27+
3. **Nullability ≠ optionality**`| null` means the column can store null; `?` / `Partial` means the caller may omit the key. Both can appear; they mean different things.
28+
4. **System-owned fields stay off write payloads** — things like tenant id, audit timestamps, and soft-delete markers are set by persistence code, not by every caller.
29+
5. **Layers that supply defaults may widen further** — e.g. a test factory can take a partial write payload and fill required fields before calling DAL. That does not change the row type or the DAL contract.
30+
31+
## Alternatives Considered
32+
33+
### Alternative 1: Keep defining types ad hoc next to each feature
34+
- **Pros**: Fast locally; no cross-package coordination.
35+
- **Cons**: Duplicates and drift continue; nullability and field sets disagree across call sites.
36+
- **Why not**: That is the current pain. It does not scale as more packages share the same entities.
37+
38+
### Alternative 2: Own canonical entity types inside `@crowd/data-access-layer`
39+
- **Pros**: Types sit next to SQL; easy for DAL authors to update.
40+
- **Cons**: Anything that needs a row shape (test-kit, common services, workers, backend) must depend on DAL or re-copy types.
41+
- **Why not**: Row shapes are shared contracts, not DAL implementation details. Putting them in `@crowd/types` keeps the dependency graph thin and matches how other shared models already live.
42+
43+
### Alternative 3: Generate types from the schema (e.g. introspection tooling)
44+
- **Pros**: Always in sync with migrations; less manual work.
45+
- **Cons**: Tooling and review process are not in place; generated output can be noisy and hard to evolve with domain naming.
46+
- **Why not**: Manual schema-aligned types are enough to establish the pattern now. Generation can be revisited once the convention is stable.
47+
48+
## Consequences
49+
50+
### Positive
51+
- One place to look for “what does this table look like in TypeScript.”
52+
- Downstream APIs and tests extend from the same base instead of inventing parallel models.
53+
- Clearer package boundaries: `@crowd/types` for shapes, DAL for access, factories/services for composition.
54+
55+
### Negative
56+
- Existing aliases and legacy shapes (`MemberRow`, feature-local inputs, etc.) will coexist during migration.
57+
- Authors must update `db/` types when migrations change columns or nullability.
58+
59+
### Risks
60+
- **Partial adoption** — mitigated by migrating types when a table is touched (factories, DAL hardening, new features), not a big-bang rewrite.
61+
- **Drift from schema** — mitigated by checking live schema (or migrations) when adding or changing `db/` types; optional codegen later.

0 commit comments

Comments
 (0)