Skip to content

Commit 22e3dd1

Browse files
wmadden-electricwmadden
authored andcommitted
TML-2727: address CodeRabbit findings on R3 namespace branding
- Mirror the SQL R3 fix on Mongo: type MongoStorageInput.namespaces as Readonly<Record<string, MongoNamespace>> so the constructor assignment needs no cast; drop the bare `as`. - Make the SQL `__unbound__` brand sound rather than asserted: inject the SqlUnboundNamespace singleton when the validated/hydrated namespaces omit the late-bound slot (cross-namespace contracts legitimately do), then reconstruct the branded map. Removes the unsound blindCast in validateStorage and the bare `as` bridge cast; keeps an honest, runtime-guaranteed cast in the hydrate path. - Make the Mongo construction test honest: it exercises the unbound-only happy path (namespaces is a required field, not a runtime throw). Signed-off-by: wmadden-electric <286902546+wmadden-electric@users.noreply.github.com>
1 parent da778c2 commit 22e3dd1

4 files changed

Lines changed: 55 additions & 31 deletions

File tree

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,6 @@ export interface MongoNamespaceCollectionsInput {
1212
readonly collections?: Record<string, MongoCollection | MongoCollectionInput>;
1313
}
1414

15-
export interface MongoStorageInput<THash extends string = string> {
16-
readonly storageHash: StorageHashBase<THash>;
17-
readonly namespaces: Readonly<Record<string, Namespace>>;
18-
}
19-
2015
// Mongo concretions always store `MongoCollection` instances in
2116
// `collections` (Mongo idiom — distinct from the SQL family's `tables`).
2217
// Narrowing the namespace map here lets target/family-level consumers
@@ -27,6 +22,11 @@ export type MongoNamespace = Namespace & {
2722
readonly collections: Readonly<Record<string, MongoCollection>>;
2823
};
2924

25+
export interface MongoStorageInput<THash extends string = string> {
26+
readonly storageHash: StorageHashBase<THash>;
27+
readonly namespaces: Readonly<Record<string, MongoNamespace>>;
28+
}
29+
3030
export class MongoStorage<THash extends string = string> extends IRNodeBase implements Storage {
3131
declare readonly kind: 'mongo-storage';
3232
readonly storageHash: StorageHashBase<THash>;
@@ -41,7 +41,7 @@ export class MongoStorage<THash extends string = string> extends IRNodeBase impl
4141
configurable: true,
4242
});
4343
this.storageHash = input.storageHash;
44-
this.namespaces = Object.freeze(input.namespaces) as Readonly<Record<string, MongoNamespace>>;
44+
this.namespaces = Object.freeze(input.namespaces);
4545
freezeNode(this);
4646
}
4747
}

packages/2-mongo-family/1-foundation/mongo-contract/test/mongo-storage.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { coreHash } from '@prisma-next/contract/types';
22
import {
33
freezeNode,
4-
type IRNode,
54
NamespaceBase,
65
UNBOUND_NAMESPACE_ID,
76
} from '@prisma-next/framework-components/ir';
@@ -17,7 +16,7 @@ const hash = coreHash('h_0');
1716
class TestNamespace extends NamespaceBase {
1817
readonly kind = 'test-namespace' as const;
1918
readonly id: string;
20-
readonly collections: Readonly<Record<string, IRNode>> = Object.freeze({});
19+
readonly collections: Readonly<Record<string, MongoCollection>> = Object.freeze({});
2120

2221
constructor(id: string) {
2322
super();
@@ -73,13 +72,14 @@ describe('MongoStorage', () => {
7372
expect(Object.isFrozen(storage)).toBe(true);
7473
});
7574

76-
it('requires a namespaces map at construction', () => {
77-
expect(
78-
() =>
79-
new MongoStorage({
80-
storageHash: hash,
81-
namespaces: { [UNBOUND_NAMESPACE_ID]: MongoUnboundNamespace.instance },
82-
}),
83-
).not.toThrow();
75+
it('constructs from the unbound namespace singleton alone', () => {
76+
// `namespaces` is a required field on `MongoStorageInput`, so the
77+
// empty/omitted case is a type error rather than a runtime throw —
78+
// this exercises the happy path of an unbound-only storage.
79+
const storage = new MongoStorage({
80+
storageHash: hash,
81+
namespaces: { [UNBOUND_NAMESPACE_ID]: MongoUnboundNamespace.instance },
82+
});
83+
expect(storage.namespaces[UNBOUND_NAMESPACE_ID]).toBe(MongoUnboundNamespace.instance);
8484
});
8585
});

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

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ import {
66
CrossReferenceSchema,
77
} from '@prisma-next/contract/types';
88
import { validateContractDomain } from '@prisma-next/contract/validate-domain';
9-
import type { Namespace } from '@prisma-next/framework-components/ir';
9+
import { type Namespace, UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
1010
import { blindCast } from '@prisma-next/utils/casts';
1111
import { ifDefined } from '@prisma-next/utils/defined';
1212
import { type Type, type } from 'arktype';
1313
import { buildSqlNamespaceMap } from './ir/build-sql-namespace';
14+
import { SqlUnboundNamespace } from './ir/sql-unbound-namespace';
1415
import {
1516
type ForeignKeyInput,
1617
type ForeignKeyReferenceInput,
@@ -265,6 +266,11 @@ export function createSqlStorageSchema(
265266
'+': 'reject',
266267
storageHash: 'string',
267268
'types?': type({ '[string]': DocumentScopedStorageTypeSchema }),
269+
// `__unbound__` is NOT required here: cross-namespace contracts can
270+
// declare only named namespaces (see cross-namespace FK fixtures). The
271+
// `__unbound__` brand on `SqlStorageInput['namespaces']` is kept sound at
272+
// construction time by injecting the unbound singleton when absent
273+
// (see `validateStorage` / `hydrateSqlStorage`), not by structural require.
268274
'namespaces?': type({ '[string]': namespaceEntry }),
269275
}) as Type<unknown>;
270276
}
@@ -441,20 +447,25 @@ export function validateStorage(value: unknown): SqlStorage {
441447
const messages = result.map((p: { message: string }) => p.message).join('; ');
442448
throw new Error(`Storage validation failed: ${messages}`);
443449
}
444-
// The arktype-validated shape matches `SqlStorageInput`
445-
// structurally. Funnel through the constructor so nested IR fields
446-
// (`types`) are normalised into class instances and the
447-
// branded `storageHash` is preserved on the returned `SqlStorage`.
448-
const validated = result as SqlStorageInput & {
449-
readonly namespaces?: SqlStorageInput['namespaces'];
450-
};
450+
// Arktype validates the JSON-safe envelope, but the `ColumnDefault`
451+
// union carries runtime-only `bigint | Date` that the validation DSL
452+
// can't express (see NOTE above), so bridge the validated shape to the
453+
// input type. Construction below re-materialises nested IR fields.
454+
const validated = blindCast<
455+
SqlStorageInput & { readonly namespaces?: SqlStorageInput['namespaces'] },
456+
'arktype validated the JSON envelope but its output type is unknown (ColumnDefault carries runtime-only bigint|Date); bridge to the input shape'
457+
>(result);
458+
const namespaces = buildSqlNamespaceMap(validated.namespaces ?? {});
459+
// The `__unbound__` brand is made true at construction: if the validated
460+
// namespaces omit the late-bound slot (e.g. a contract declaring only named
461+
// namespaces), inject the family unbound singleton rather than asserting a
462+
// shape that isn't there. Reconstructing the literal lets the branded
463+
// `SqlStorageInput['namespaces']` hold with no cast.
464+
const unbound = namespaces[UNBOUND_NAMESPACE_ID] ?? SqlUnboundNamespace.instance;
451465
return new SqlStorage({
452466
storageHash: validated.storageHash,
453467
...ifDefined('types', validated.types),
454-
namespaces: blindCast<
455-
SqlStorageInput['namespaces'],
456-
'structural storage validation requires the __unbound__ namespace slot'
457-
>(buildSqlNamespaceMap(validated.namespaces ?? {})),
468+
namespaces: { ...namespaces, [UNBOUND_NAMESPACE_ID]: unbound },
458469
});
459470
}
460471

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { ContractValidationError } from '@prisma-next/contract/contract-validation-error';
22
import type { Contract } from '@prisma-next/contract/types';
33
import type { ContractSerializer } from '@prisma-next/framework-components/control';
4-
import { type Namespace, NamespaceBase } from '@prisma-next/framework-components/ir';
4+
import {
5+
type Namespace,
6+
NamespaceBase,
7+
UNBOUND_NAMESPACE_ID,
8+
} from '@prisma-next/framework-components/ir';
59
import {
610
buildSqlNamespace,
711
type SqlNamespaceTablesInput,
812
SqlStorage,
913
type SqlStorageInput,
1014
type SqlStorageTypeEntry,
15+
SqlUnboundNamespace,
1116
StorageTable,
1217
type StorageTableInput,
1318
} from '@prisma-next/sql-contract/types';
@@ -113,16 +118,24 @@ export abstract class SqlContractSerializerBase<TContract extends Contract<SqlSt
113118
);
114119
}
115120
const hydratedNamespaces = this.hydrateSqlNamespaceMap(rawNamespaces);
121+
// Keep the `__unbound__` brand sound: a deserialized contract may declare
122+
// only named namespaces (cross-namespace contracts omit the late-bound
123+
// slot), so inject the family unbound singleton when absent rather than
124+
// asserting a shape that isn't present.
125+
const unbound = hydratedNamespaces[UNBOUND_NAMESPACE_ID] ?? SqlUnboundNamespace.instance;
116126

117127
return {
118128
...validated,
119129
storage: new SqlStorage({
120130
storageHash: validated.storage.storageHash,
121131
...ifDefined('types', hydratedTypes),
132+
// `__unbound__` is guaranteed present above; the residual narrowing is
133+
// the family invariant that hydrated SQL namespaces are `SqlNamespace`
134+
// instances (target/family classes, not the wider framework `Namespace`).
122135
namespaces: blindCast<
123136
SqlStorageInput['namespaces'],
124-
'deserialized SQL contracts require the __unbound__ namespace slot'
125-
>(hydratedNamespaces),
137+
'hydrated SQL namespaces are SqlNamespace instances; __unbound__ guaranteed present (injected above)'
138+
>({ ...hydratedNamespaces, [UNBOUND_NAMESPACE_ID]: unbound }),
126139
}),
127140
};
128141
}

0 commit comments

Comments
 (0)