Skip to content

Commit a047d45

Browse files
wmadden-electricwmadden
authored andcommitted
TML-2727: address PR #630 review on namespace construction
Rename bound namespace concretions to SqlBoundNamespace / MongoBoundNamespace with static factories, discriminate map entries by family kind instead of instanceof, brand __unbound__ on SqlStorageInput, and use ifDefined at hydration boundaries. Signed-off-by: wmadden-electric <286902546+wmadden-electric@users.noreply.github.com>
1 parent cbdaf2e commit a047d45

12 files changed

Lines changed: 180 additions & 72 deletions

File tree

packages/2-mongo-family/1-foundation/mongo-contract/src/ir/build-mongo-namespace.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,36 @@ import { MongoCollection } from './mongo-collection';
99
import type { MongoNamespace, MongoNamespaceCollectionsInput } from './mongo-storage';
1010
import { MongoUnboundNamespace } from './mongo-unbound-namespace';
1111

12-
class MongoNamespaceFromCollectionsInput extends NamespaceBase {
12+
const MONGO_NAMESPACE_KIND = 'mongo-namespace' as const;
13+
14+
function isMaterializedMongoNamespace(
15+
ns: Namespace | MongoNamespaceCollectionsInput,
16+
): ns is MongoNamespace {
17+
if (typeof ns !== 'object' || ns === null) {
18+
return false;
19+
}
20+
const proto = Object.getPrototypeOf(ns);
21+
if (proto === Object.prototype || proto === null) {
22+
return false;
23+
}
24+
return (ns as Namespace).kind === MONGO_NAMESPACE_KIND;
25+
}
26+
27+
class MongoBoundNamespace extends NamespaceBase {
1328
declare readonly kind: string;
1429

1530
readonly id: string;
1631
readonly collections: Readonly<Record<string, MongoCollection>>;
1732

18-
constructor(input: MongoNamespaceCollectionsInput) {
33+
static fromCollectionsInput(input: MongoNamespaceCollectionsInput): MongoNamespace {
34+
const collectionCount = Object.keys(input.collections ?? {}).length;
35+
if (input.id === UNBOUND_NAMESPACE_ID && collectionCount === 0) {
36+
return castAs<MongoNamespace>(MongoUnboundNamespace.instance);
37+
}
38+
return castAs<MongoNamespace>(new MongoBoundNamespace(input));
39+
}
40+
41+
private constructor(input: MongoNamespaceCollectionsInput) {
1942
super();
2043
this.id = input.id;
2144
this.collections = Object.freeze(
@@ -27,7 +50,7 @@ class MongoNamespaceFromCollectionsInput extends NamespaceBase {
2750
),
2851
);
2952
Object.defineProperty(this, 'kind', {
30-
value: 'mongo-namespace',
53+
value: MONGO_NAMESPACE_KIND,
3154
writable: false,
3255
enumerable: false,
3356
configurable: true,
@@ -37,11 +60,7 @@ class MongoNamespaceFromCollectionsInput extends NamespaceBase {
3760
}
3861

3962
export function buildMongoNamespace(input: MongoNamespaceCollectionsInput): MongoNamespace {
40-
const collectionCount = Object.keys(input.collections ?? {}).length;
41-
if (input.id === UNBOUND_NAMESPACE_ID && collectionCount === 0) {
42-
return castAs<MongoNamespace>(MongoUnboundNamespace.instance);
43-
}
44-
return castAs<MongoNamespace>(new MongoNamespaceFromCollectionsInput(input));
63+
return MongoBoundNamespace.fromCollectionsInput(input);
4564
}
4665

4766
export function buildMongoNamespaceMap(
@@ -50,12 +69,12 @@ export function buildMongoNamespaceMap(
5069
return Object.fromEntries(
5170
Object.entries(namespaces).map(([nsKey, ns]) => [
5271
nsKey,
53-
ns instanceof NamespaceBase
72+
isMaterializedMongoNamespace(ns)
5473
? blindCast<
5574
MongoNamespace,
56-
'an already-built NamespaceBase in a Mongo-family namespace map is a MongoNamespace'
75+
'a materialised Mongo-family namespace entry in a namespace map is a MongoNamespace'
5776
>(ns)
58-
: buildMongoNamespace(ns),
77+
: MongoBoundNamespace.fromCollectionsInput(ns),
5978
]),
6079
);
6180
}

packages/2-sql/1-core/contract/src/ir/build-sql-namespace.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,36 @@ import type { SqlNamespace, SqlNamespaceTablesInput } from './sql-storage';
1010
import { SqlUnboundNamespace } from './sql-unbound-namespace';
1111
import { StorageTable } from './storage-table';
1212

13-
class SqlNamespaceFromTablesInput extends NamespaceBase {
13+
const SQL_NAMESPACE_KIND = 'sql-namespace' as const;
14+
15+
function isMaterializedSqlNamespace(ns: Namespace | SqlNamespaceTablesInput): ns is SqlNamespace {
16+
if (typeof ns !== 'object' || ns === null) {
17+
return false;
18+
}
19+
const proto = Object.getPrototypeOf(ns);
20+
if (proto === Object.prototype || proto === null) {
21+
return false;
22+
}
23+
return (ns as Namespace).kind === SQL_NAMESPACE_KIND;
24+
}
25+
26+
class SqlBoundNamespace extends NamespaceBase {
1427
declare readonly kind: string;
1528
declare readonly enum?: Readonly<Record<string, PostgresEnumStorageEntry>>;
1629

1730
readonly id: string;
1831
readonly tables: Readonly<Record<string, StorageTable>>;
1932

20-
constructor(input: SqlNamespaceTablesInput) {
33+
static fromTablesInput(input: SqlNamespaceTablesInput): SqlNamespace {
34+
const tableCount = Object.keys(input.tables ?? {}).length;
35+
const enumCount = Object.keys(input.enum ?? {}).length;
36+
if (input.id === UNBOUND_NAMESPACE_ID && tableCount === 0 && enumCount === 0) {
37+
return castAs<SqlNamespace>(SqlUnboundNamespace.instance);
38+
}
39+
return castAs<SqlNamespace>(new SqlBoundNamespace(input));
40+
}
41+
42+
private constructor(input: SqlNamespaceTablesInput) {
2143
super();
2244
this.id = input.id;
2345
this.tables = Object.freeze(
@@ -37,7 +59,7 @@ class SqlNamespaceFromTablesInput extends NamespaceBase {
3759
});
3860
}
3961
Object.defineProperty(this, 'kind', {
40-
value: 'sql-namespace',
62+
value: SQL_NAMESPACE_KIND,
4163
writable: false,
4264
enumerable: false,
4365
configurable: true,
@@ -47,12 +69,7 @@ class SqlNamespaceFromTablesInput extends NamespaceBase {
4769
}
4870

4971
export function buildSqlNamespace(input: SqlNamespaceTablesInput): SqlNamespace {
50-
const tableCount = Object.keys(input.tables ?? {}).length;
51-
const enumCount = Object.keys(input.enum ?? {}).length;
52-
if (input.id === UNBOUND_NAMESPACE_ID && tableCount === 0 && enumCount === 0) {
53-
return castAs<SqlNamespace>(SqlUnboundNamespace.instance);
54-
}
55-
return castAs<SqlNamespace>(new SqlNamespaceFromTablesInput(input));
72+
return SqlBoundNamespace.fromTablesInput(input);
5673
}
5774

5875
export function buildSqlNamespaceMap(
@@ -61,12 +78,12 @@ export function buildSqlNamespaceMap(
6178
return Object.fromEntries(
6279
Object.entries(namespaces).map(([nsKey, ns]) => [
6380
nsKey,
64-
ns instanceof NamespaceBase
81+
isMaterializedSqlNamespace(ns)
6582
? blindCast<
6683
SqlNamespace,
67-
'an already-built NamespaceBase in an SQL-family namespace map is a SqlNamespace'
84+
'a materialised SQL-family namespace entry in a namespace map is a SqlNamespace'
6885
>(ns)
69-
: buildSqlNamespace(ns),
86+
: SqlBoundNamespace.fromTablesInput(ns),
7087
]),
7188
);
7289
}

packages/2-sql/1-core/contract/src/ir/sql-storage.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ export interface SqlNamespaceTablesInput {
3232
export interface SqlStorageInput<THash extends string = string> {
3333
readonly storageHash: StorageHashBase<THash>;
3434
readonly types?: Record<string, SqlStorageTypeEntry>;
35-
readonly namespaces: Readonly<Record<string, Namespace>>;
35+
readonly namespaces: Readonly<Record<string, SqlNamespace>> & {
36+
readonly __unbound__: SqlNamespace;
37+
};
3638
}
3739

3840
/**
@@ -81,9 +83,7 @@ export class SqlStorage<THash extends string = string> extends SqlNode implement
8183
constructor(input: SqlStorageInput<THash>) {
8284
super();
8385
this.storageHash = input.storageHash;
84-
this.namespaces = Object.freeze(input.namespaces) as Readonly<Record<string, SqlNamespace>> & {
85-
readonly __unbound__: SqlNamespace;
86-
};
86+
this.namespaces = Object.freeze(input.namespaces);
8787
if (input.types !== undefined) {
8888
this.types = Object.freeze(
8989
Object.fromEntries(

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
} from '@prisma-next/contract/types';
88
import { validateContractDomain } from '@prisma-next/contract/validate-domain';
99
import type { Namespace } from '@prisma-next/framework-components/ir';
10+
import { blindCast } from '@prisma-next/utils/casts';
11+
import { ifDefined } from '@prisma-next/utils/defined';
1012
import { type Type, type } from 'arktype';
1113
import { buildSqlNamespaceMap } from './ir/build-sql-namespace';
1214
import {
@@ -448,8 +450,11 @@ export function validateStorage(value: unknown): SqlStorage {
448450
};
449451
return new SqlStorage({
450452
storageHash: validated.storageHash,
451-
...(validated.types !== undefined ? { types: validated.types } : {}),
452-
namespaces: buildSqlNamespaceMap(validated.namespaces ?? {}),
453+
...ifDefined('types', validated.types),
454+
namespaces: blindCast<
455+
SqlStorageInput['namespaces'],
456+
'structural storage validation requires the __unbound__ namespace slot'
457+
>(buildSqlNamespaceMap(validated.namespaces ?? {})),
453458
});
454459
}
455460

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
2+
import { describe, expect, it } from 'vitest';
3+
import { buildSqlNamespace, buildSqlNamespaceMap } from '../src/ir/build-sql-namespace';
4+
import { SqlUnboundNamespace } from '../src/ir/sql-unbound-namespace';
5+
6+
const emptyTableInput = {
7+
columns: {},
8+
uniques: [],
9+
indexes: [],
10+
foreignKeys: [],
11+
} as const;
12+
13+
describe('buildSqlNamespaceMap', () => {
14+
it('passes through a materialised namespace without re-wrapping tables', () => {
15+
const built = buildSqlNamespace({ id: 'app', tables: { users: emptyTableInput } });
16+
const map = buildSqlNamespaceMap({ app: built });
17+
expect(map.app).toBe(built);
18+
});
19+
20+
it('materialises plain tables-input entries', () => {
21+
const map = buildSqlNamespaceMap({
22+
[UNBOUND_NAMESPACE_ID]: { id: UNBOUND_NAMESPACE_ID },
23+
app: { id: 'app', tables: { users: emptyTableInput } },
24+
});
25+
expect(map[UNBOUND_NAMESPACE_ID]).toBe(SqlUnboundNamespace.instance);
26+
expect(map.app.id).toBe('app');
27+
expect(map.app.tables.users).toBeDefined();
28+
});
29+
});

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

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from '@prisma-next/contract/types';
2222
import { type CapabilityMatrix, mergeCapabilityMatrices } from '@prisma-next/contract-authoring';
2323
import type { CodecLookup } from '@prisma-next/framework-components/codec';
24-
import { type Namespace, UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
24+
import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
2525
import { validateIndexTypes } from '@prisma-next/sql-contract/index-type-validation';
2626
import {
2727
createIndexTypeRegistry,
@@ -35,13 +35,15 @@ import {
3535
type PostgresEnumStorageEntry,
3636
type SqlNamespaceTablesInput,
3737
SqlStorage,
38+
type SqlStorageInput,
3839
type StorageColumn,
3940
StorageTable,
4041
type StorageTableInput,
4142
type StorageTypeInstance,
4243
toStorageTypeInstance,
4344
} from '@prisma-next/sql-contract/types';
4445
import { validateStorageSemantics } from '@prisma-next/sql-contract/validators';
46+
import { blindCast } from '@prisma-next/utils/casts';
4547
import { ifDefined } from '@prisma-next/utils/defined';
4648
import type {
4749
ContractDefinition,
@@ -544,16 +546,21 @@ export function buildSqlContractFromDefinition(
544546
namespaceCoordinateIds.add(id);
545547
}
546548
const { createNamespace } = definition;
547-
const namespaces: Record<string, Namespace> = Object.fromEntries(
548-
[...namespaceCoordinateIds].sort().map((id) => {
549-
const enumTypes = namespaceEnumTypesById[id];
550-
const nsInput: SqlNamespaceTablesInput = {
551-
id,
552-
tables: tablesByNamespace[id] ?? {},
553-
...ifDefined('enum', enumTypes),
554-
};
555-
return [id, createNamespace ? createNamespace(nsInput) : buildSqlNamespace(nsInput)];
556-
}),
549+
const namespaces = blindCast<
550+
SqlStorageInput['namespaces'],
551+
'contract authoring always materialises the __unbound__ namespace coordinate'
552+
>(
553+
Object.fromEntries(
554+
[...namespaceCoordinateIds].sort().map((id) => {
555+
const enumTypes = namespaceEnumTypesById[id];
556+
const nsInput: SqlNamespaceTablesInput = {
557+
id,
558+
tables: tablesByNamespace[id] ?? {},
559+
...ifDefined('enum', enumTypes),
560+
};
561+
return [id, createNamespace ? createNamespace(nsInput) : buildSqlNamespace(nsInput)];
562+
}),
563+
),
557564
);
558565
const domainUnboundTypes =
559566
Object.keys(documentTypes).length > 0 ? { types: documentTypes } : undefined;

packages/2-sql/5-runtime/test/utils.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,9 +422,7 @@ export function createTestContract(
422422
? new SqlStorage({
423423
...rest['storage'],
424424
storageHash: storageHashValue,
425-
namespaces:
426-
rest['storage'].namespaces ??
427-
({ __unbound__: SqlUnboundNamespace.instance } as SqlStorageInput['namespaces']),
425+
namespaces: rest['storage'].namespaces ?? { __unbound__: SqlUnboundNamespace.instance },
428426
})
429427
: new SqlStorage({
430428
storageHash: storageHashValue,

packages/2-sql/9-family/src/core/ir/sql-contract-serializer-base.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
buildSqlNamespace,
77
type SqlNamespaceTablesInput,
88
SqlStorage,
9+
type SqlStorageInput,
910
type SqlStorageTypeEntry,
1011
StorageTable,
1112
type StorageTableInput,
@@ -14,6 +15,8 @@ import {
1415
createSqlContractSchema,
1516
validateSqlContractFully,
1617
} from '@prisma-next/sql-contract/validators';
18+
import { blindCast } from '@prisma-next/utils/casts';
19+
import { ifDefined } from '@prisma-next/utils/defined';
1720
import type { JsonObject } from '@prisma-next/utils/json';
1821
import { type Type, type } from 'arktype';
1922

@@ -115,8 +118,11 @@ export abstract class SqlContractSerializerBase<TContract extends Contract<SqlSt
115118
...validated,
116119
storage: new SqlStorage({
117120
storageHash: validated.storage.storageHash,
118-
...(hydratedTypes !== undefined ? { types: hydratedTypes } : {}),
119-
namespaces: hydratedNamespaces,
121+
...ifDefined('types', hydratedTypes),
122+
namespaces: blindCast<
123+
SqlStorageInput['namespaces'],
124+
'deserialized SQL contracts require the __unbound__ namespace slot'
125+
>(hydratedNamespaces),
120126
}),
121127
};
122128
}
@@ -125,9 +131,14 @@ export abstract class SqlContractSerializerBase<TContract extends Contract<SqlSt
125131
namespaces: Readonly<Record<string, Namespace | Record<string, unknown>>>,
126132
): Readonly<Record<string, Namespace>> {
127133
return Object.fromEntries(
128-
Object.entries(namespaces).map(([nsId, raw]) => {
129-
const hydrated = this.hydrateSqlNamespaceEntry(nsId, raw);
130-
return [nsId, hydrated instanceof NamespaceBase ? hydrated : buildSqlNamespace(hydrated)];
134+
Object.entries(namespaces).map(([nsId, namespaceEntryRaw]) => {
135+
// Raw entries passed structural validation; hydrate materialises family IR class instances.
136+
const namespaceHydrated = this.hydrateSqlNamespaceEntry(nsId, namespaceEntryRaw);
137+
const namespaceMaterialised =
138+
namespaceHydrated instanceof NamespaceBase
139+
? namespaceHydrated
140+
: buildSqlNamespace(namespaceHydrated);
141+
return [nsId, namespaceMaterialised];
131142
}),
132143
);
133144
}

packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
2020
import { createPostgresMigrationPlanner } from '@prisma-next/target-postgres/planner';
2121
import { buildBuiltinIdentityValue } from '@prisma-next/target-postgres/planner-identity-values';
22+
import { blindCast } from '@prisma-next/utils/casts';
2223
import { describe, expect, it } from 'vitest';
2324
import pgvectorDescriptor from '../../src/exports/control';
2425

@@ -580,14 +581,16 @@ function createTestContract(
580581
},
581582
};
582583
const { storage: _s, ...rest } = overrides ?? {};
583-
const namespaces = storageInput.namespaces
584-
? buildSqlNamespaceMap(storageInput.namespaces)
585-
: {
586-
[UNBOUND_NAMESPACE_ID]: buildSqlNamespace({
587-
id: UNBOUND_NAMESPACE_ID,
588-
tables: defaultTables,
589-
}),
590-
};
584+
const namespaces = blindCast<SqlStorageInput['namespaces']>(
585+
storageInput.namespaces
586+
? buildSqlNamespaceMap(storageInput.namespaces)
587+
: {
588+
[UNBOUND_NAMESPACE_ID]: buildSqlNamespace({
589+
id: UNBOUND_NAMESPACE_ID,
590+
tables: defaultTables,
591+
}),
592+
},
593+
);
591594
return {
592595
target: 'postgres',
593596
targetFamily: 'sql',

0 commit comments

Comments
 (0)