Skip to content

Commit d15ad0b

Browse files
committed
feat(contract): carry junction namespaceId on the emitted through descriptor
Nothing constrains the source, target, and junction tables of an N:M relation to one namespace, so resolving the junction by bare table name is ambiguous under cross-namespace collisions (TML-2550). Mirror the FK-reference shape: the emitted through block now carries the junction's namespaceId (JSON schema + arktype validator + ContractRelationThrough + build-contract emission + contract.d.ts literal), and the orm-client resolver reads it from the contract — looking the junction up in its declared namespace — instead of re-deriving by name scan. Follows up wmadden's review on #678 (domain-types.ts 'Needs namespace'). Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent 4763690 commit d15ad0b

8 files changed

Lines changed: 42 additions & 11 deletions

File tree

packages/1-framework/0-foundation/contract/src/domain-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type ContractRelationOn = {
3232

3333
export type ContractRelationThrough = {
3434
readonly table: string;
35+
readonly namespaceId: string;
3536
readonly parentColumns: readonly string[];
3637
readonly childColumns: readonly string[];
3738
readonly targetColumns: readonly string[];

packages/1-framework/3-tooling/emitter/src/domain-type-generation.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,18 +130,26 @@ export function generateModelRelationsType(relations: Record<string, unknown>):
130130
const through = relObj['through'] as
131131
| {
132132
table?: string;
133+
namespaceId?: string;
133134
parentColumns?: string[];
134135
childColumns?: string[];
135136
targetColumns?: string[];
136137
}
137138
| undefined;
138-
if (through?.table && through.parentColumns && through.childColumns && through.targetColumns) {
139+
if (
140+
through?.table &&
141+
through.namespaceId &&
142+
through.parentColumns &&
143+
through.childColumns &&
144+
through.targetColumns
145+
) {
139146
const table = serializeValue(through.table);
147+
const namespaceId = serializeValue(through.namespaceId);
140148
const parentColumns = through.parentColumns.map((c) => serializeValue(c)).join(', ');
141149
const childColumns = through.childColumns.map((c) => serializeValue(c)).join(', ');
142150
const targetColumns = through.targetColumns.map((c) => serializeValue(c)).join(', ');
143151
parts.push(
144-
`readonly through: { readonly table: ${table}; readonly parentColumns: readonly [${parentColumns}]; readonly childColumns: readonly [${childColumns}]; readonly targetColumns: readonly [${targetColumns}] }`,
152+
`readonly through: { readonly table: ${table}; readonly namespaceId: ${namespaceId}; readonly parentColumns: readonly [${parentColumns}]; readonly childColumns: readonly [${childColumns}]; readonly targetColumns: readonly [${targetColumns}] }`,
145153
);
146154
}
147155

packages/1-framework/3-tooling/emitter/test/domain-type-generation.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ describe('generateModelRelationsType', () => {
351351
cardinality: 'N:M',
352352
through: {
353353
table: 'post_tags',
354+
namespaceId: 'public',
354355
parentColumns: ['postId'],
355356
childColumns: ['tagId'],
356357
targetColumns: ['id'],
@@ -361,6 +362,7 @@ describe('generateModelRelationsType', () => {
361362
expect(result).toContain("readonly cardinality: 'N:M'");
362363
expect(result).toContain('readonly through:');
363364
expect(result).toContain("readonly table: 'post_tags'");
365+
expect(result).toContain("readonly namespaceId: 'public'");
364366
expect(result).toContain("readonly parentColumns: readonly ['postId']");
365367
expect(result).toContain("readonly childColumns: readonly ['tagId']");
366368
expect(result).toContain("readonly targetColumns: readonly ['id']");
@@ -373,6 +375,7 @@ describe('generateModelRelationsType', () => {
373375
cardinality: 'N:M',
374376
through: {
375377
table: 'user_roles',
378+
namespaceId: 'public',
376379
parentColumns: ['userId', 'tenantId'],
377380
childColumns: ['roleId'],
378381
targetColumns: ['id'],

packages/2-sql/1-core/contract/src/validators.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ const ModelStorageSchema = type({
364364
const ContractRelationThroughSchema = type({
365365
'+': 'reject',
366366
table: 'string',
367+
namespaceId: 'string',
367368
parentColumns: type.string.array().readonly(),
368369
childColumns: type.string.array().readonly(),
369370
targetColumns: type.string.array().readonly(),

packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,10 @@
596596
"type": "string",
597597
"description": "Junction table name"
598598
},
599+
"namespaceId": {
600+
"type": "string",
601+
"description": "Namespace the junction table lives in"
602+
},
599603
"parentColumns": {
600604
"type": "array",
601605
"description": "Junction columns referencing the parent FK",
@@ -612,7 +616,7 @@
612616
"items": { "type": "string" }
613617
}
614618
},
615-
"required": ["table", "parentColumns", "childColumns", "targetColumns"]
619+
"required": ["table", "namespaceId", "parentColumns", "childColumns", "targetColumns"]
616620
}
617621
},
618622
"required": ["to", "cardinality"]

packages/2-sql/2-authoring/contract-ts/src/build-contract.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,12 @@ export function buildSqlContractFromDefinition(
301301
const defaultNamespaceId = definition.target.defaultNamespaceId;
302302
const targetFamily = 'sql';
303303
const modelsByName = new Map(definition.models.map((m) => [m.modelName, m]));
304+
const tableNamespaceByName = new Map(
305+
definition.models.map((m) => [
306+
m.tableName,
307+
m.namespaceId !== undefined && m.namespaceId.length > 0 ? m.namespaceId : defaultNamespaceId,
308+
]),
309+
);
304310

305311
const tablesByNamespace: Record<string, Record<string, StorageTable>> = {};
306312
const tableNameToNamespaceId = new Map<string, string>();
@@ -494,6 +500,7 @@ export function buildSqlContractFromDefinition(
494500
? {
495501
through: {
496502
table: relation.through.table,
503+
namespaceId: tableNamespaceByName.get(relation.through.table) ?? defaultNamespaceId,
497504
parentColumns: relation.through.parentColumns,
498505
childColumns: relation.through.childColumns,
499506
targetColumns: targetModel.id?.columns ?? ([] as const),

packages/3-extensions/sql-orm-client/src/collection-contract.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,6 @@ export function getCompleteColumnToFieldMap(
200200
}
201201

202202
interface ResolvedThrough extends ContractRelationThrough {
203-
/** Namespace of the junction table, resolved from storage. */
204-
readonly namespaceId?: string;
205203
readonly requiredPayloadColumns: readonly string[];
206204
}
207205

@@ -257,13 +255,20 @@ export function resolveIncludeRelation(
257255
function resolveThrough(
258256
contract: Contract<SqlStorage>,
259257
raw:
260-
| { table?: unknown; parentColumns?: unknown; childColumns?: unknown; targetColumns?: unknown }
258+
| {
259+
table?: unknown;
260+
namespaceId?: unknown;
261+
parentColumns?: unknown;
262+
childColumns?: unknown;
263+
targetColumns?: unknown;
264+
}
261265
| undefined,
262266
): ResolvedThrough | undefined {
263267
if (!raw) return undefined;
264-
const { table, parentColumns, childColumns, targetColumns } = raw;
268+
const { table, namespaceId, parentColumns, childColumns, targetColumns } = raw;
265269
if (
266270
typeof table !== 'string' ||
271+
typeof namespaceId !== 'string' ||
267272
!Array.isArray(parentColumns) ||
268273
!Array.isArray(childColumns) ||
269274
!Array.isArray(targetColumns)
@@ -275,10 +280,10 @@ function resolveThrough(
275280
...castAs<readonly string[]>(parentColumns),
276281
...castAs<readonly string[]>(childColumns),
277282
]);
278-
const resolvedJunction = resolveTableForContract(contract, table);
283+
const junctionTable = contract.storage.namespaces[namespaceId]?.tables[table];
279284
const requiredPayloadColumns: string[] = [];
280-
if (resolvedJunction) {
281-
for (const [colName, col] of Object.entries(resolvedJunction.table.columns)) {
285+
if (junctionTable) {
286+
for (const [colName, col] of Object.entries(junctionTable.columns)) {
282287
if (!fkColumnSet.has(colName) && !col.nullable && col.default === undefined) {
283288
requiredPayloadColumns.push(colName);
284289
}
@@ -287,11 +292,11 @@ function resolveThrough(
287292

288293
return {
289294
table,
295+
namespaceId,
290296
parentColumns: castAs<readonly string[]>(parentColumns),
291297
childColumns: castAs<readonly string[]>(childColumns),
292298
targetColumns: castAs<readonly string[]>(targetColumns),
293299
requiredPayloadColumns,
294-
...(resolvedJunction !== undefined ? { namespaceId: resolvedJunction.namespaceId } : {}),
295300
};
296301
}
297302

@@ -322,6 +327,7 @@ export function resolveModelRelations(
322327
on?: { localFields?: unknown; targetFields?: unknown };
323328
through?: {
324329
table?: unknown;
330+
namespaceId?: unknown;
325331
parentColumns?: unknown;
326332
childColumns?: unknown;
327333
targetColumns?: unknown;

packages/3-extensions/sql-orm-client/test/collection-contract.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ describe('resolveModelRelations() through descriptor', () => {
405405
on: { localFields: ['id'], targetFields: targetColumns },
406406
through: {
407407
table: junctionTable,
408+
namespaceId: 'public',
408409
parentColumns,
409410
childColumns,
410411
targetColumns,

0 commit comments

Comments
 (0)