Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
9d14f6c
test(sql-builder): pin namespaced sql.<ns>.<table> accessor surface
SevInf Jun 3, 2026
3b71156
feat(sql-builder): add additive namespaced sql.<ns>.<table> accessor
SevInf Jun 3, 2026
3b512f5
test(sql-orm-client): pin namespaced orm.<ns>.<Model> accessor surface
SevInf Jun 3, 2026
0892749
feat(sql-orm-client): add additive namespaced orm.<ns>.<Model> accessor
SevInf Jun 3, 2026
0f11a91
test(sql-orm-client): pin namespace-scoped metadata resolution
SevInf Jun 3, 2026
861c0d9
feat(sql-orm-client): make ORM metadata resolution namespace-aware
SevInf Jun 3, 2026
af08d03
test(sql-orm-client): pin namespaced orm CRUD execution
SevInf Jun 3, 2026
ac9b990
feat(sql-orm-client): thread namespace through CRUD execution interme…
SevInf Jun 3, 2026
84d3415
test(sql-orm-client): pin namespaced returning-row mutation execution
SevInf Jun 4, 2026
8f75973
feat(sql-orm-client): thread namespace through returning-row mutation…
SevInf Jun 4, 2026
e708a75
test(sql-orm-client): pin cross-namespace relation resolution
SevInf Jun 4, 2026
eccf7f7
feat(sql-orm-client): resolve relation targets within the target name…
SevInf Jun 4, 2026
c981603
test(sql): pin coordinate-aware storage table/column/codec resolution
SevInf Jun 4, 2026
9970b64
feat(sql): make core storage table/column/codec resolvers coordinate-…
SevInf Jun 4, 2026
a98adca
test(sql): pin per-namespace column/codec discrimination for shared b…
SevInf Jun 4, 2026
6211545
feat(sql): thread namespace coordinate into storage table/column/code…
SevInf Jun 4, 2026
50e235c
test(postgres): widen facade orm mock cast to satisfy workspace typec…
SevInf Jun 4, 2026
daaa45f
test(sql): pin per-namespace write-path resolution for shared bare ta…
SevInf Jun 4, 2026
c1ba4e6
feat(sql): thread namespace coordinate through orm write paths and sq…
SevInf Jun 4, 2026
5c1d6e1
test(sql-orm-client): pin per-namespace aggregate resolution for shar…
SevInf Jun 4, 2026
e79bc1f
feat(sql-orm-client): thread namespace coordinate through the aggrega…
SevInf Jun 4, 2026
c987775
test(sql-orm-client): pin per-namespace identity resolution for singu…
SevInf Jun 4, 2026
664f44d
feat(sql-orm-client): thread namespace coordinate through PK-identity…
SevInf Jun 4, 2026
616612e
test(postgres): pin namespaced sql/orm reachability through the facad…
SevInf Jun 4, 2026
71bf99d
test(sqlite): pin namespaced sql/orm reachability through the facade …
SevInf Jun 4, 2026
f1ba18d
docs(explicit-namespace-dsl): slice-01 specs, dispatch plan, review l…
SevInf Jun 4, 2026
80f10a2
feat(sql-contract-ts): author same bare table name across namespaces
SevInf Jun 4, 2026
3b6daca
feat(sql-contract-psl): interpret same bare model name across namespaces
SevInf Jun 4, 2026
95e0f87
Coordinate-key execution-context codec registry by namespace
SevInf Jun 4, 2026
b8ca3b9
docs(explicit-namespace-dsl): update review log + learnings (P1/P2A, …
SevInf Jun 4, 2026
0ec7f86
test(integration): prove namespaced accessors end-to-end on PGlite
SevInf Jun 4, 2026
5a579e0
refactor(sql): make namespace the leading required arg for codec colu…
SevInf Jun 4, 2026
727d904
docs: drop implementation-narration comments in namespaced e2e test
SevInf Jun 4, 2026
b981946
refactor(sql): require leading namespace for storage-column codec + b…
SevInf Jun 4, 2026
d650928
refactor(sql-orm-client): require leading namespace across all model/…
SevInf Jun 4, 2026
92e3891
docs(explicit-namespace-dsl): record slice-02 carry-ins (flat-throw→b…
SevInf Jun 4, 2026
cecfe9b
refactor(sql): complete namespace-coordinate integration after main r…
SevInf Jun 8, 2026
bd616ac
style(sql-contract-psl): sort imports in interpreter.namespaces.test …
SevInf Jun 8, 2026
ac03f7b
docs(upgrade): record 0.12->0.13 extension-author upgrade entry for n…
SevInf Jun 8, 2026
31c4748
fix(sqlite): widen orm mock cast for namespaced OrmClient type in tra…
SevInf Jun 8, 2026
f2f8cf6
fix(sql-orm-client): thread namespace coordinate through nested where…
SevInf Jun 8, 2026
7b0992a
fix(sql-orm-client): thread namespace coordinate into mutation identi…
SevInf Jun 8, 2026
e5e1311
test(postgres,sqlite): drop unknown-cast on orm mock via hoisted loos…
SevInf Jun 8, 2026
5d875a0
fix(sql-orm-client): reconcile N:M through-descriptor with required-n…
SevInf Jun 9, 2026
d7c535b
test(e2e): retry env-flaky embedded-db tests on CI
SevInf Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions packages/2-sql/1-core/contract/src/resolve-storage-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,41 @@ function tableInNamespace(
}

/**
* Resolve a bare storage table name to its namespace coordinate and table IR by
* scanning the contract's namespaces. For the single-namespace contracts in
* scope the scan is exact; cross-namespace bare-name collisions are selected
* explicitly (TML-2550).
* Resolve a bare storage table name to its namespace coordinate and table IR.
*
* When `namespaceId` is supplied, the table is resolved strictly within that
* namespace (no scan). When omitted, a bare name unique across namespaces
* resolves to its sole namespace; a bare name declared in more than one
* namespace throws a fail-fast diagnostic naming the candidate namespaces
* rather than silently selecting the first match.
*/
export function resolveStorageTable(
storage: SqlStorage,
tableName: string,
namespaceId?: string,
): ResolvedStorageTable | undefined {
for (const namespaceId of Object.keys(storage.namespaces)) {
if (namespaceId !== undefined) {
const table = tableInNamespace(storage.namespaces[namespaceId], tableName);
return table === undefined ? undefined : { namespaceId, table };
}

const matches: ResolvedStorageTable[] = [];
for (const candidateNamespaceId of Object.keys(storage.namespaces)) {
const table = tableInNamespace(storage.namespaces[candidateNamespaceId], tableName);
if (table !== undefined) {
return { namespaceId, table };
matches.push({ namespaceId: candidateNamespaceId, table });
}
}

return undefined;
if (matches.length > 1) {
const candidates = matches
.map((match) => match.namespaceId)
.sort()
.join(', ');
throw new Error(
`Storage table "${tableName}" is ambiguous across namespaces [${candidates}]; qualify it with a namespace coordinate.`,
);
}

return matches[0];
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,36 @@ function tableNamed(_name: string): StorageTable {
});
}

function tableWithColumn(columnName: string): StorageTable {
return new StorageTable({
columns: {
id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false },
[columnName]: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
},
primaryKey: { columns: ['id'] },
uniques: [],
indexes: [],
foreignKeys: [],
});
}

function twoNamespaceSameTableName(): {
storage: SqlStorage;
publicUsers: StorageTable;
authUsers: StorageTable;
} {
const publicUsers = tableWithColumn('email_addr');
const authUsers = tableWithColumn('token_col');
const storage = new SqlStorage({
storageHash: 'sha256:test',
namespaces: {
public: buildSqlNamespace({ id: 'public', entries: { table: { users: publicUsers } } }),
auth: buildSqlNamespace({ id: 'auth', entries: { table: { users: authUsers } } }),
},
});
return { storage, publicUsers, authUsers };
}

describe('resolveStorageTable', () => {
it('finds a table in whichever namespace declares it', () => {
const authOnly = tableNamed('auth-only');
Expand Down Expand Up @@ -60,4 +90,31 @@ describe('resolveStorageTable', () => {

expect(resolveStorageTable(storage, 'missing')).toBeUndefined();
});

it('resolves a same-bare-name table strictly within the given namespace', () => {
const { storage, publicUsers, authUsers } = twoNamespaceSameTableName();

expect(resolveStorageTable(storage, 'users', 'public')).toEqual({
namespaceId: 'public',
table: publicUsers,
});
expect(resolveStorageTable(storage, 'users', 'auth')).toEqual({
namespaceId: 'auth',
table: authUsers,
});
});

it('throws naming the candidate namespaces for an ambiguous bare table name', () => {
const { storage } = twoNamespaceSameTableName();

expect(() => resolveStorageTable(storage, 'users')).toThrow(/ambiguous/i);
expect(() => resolveStorageTable(storage, 'users')).toThrow(/auth/);
expect(() => resolveStorageTable(storage, 'users')).toThrow(/public/);
});

it('returns undefined for an unknown table within a given namespace', () => {
const { storage } = twoNamespaceSameTableName();

expect(resolveStorageTable(storage, 'missing', 'public')).toBeUndefined();
});
});
114 changes: 78 additions & 36 deletions packages/2-sql/2-authoring/contract-psl/src/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ import {
buildModelMappings,
collectResolvedFields,
type ModelNameMapping,
type ModelNamespaceEntry,
modelCoordinateKey,
type ResolvedField,
} from './psl-field-resolution';
import {
Expand Down Expand Up @@ -594,6 +596,12 @@ interface BuildModelNodeInput {
readonly model: PslModel;
readonly mapping: ModelNameMapping;
readonly modelMappings: ReadonlyMap<string, ModelNameMapping>;
/**
* Model mappings keyed by `(namespaceId, modelName)` coordinate. Used to
* resolve a namespace-qualified relation target (`auth.User`) to the exact
* model even when the bare name is shared across namespaces.
*/
readonly modelMappingsByCoordinate: ReadonlyMap<string, ModelNameMapping>;
readonly modelNames: Set<string>;
readonly compositeTypeNames: ReadonlySet<string>;
readonly enumTypeDescriptors: Map<string, ColumnDescriptor>;
Expand Down Expand Up @@ -1166,19 +1174,23 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
continue;
}

if (fieldTypeNamespaceId !== undefined) {
const resolvedTargetNamespaceId = input.modelNamespaceIds.get(fieldTypeName);
const normalizedQualifier =
fieldTypeNamespaceId === 'unbound' ? '__unbound__' : fieldTypeNamespaceId;
if (resolvedTargetNamespaceId !== normalizedQualifier) {
diagnostics.push({
code: 'PSL_INVALID_RELATION_TARGET',
message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${qualifiedTypeName}"`,
sourceId,
span: relationAttribute.field.span,
});
continue;
}
const normalizedQualifier =
fieldTypeNamespaceId === undefined
? undefined
: fieldTypeNamespaceId === 'unbound'
? '__unbound__'
: fieldTypeNamespaceId;
if (
normalizedQualifier !== undefined &&
!input.modelMappingsByCoordinate.has(modelCoordinateKey(normalizedQualifier, fieldTypeName))
) {
diagnostics.push({
code: 'PSL_INVALID_RELATION_TARGET',
message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${qualifiedTypeName}"`,
sourceId,
span: relationAttribute.field.span,
});
continue;
}

const parsedRelation = parseRelationAttribute({
Expand All @@ -1201,7 +1213,12 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
continue;
}

const targetMapping = input.modelMappings.get(fieldTypeName);
const targetMapping =
normalizedQualifier !== undefined
? input.modelMappingsByCoordinate.get(
modelCoordinateKey(normalizedQualifier, fieldTypeName),
)
: input.modelMappings.get(fieldTypeName);
if (!targetMapping) {
diagnostics.push({
code: 'PSL_INVALID_RELATION_TARGET',
Expand Down Expand Up @@ -1269,7 +1286,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
})
: undefined;

const targetNamespaceId = input.modelNamespaceIds.get(targetMapping.model.name);
const targetNamespaceId =
normalizedQualifier !== undefined
? normalizedQualifier
: input.modelNamespaceIds.get(targetMapping.model.name);
foreignKeyNodes.push({
columns: localColumns,
references: {
Expand All @@ -1289,6 +1309,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
declaringTableName: tableName,
targetModelName: targetMapping.model.name,
targetTableName: targetMapping.tableName,
...ifDefined('targetNamespaceId', targetNamespaceId),
...ifDefined('relationName', parsedRelation.relationName),
localColumns,
referencedColumns,
Expand Down Expand Up @@ -1528,16 +1549,20 @@ function resolvePolymorphism(
): Record<string, ContractModel> {
let patched = models;

const coordinateFor = (modelName: string): string =>
modelCoordinateKey(modelNamespaceIds.get(modelName) ?? defaultNamespaceId, modelName);

// STI variant columns were materialised onto the base storage table so the
// variants' `storage.fields` resolve. They are storage-only on the base — the
// domain field belongs to the variant — so strip them from the base model's
// domain + storage field maps (the table column, built upstream, stays).
for (const [baseName, fieldNames] of stiBaseFieldsByBase) {
const baseModel = patched[baseName];
const baseKey = coordinateFor(baseName);
const baseModel = patched[baseKey];
if (!baseModel || fieldNames.length === 0) continue;
patched = {
...patched,
[baseName]: stripStorageOnlyDomainFields(baseModel, fieldNames),
[baseKey]: stripStorageOnlyDomainFields(baseModel, fieldNames),
};
}

Expand All @@ -1552,7 +1577,7 @@ function resolvePolymorphism(
continue;
}

const model = patched[modelName];
const model = patched[coordinateFor(modelName)];
if (!model) continue;

if (!Object.hasOwn(model.fields, decl.fieldName)) {
Expand Down Expand Up @@ -1597,7 +1622,7 @@ function resolvePolymorphism(

patched = {
...patched,
[modelName]: { ...model, discriminator: { field: decl.fieldName }, variants },
[coordinateFor(modelName)]: { ...model, discriminator: { field: decl.fieldName }, variants },
};
}

Expand Down Expand Up @@ -1626,7 +1651,7 @@ function resolvePolymorphism(
continue;
}

const variantModel = patched[variantName];
const variantModel = patched[coordinateFor(variantName)];
if (!variantModel) continue;

const baseMapping = modelMappings.get(baseDecl.baseName);
Expand All @@ -1646,7 +1671,7 @@ function resolvePolymorphism(

patched = {
...patched,
[variantName]: stripStorageOnlyDomainFields(
[coordinateFor(variantName)]: stripStorageOnlyDomainFields(
patchedVariant,
syntheticPkFieldsByVariant.get(variantName) ?? [],
),
Expand Down Expand Up @@ -1883,6 +1908,7 @@ export function interpretPslDocumentToSqlContract(
// remains the input to the rest of the interpreter so non-namespace
// concerns stay structurally identical to before.
const models: PslModel[] = [];
const modelEntries: ModelNamespaceEntry[] = [];
const modelNamespaceIds = new Map<string, string>();
for (const namespace of input.document.ast.namespaces) {
const resolvedNamespaceId = resolveNamespaceIdForSqlTarget({
Expand All @@ -1891,11 +1917,13 @@ export function interpretPslDocumentToSqlContract(
});
for (const model of namespace.models) {
models.push(model);
modelEntries.push({ model, namespaceId: resolvedNamespaceId });
if (resolvedNamespaceId !== undefined) {
modelNamespaceIds.set(model.name, resolvedNamespaceId);
}
}
}
const defaultNamespaceId = input.target.defaultNamespaceId;
// Top-level enums (the __unspecified__ bucket) route to `storageTypes`;
// enums inside a named namespace block route to `namespaceTypes[nsId]`.
const topLevelEnums = input.document.ast.namespaces
Expand Down Expand Up @@ -1992,7 +2020,20 @@ export function interpretPslDocumentToSqlContract(

const storageTypes = { ...enumResult.storageTypes, ...namedTypeResult.storageTypes };

const modelMappings = buildModelMappings(models, diagnostics, sourceId);
const modelMappingsByCoordinate = buildModelMappings(
modelEntries,
defaultNamespaceId,
diagnostics,
sourceId,
);
// Bare-name view for unqualified relation targets and polymorphism, where
// resolution is by bare model name. When a bare name is shared across
// namespaces this collapses to the last entry; qualified relation targets
// and per-model lowering use the coordinate-keyed map above instead.
const modelMappings = new Map<string, ModelNameMapping>();
for (const mapping of modelMappingsByCoordinate.values()) {
modelMappings.set(mapping.model.name, mapping);
}
const modelNodes: ModelNode[] = [];
const fkRelationMetadata: FkRelationMetadata[] = [];
const backrelationCandidates: ModelBackrelationCandidate[] = [];
Expand All @@ -2001,15 +2042,17 @@ export function interpretPslDocumentToSqlContract(
// modelRelations after local back-relation matching so they bypass that step.
const crossSpaceRelationsByModel = new Map<string, RelationNode[]>();

for (const model of models) {
const mapping = modelMappings.get(model.name);
for (const { model, namespaceId } of modelEntries) {
const coordinate = modelCoordinateKey(namespaceId ?? defaultNamespaceId, model.name);
const mapping = modelMappingsByCoordinate.get(coordinate);
if (!mapping) {
continue;
}
const result = buildModelNodeFromPsl({
model,
mapping,
modelMappings,
modelMappingsByCoordinate,
modelNames,
compositeTypeNames,
enumTypeDescriptors: allEnumTypeDescriptors,
Expand All @@ -2026,15 +2069,12 @@ export function interpretPslDocumentToSqlContract(
diagnostics,
modelNamespaceIds,
});
const resolvedNamespaceId = modelNamespaceIds.get(model.name);
modelNodes.push(
resolvedNamespaceId !== undefined
? { ...result.modelNode, namespaceId: resolvedNamespaceId }
: result.modelNode,
namespaceId !== undefined ? { ...result.modelNode, namespaceId } : result.modelNode,
);
fkRelationMetadata.push(...result.fkRelationMetadata);
backrelationCandidates.push(...result.backrelationCandidates);
modelResolvedFields.set(model.name, result.resolvedFields);
modelResolvedFields.set(coordinate, result.resolvedFields);
if (result.crossSpaceRelations.length > 0) {
const existing = crossSpaceRelationsByModel.get(model.name) ?? [];
crossSpaceRelationsByModel.set(model.name, [...existing, ...result.crossSpaceRelations]);
Expand Down Expand Up @@ -2135,15 +2175,17 @@ export function interpretPslDocumentToSqlContract(
})),
});

// Keyed by `(namespaceId, modelName)` coordinate so two models that share a
// bare name across namespaces stay distinct through the patch/polymorphism
// passes; only a genuine same-namespace duplicate is an error.
const modelsForPatch: Record<string, ContractModel> = {};
for (const namespaceSlice of Object.values(contract.domain.namespaces)) {
for (const [namespaceId, namespaceSlice] of Object.entries(contract.domain.namespaces)) {
for (const [modelName, model] of Object.entries(namespaceSlice.models)) {
if (Object.hasOwn(modelsForPatch, modelName)) {
throw new Error(
`duplicate model name "${modelName}" across domain namespaces during PSL interpretation`,
);
const coordinate = modelCoordinateKey(namespaceId, modelName);
if (Object.hasOwn(modelsForPatch, coordinate)) {
throw new Error(`duplicate model "${namespaceId}.${modelName}" during PSL interpretation`);
}
modelsForPatch[modelName] = model;
modelsForPatch[coordinate] = model;
}
}
let patchedModels = patchModelDomainFields(modelsForPatch, modelResolvedFields);
Expand Down Expand Up @@ -2188,7 +2230,7 @@ export function interpretPslDocumentToSqlContract(
models: Object.fromEntries(
Object.entries(namespaceSlice.models).map(([modelName, model]) => [
modelName,
patchedModels[modelName] ?? model,
patchedModels[modelCoordinateKey(namespaceId, modelName)] ?? model,
]),
),
...(namespaceSlice.valueObjects !== undefined
Expand Down
Loading
Loading