@prisma-next/sql-contract-ts 0.12.0-dev.59 → 0.12.0-dev.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,27 +1,27 @@
1
1
  {
2
2
  "name": "@prisma-next/sql-contract-ts",
3
- "version": "0.12.0-dev.59",
3
+ "version": "0.12.0-dev.60",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "description": "SQL-specific TypeScript contract authoring surface for Prisma Next",
8
8
  "dependencies": {
9
- "@prisma-next/config": "0.12.0-dev.59",
10
- "@prisma-next/contract": "0.12.0-dev.59",
11
- "@prisma-next/contract-authoring": "0.12.0-dev.59",
12
- "@prisma-next/framework-components": "0.12.0-dev.59",
13
- "@prisma-next/sql-contract": "0.12.0-dev.59",
14
- "@prisma-next/utils": "0.12.0-dev.59",
9
+ "@prisma-next/config": "0.12.0-dev.60",
10
+ "@prisma-next/contract": "0.12.0-dev.60",
11
+ "@prisma-next/contract-authoring": "0.12.0-dev.60",
12
+ "@prisma-next/framework-components": "0.12.0-dev.60",
13
+ "@prisma-next/sql-contract": "0.12.0-dev.60",
14
+ "@prisma-next/utils": "0.12.0-dev.60",
15
15
  "arktype": "^2.2.0",
16
16
  "pathe": "^2.0.3",
17
17
  "ts-toolbelt": "^9.6.0"
18
18
  },
19
19
  "devDependencies": {
20
- "@prisma-next/test-utils": "0.12.0-dev.59",
21
- "@prisma-next/tsconfig": "0.12.0-dev.59",
20
+ "@prisma-next/test-utils": "0.12.0-dev.60",
21
+ "@prisma-next/tsconfig": "0.12.0-dev.60",
22
22
  "@types/pg": "8.20.0",
23
23
  "pg": "8.21.0",
24
- "@prisma-next/tsdown": "0.12.0-dev.59",
24
+ "@prisma-next/tsdown": "0.12.0-dev.60",
25
25
  "tsdown": "0.22.1",
26
26
  "typescript": "5.9.3",
27
27
  "vitest": "4.1.7"
@@ -425,6 +425,31 @@ export function buildSqlContractFromDefinition(
425
425
  }
426
426
 
427
427
  const foreignKeys = (semanticModel.foreignKeys ?? []).map((fk) => {
428
+ if (fk.references.spaceId !== undefined) {
429
+ // Cross-space FK: the target lives in a different contract space.
430
+ // Skip local model lookup and carry the spaceId coordinate through.
431
+ const targetNamespaceId = fk.references.namespaceId ?? defaultNamespaceId;
432
+ return {
433
+ source: { namespaceId: asNamespaceId(namespaceId), tableName, columns: fk.columns },
434
+ target: {
435
+ namespaceId: asNamespaceId(targetNamespaceId),
436
+ tableName: fk.references.table,
437
+ columns: fk.references.columns,
438
+ spaceId: fk.references.spaceId,
439
+ },
440
+ ...applyFkDefaults(
441
+ {
442
+ ...ifDefined('constraint', fk.constraint),
443
+ ...ifDefined('index', fk.index),
444
+ },
445
+ definition.foreignKeyDefaults,
446
+ ),
447
+ ...ifDefined('name', fk.name),
448
+ ...ifDefined('onDelete', fk.onDelete),
449
+ ...ifDefined('onUpdate', fk.onUpdate),
450
+ };
451
+ }
452
+
428
453
  const targetModel = assertKnownTargetModel(
429
454
  modelsByName,
430
455
  semanticModel.modelName,
@@ -523,6 +548,24 @@ export function buildSqlContractFromDefinition(
523
548
  );
524
549
  const modelRelations: Record<string, ContractRelation> = {};
525
550
  for (const relation of semanticModel.relations ?? []) {
551
+ // Cross-space relations have `spaceId` set — the target model lives in
552
+ // a different contract space, so skip local model lookup and validation.
553
+ if (relation.spaceId !== undefined) {
554
+ const targetNamespaceId = relation.namespaceId ?? defaultNamespaceId;
555
+ modelRelations[relation.fieldName] = {
556
+ to: crossRef(relation.toModel, targetNamespaceId, relation.spaceId),
557
+ // Cross-space belongsTo relations are always N:1 (the FK-owning side).
558
+ cardinality: 'N:1',
559
+ on: {
560
+ localFields: relation.on.parentColumns.map((col) => columnToField.get(col) ?? col),
561
+ // For cross-space targets the lowering carries field names directly
562
+ // (no fieldToColumn map available for the remote model).
563
+ targetFields: relation.on.childColumns,
564
+ },
565
+ };
566
+ continue;
567
+ }
568
+
526
569
  const targetModel = assertKnownTargetModel(
527
570
  modelsByName,
528
571
  semanticModel.modelName,
@@ -22,6 +22,7 @@ import {
22
22
  import {
23
23
  type ContractInput,
24
24
  type ContractModelBuilder,
25
+ extensionModel,
25
26
  field,
26
27
  isContractInput,
27
28
  type ModelAttributesSpec,
@@ -529,4 +530,4 @@ export type {
529
530
  ModelLike,
530
531
  ScalarFieldBuilder,
531
532
  };
532
- export { field, model, rel };
533
+ export { extensionModel, field, model, rel };
@@ -59,6 +59,12 @@ export interface ForeignKeyNode {
59
59
  * know the target namespace can stamp it explicitly.
60
60
  */
61
61
  readonly namespaceId?: string;
62
+ /**
63
+ * Contract-space identity of the referenced table. When present, the
64
+ * table lives in a different contract space (identified by this value)
65
+ * rather than the current contract. Absent for local FKs.
66
+ */
67
+ readonly spaceId?: string;
62
68
  };
63
69
  readonly name?: string;
64
70
  readonly onDelete?: ReferentialAction;
@@ -72,6 +78,17 @@ export interface RelationNode {
72
78
  readonly toModel: string;
73
79
  readonly toTable: string;
74
80
  readonly cardinality: '1:1' | '1:N' | 'N:1' | 'N:M';
81
+ /**
82
+ * Contract-space identity of the related model. When present, the
83
+ * related model lives in a different contract space. Absent for local
84
+ * (same-space) relations.
85
+ */
86
+ readonly spaceId?: string;
87
+ /**
88
+ * Namespace coordinate of the related model in the foreign space.
89
+ * Only set when `spaceId` is present.
90
+ */
91
+ readonly namespaceId?: string;
75
92
  readonly on: {
76
93
  readonly parentTable: string;
77
94
  readonly parentColumns: readonly string[];
@@ -21,6 +21,7 @@ import type {
21
21
  SqlNamespaceTablesInput,
22
22
  StorageTypeInstance,
23
23
  } from '@prisma-next/sql-contract/types';
24
+ import { blindCast } from '@prisma-next/utils/casts';
24
25
  import { ifDefined } from '@prisma-next/utils/defined';
25
26
  import type { NamedConstraintSpec } from './authoring-type-utils';
26
27
  import type { EnumTypeHandle } from './enum-type';
@@ -157,6 +158,16 @@ export class ScalarFieldBuilder<State extends AnyScalarFieldState = AnyScalarFie
157
158
 
158
159
  constructor(private readonly state: State) {}
159
160
 
161
+ /**
162
+ * Returns the physical column name when `.column(name)` was called, or
163
+ * `undefined` when the field uses the default (logical field name) mapping.
164
+ * Used by cross-space FK lowering to stamp the physical column name onto
165
+ * `TargetFieldRef.columnName` so FK target columns are resolved correctly.
166
+ */
167
+ get physicalColumnName(): string | undefined {
168
+ return this.state.columnName;
169
+ }
170
+
160
171
  optional(): ScalarFieldBuilder<
161
172
  State extends ScalarFieldState<
162
173
  infer CodecId,
@@ -442,6 +453,22 @@ type BelongsToRelation<
442
453
  readonly from: FromField;
443
454
  readonly to: ToField;
444
455
  readonly sql?: SqlSpec;
456
+ /**
457
+ * Contract-space identity of the target model. Populated when
458
+ * `belongsTo` receives a cross-space branded handle. Absent for
459
+ * local (same-space) relations.
460
+ */
461
+ readonly spaceId?: string;
462
+ /**
463
+ * Physical table name of the cross-space target model. Only set
464
+ * when `spaceId` is present; read from the handle's `tableName`.
465
+ */
466
+ readonly tableName?: string;
467
+ /**
468
+ * Namespace coordinate of the cross-space target model.
469
+ * Only set when `spaceId` is present.
470
+ */
471
+ readonly namespaceId?: string;
445
472
  };
446
473
 
447
474
  type HasManyRelation<
@@ -533,27 +560,69 @@ export class RelationBuilder<State extends RelationState = AnyRelationState> {
533
560
  }
534
561
  }
535
562
 
563
+ /**
564
+ * Reference to a column on the current (local) model.
565
+ *
566
+ * Source columns are always local to the contract being authored. The
567
+ * cross-space brand lives on `TargetFieldRef` (the target side of a foreign
568
+ * key), not here.
569
+ */
536
570
  export type ColumnRef<FieldName extends string = string> = {
537
571
  readonly kind: 'columnRef';
538
572
  readonly fieldName: FieldName;
539
573
  };
540
574
 
575
+ /**
576
+ * Reference to a field on a target model, produced by model `.refs` and
577
+ * `constraints.ref(modelName, fieldName)`.
578
+ *
579
+ * The `TSpaceId` phantom parameter carries the contract-space identity of the
580
+ * target model. Local model handles produce `TSpaceId = '<self>'`; extension
581
+ * handles carry the extension's `spaceId`. The brand is propagated from the
582
+ * parent `ContractModelBuilder` via the `spaceId?` property: absent means local
583
+ * (`'<self>'`), present means cross-space.
584
+ */
541
585
  export type TargetFieldRef<
542
586
  ModelName extends string = string,
543
587
  FieldName extends string = string,
544
- Source extends TargetFieldRefSource = TargetFieldRefSource,
588
+ TSpaceId extends string = string,
545
589
  > = {
546
590
  readonly kind: 'targetFieldRef';
547
- readonly source: Source;
591
+ readonly source: TargetFieldRefSource;
548
592
  readonly modelName: ModelName;
549
593
  readonly fieldName: FieldName;
594
+ /**
595
+ * Cross-space discriminator. When present, the referenced model lives in a
596
+ * different contract space identified by this value. Absent for local refs.
597
+ */
598
+ readonly spaceId?: TSpaceId extends '<self>' ? never : TSpaceId;
599
+ /**
600
+ * Namespace id of the cross-space target model (e.g. `'auth'` for
601
+ * `supabase` `auth.User`). Only present for cross-space refs.
602
+ */
603
+ readonly namespaceId?: string;
604
+ /**
605
+ * Physical table name of the cross-space target model. Only present for
606
+ * cross-space refs; allows the lowering path to bypass the local model
607
+ * registry.
608
+ */
609
+ readonly tableName?: string;
610
+ /**
611
+ * Physical column name of the target field. Populated for cross-space refs
612
+ * when the extension handle's field used `.column(name)` to rename the
613
+ * physical column. When absent the logical `fieldName` is used as the column
614
+ * name. Only relevant for cross-space FK lowering — local FKs resolve column
615
+ * names via the local `fieldToColumn` map.
616
+ */
617
+ readonly columnName?: string;
550
618
  };
551
619
 
552
620
  export type ModelTokenRefs<
553
621
  ModelName extends string,
554
622
  Fields extends Record<string, ScalarFieldBuilder>,
623
+ TSpaceId extends string = '<self>',
555
624
  > = {
556
- readonly [K in keyof Fields]: TargetFieldRef<ModelName, K & string>;
625
+ readonly [K in keyof Fields]: TargetFieldRef<ModelName, K & string, TSpaceId>;
557
626
  };
558
627
 
559
628
  type ConstraintOptions<Name extends string | undefined = string | undefined> = {
@@ -625,6 +694,22 @@ export type ForeignKeyConstraint<
625
694
  readonly targetModel: TargetModelName;
626
695
  readonly targetFields: TargetFieldNames;
627
696
  readonly targetSource?: TargetFieldRefSource;
697
+ /**
698
+ * Cross-space discriminator. When present, the FK target lives in a
699
+ * different contract space identified by this value. Absent for local FKs.
700
+ */
701
+ readonly targetSpaceId?: string;
702
+ /**
703
+ * Namespace coordinate of the cross-space target model. Populated when
704
+ * the target model handle carries a `namespace` (e.g. `auth` for supabase
705
+ * `auth.User`). Absent for local FKs.
706
+ */
707
+ readonly targetNamespaceId?: string;
708
+ /**
709
+ * Table name of the cross-space target. Populated for cross-space FKs
710
+ * so the lowering path doesn't need a local model lookup.
711
+ */
712
+ readonly targetTableName?: string;
628
713
  readonly name?: Name;
629
714
  readonly onDelete?: 'noAction' | 'restrict' | 'cascade' | 'setNull' | 'setDefault';
630
715
  readonly onUpdate?: 'noAction' | 'restrict' | 'cascade' | 'setNull' | 'setDefault';
@@ -640,6 +725,9 @@ function normalizeTargetFieldRefInput(input: TargetFieldRef | readonly TargetFie
640
725
  readonly modelName: string;
641
726
  readonly fieldNames: readonly string[];
642
727
  readonly source: TargetFieldRefSource;
728
+ readonly spaceId: string | undefined;
729
+ readonly namespaceId: string | undefined;
730
+ readonly tableName: string | undefined;
643
731
  } {
644
732
  const refs = Array.isArray(input) ? input : [input];
645
733
  const [first] = refs;
@@ -649,10 +737,33 @@ function normalizeTargetFieldRefInput(input: TargetFieldRef | readonly TargetFie
649
737
  if (refs.some((ref) => ref.modelName !== first.modelName)) {
650
738
  throw new Error('All target refs in a foreign key must point to the same model');
651
739
  }
740
+ // F-compound: all refs in a compound FK must share the same cross-space coordinate.
741
+ // A mismatch in spaceId, namespaceId, or tableName means the refs come from
742
+ // different spaces despite having the same modelName — an impossible FK.
743
+ if (refs.some((ref) => ref.spaceId !== first.spaceId)) {
744
+ throw new Error(
745
+ `All target refs in a compound foreign key must share the same spaceId (found mismatch: "${first.spaceId ?? '<local>'}" vs "${refs.find((r) => r.spaceId !== first.spaceId)?.spaceId ?? '<local>'}")`,
746
+ );
747
+ }
748
+ if (refs.some((ref) => ref.namespaceId !== first.namespaceId)) {
749
+ throw new Error(
750
+ 'All target refs in a compound foreign key must share the same namespaceId (found mismatch)',
751
+ );
752
+ }
753
+ if (refs.some((ref) => ref.tableName !== first.tableName)) {
754
+ throw new Error(
755
+ 'All target refs in a compound foreign key must share the same tableName (found mismatch)',
756
+ );
757
+ }
652
758
  return {
653
759
  modelName: first.modelName,
654
- fieldNames: refs.map((ref) => ref.fieldName),
760
+ // F-col: for cross-space refs, prefer the physical column name (columnName) over
761
+ // the logical field name. Local refs have no columnName and use fieldName directly.
762
+ fieldNames: refs.map((ref) => ref.columnName ?? ref.fieldName),
655
763
  source: refs.some((ref) => ref.source === 'string') ? 'string' : 'token',
764
+ spaceId: first.spaceId,
765
+ namespaceId: first.namespaceId,
766
+ tableName: first.tableName,
656
767
  };
657
768
  }
658
769
 
@@ -772,6 +883,15 @@ function createConstraintsDsl<IndexTypes extends IndexTypeMap = Record<never, ne
772
883
  targetModel: normalizedTarget.modelName,
773
884
  targetFields: normalizedTarget.fieldNames,
774
885
  targetSource: normalizedTarget.source,
886
+ ...(normalizedTarget.spaceId !== undefined
887
+ ? { targetSpaceId: normalizedTarget.spaceId }
888
+ : {}),
889
+ ...(normalizedTarget.namespaceId !== undefined
890
+ ? { targetNamespaceId: normalizedTarget.namespaceId }
891
+ : {}),
892
+ ...(normalizedTarget.tableName !== undefined
893
+ ? { targetTableName: normalizedTarget.tableName }
894
+ : {}),
775
895
  ...(options?.name ? { name: options.name } : {}),
776
896
  ...(options?.onDelete ? { onDelete: options.onDelete } : {}),
777
897
  ...(options?.onUpdate ? { onUpdate: options.onUpdate } : {}),
@@ -847,17 +967,40 @@ function createFieldRefs<Fields extends Record<string, ScalarFieldBuilder>>(
847
967
  function createModelTokenRefs<
848
968
  ModelName extends string,
849
969
  Fields extends Record<string, ScalarFieldBuilder>,
850
- >(modelName: ModelName, fields: Fields): ModelTokenRefs<ModelName, Fields> {
970
+ TSpaceId extends string = '<self>',
971
+ >(
972
+ modelName: ModelName,
973
+ fields: Fields,
974
+ crossSpaceCoordinate?: {
975
+ readonly spaceId: TSpaceId;
976
+ readonly namespaceId?: string;
977
+ readonly tableName?: string;
978
+ },
979
+ ): ModelTokenRefs<ModelName, Fields, TSpaceId> {
851
980
  const refs = {} as Record<string, TargetFieldRef>;
852
- for (const fieldName of Object.keys(fields)) {
981
+ for (const [fieldName, fieldBuilder] of Object.entries(fields)) {
982
+ const physicalColumn =
983
+ crossSpaceCoordinate !== undefined ? fieldBuilder.physicalColumnName : undefined;
853
984
  refs[fieldName] = {
854
985
  kind: 'targetFieldRef',
855
986
  source: 'token',
856
987
  modelName,
857
988
  fieldName,
989
+ ...(crossSpaceCoordinate !== undefined
990
+ ? {
991
+ spaceId: crossSpaceCoordinate.spaceId,
992
+ ...(crossSpaceCoordinate.namespaceId !== undefined
993
+ ? { namespaceId: crossSpaceCoordinate.namespaceId }
994
+ : {}),
995
+ ...(crossSpaceCoordinate.tableName !== undefined
996
+ ? { tableName: crossSpaceCoordinate.tableName }
997
+ : {}),
998
+ ...(physicalColumn !== undefined ? { columnName: physicalColumn } : {}),
999
+ }
1000
+ : {}),
858
1001
  };
859
1002
  }
860
- return refs as ModelTokenRefs<ModelName, Fields>;
1003
+ return refs as ModelTokenRefs<ModelName, Fields, TSpaceId>;
861
1004
  }
862
1005
 
863
1006
  type StageInput<Context, Spec> = Spec | ((context: Context) => Spec);
@@ -1002,6 +1145,7 @@ export class ContractModelBuilder<
1002
1145
  AttributesSpec extends ModelAttributesSpec | undefined = undefined,
1003
1146
  SqlSpec extends SqlStageSpec | undefined = undefined,
1004
1147
  IndexTypes extends IndexTypeMap = Record<never, never>,
1148
+ TSpaceId extends string = '<self>',
1005
1149
  > {
1006
1150
  declare readonly __name: ModelName;
1007
1151
  declare readonly __fields: Fields;
@@ -1009,7 +1153,8 @@ export class ContractModelBuilder<
1009
1153
  declare readonly __attributes: AttributesSpec;
1010
1154
  declare readonly __sql: SqlSpec;
1011
1155
  declare readonly __indexTypes: IndexTypes;
1012
- readonly refs: ModelName extends string ? ModelTokenRefs<ModelName, Fields> : never;
1156
+ declare readonly __spaceId: TSpaceId;
1157
+ readonly refs: ModelName extends string ? ModelTokenRefs<ModelName, Fields, TSpaceId> : never;
1013
1158
 
1014
1159
  constructor(
1015
1160
  readonly stageOne: {
@@ -1020,10 +1165,25 @@ export class ContractModelBuilder<
1020
1165
  },
1021
1166
  readonly attributesFactory?: StageInput<AttributeContext<Fields>, AttributesSpec>,
1022
1167
  readonly sqlFactory?: StageInput<SqlContext<Fields, IndexTypes>, SqlSpec>,
1168
+ readonly spaceId?: TSpaceId,
1169
+ readonly tableName?: string,
1023
1170
  ) {
1024
- this.refs = (
1025
- stageOne.modelName ? createModelTokenRefs(stageOne.modelName, stageOne.fields) : undefined
1026
- ) as ModelName extends string ? ModelTokenRefs<ModelName, Fields> : never;
1171
+ const crossSpaceCoordinate =
1172
+ spaceId !== undefined
1173
+ ? {
1174
+ spaceId,
1175
+ ...(stageOne.namespace !== undefined ? { namespaceId: stageOne.namespace } : {}),
1176
+ ...(tableName !== undefined ? { tableName } : {}),
1177
+ }
1178
+ : undefined;
1179
+ this.refs = blindCast<
1180
+ ModelName extends string ? ModelTokenRefs<ModelName, Fields, TSpaceId> : never,
1181
+ 'conditional generic: stageOne.modelName presence matches ModelName extends string'
1182
+ >(
1183
+ stageOne.modelName
1184
+ ? createModelTokenRefs(stageOne.modelName, stageOne.fields, crossSpaceCoordinate)
1185
+ : undefined,
1186
+ );
1027
1187
  }
1028
1188
 
1029
1189
  ref<FieldName extends keyof Fields & string>(
@@ -1053,7 +1213,8 @@ export class ContractModelBuilder<
1053
1213
  Relations & NextRelations,
1054
1214
  AttributesSpec,
1055
1215
  SqlSpec,
1056
- IndexTypes
1216
+ IndexTypes,
1217
+ TSpaceId
1057
1218
  > {
1058
1219
  const duplicateRelationName = findDuplicateRelationName(this.stageOne.relations, relations);
1059
1220
  if (duplicateRelationName) {
@@ -1072,6 +1233,8 @@ export class ContractModelBuilder<
1072
1233
  },
1073
1234
  this.attributesFactory,
1074
1235
  this.sqlFactory,
1236
+ this.spaceId,
1237
+ this.tableName,
1075
1238
  );
1076
1239
  }
1077
1240
 
@@ -1080,17 +1243,61 @@ export class ContractModelBuilder<
1080
1243
  AttributeContext<Fields>,
1081
1244
  ValidateAttributesStageSpec<Fields, SqlSpec, NextAttributesSpec>
1082
1245
  >,
1083
- ): ContractModelBuilder<ModelName, Fields, Relations, NextAttributesSpec, SqlSpec, IndexTypes> {
1084
- return new ContractModelBuilder(this.stageOne, specOrFactory, this.sqlFactory);
1246
+ ): ContractModelBuilder<
1247
+ ModelName,
1248
+ Fields,
1249
+ Relations,
1250
+ NextAttributesSpec,
1251
+ SqlSpec,
1252
+ IndexTypes,
1253
+ TSpaceId
1254
+ > {
1255
+ return new ContractModelBuilder(
1256
+ this.stageOne,
1257
+ specOrFactory,
1258
+ this.sqlFactory,
1259
+ this.spaceId,
1260
+ this.tableName,
1261
+ );
1085
1262
  }
1086
1263
 
1087
1264
  sql<const NextSqlSpec extends SqlStageSpec>(
1088
1265
  specOrFactory: StageInput<SqlContext<Fields, IndexTypes>, NextSqlSpec>,
1089
1266
  ): [ValidateSqlStageSpec<Fields, AttributesSpec, NextSqlSpec>] extends [never]
1090
- ? ContractModelBuilder<ModelName, Fields, Relations, AttributesSpec, never, IndexTypes>
1091
- : ContractModelBuilder<ModelName, Fields, Relations, AttributesSpec, NextSqlSpec, IndexTypes> {
1267
+ ? ContractModelBuilder<
1268
+ ModelName,
1269
+ Fields,
1270
+ Relations,
1271
+ AttributesSpec,
1272
+ never,
1273
+ IndexTypes,
1274
+ TSpaceId
1275
+ >
1276
+ : ContractModelBuilder<
1277
+ ModelName,
1278
+ Fields,
1279
+ Relations,
1280
+ AttributesSpec,
1281
+ NextSqlSpec,
1282
+ IndexTypes,
1283
+ TSpaceId
1284
+ > {
1092
1285
  // Conditional return type cannot be verified by the implementation; the runtime value is always a valid ContractModelBuilder regardless of the validation outcome (validation is type-level only).
1093
- return new ContractModelBuilder(this.stageOne, this.attributesFactory, specOrFactory) as never;
1286
+ // When specOrFactory is a static object (not a function), extract tableName for the cross-space coordinate.
1287
+ const nextTableName =
1288
+ typeof specOrFactory !== 'function' ? specOrFactory.table : this.tableName;
1289
+ return blindCast<
1290
+ never,
1291
+ 'conditional return type; runtime value is always a valid ContractModelBuilder'
1292
+ >(
1293
+ new ContractModelBuilder(
1294
+ this.stageOne,
1295
+ this.attributesFactory,
1296
+ specOrFactory,
1297
+ this.spaceId,
1298
+ nextTableName,
1299
+ ),
1300
+ );
1094
1301
  }
1095
1302
 
1096
1303
  buildAttributesSpec(): AttributesSpec {
@@ -1341,6 +1548,86 @@ export function model<
1341
1548
  });
1342
1549
  }
1343
1550
 
1551
+ /**
1552
+ * Factory for building a standalone branded extension model handle.
1553
+ *
1554
+ * Use this instead of `new ContractModelBuilder(…)` when constructing handles
1555
+ * for models that live in a foreign contract space (e.g. a Supabase extension
1556
+ * model referenced by a user's contract). The `spaceId` brands the returned
1557
+ * handle so `refs.<field>.spaceId` carries the foreign space identifier.
1558
+ *
1559
+ * @param name - The domain model name as declared in the foreign contract
1560
+ * (e.g. `'AuthUser'`, not a bare table alias like `'User'`).
1561
+ * @param input.namespace - The namespace within the foreign space (e.g. `'auth'`).
1562
+ * @param input.fields - Field definitions (use `field.column(…)`).
1563
+ * @param input.table - The physical table name in the foreign schema.
1564
+ * @param spaceId - The extension space identifier (e.g. `'supabase'`).
1565
+ */
1566
+ export function extensionModel<
1567
+ const ModelName extends string,
1568
+ Fields extends Record<string, ScalarFieldBuilder>,
1569
+ const TSpaceId extends string,
1570
+ >(
1571
+ name: ModelName,
1572
+ input: {
1573
+ readonly namespace: string;
1574
+ readonly fields: Fields;
1575
+ readonly table: string;
1576
+ },
1577
+ spaceId: TSpaceId,
1578
+ ): ContractModelBuilder<
1579
+ ModelName,
1580
+ Fields,
1581
+ Record<never, never>,
1582
+ undefined,
1583
+ undefined,
1584
+ Record<never, never>,
1585
+ TSpaceId
1586
+ > {
1587
+ const builder = new ContractModelBuilder<
1588
+ ModelName,
1589
+ Fields,
1590
+ Record<never, never>,
1591
+ undefined,
1592
+ undefined,
1593
+ Record<never, never>,
1594
+ TSpaceId
1595
+ >(
1596
+ { modelName: name, namespace: input.namespace, fields: input.fields, relations: {} },
1597
+ undefined,
1598
+ undefined,
1599
+ spaceId,
1600
+ input.table,
1601
+ );
1602
+ return builder;
1603
+ }
1604
+
1605
+ /**
1606
+ * Narrow shape for detecting a cross-space branded model handle at runtime.
1607
+ * `ContractModelBuilder` exposes these fields but `AnyNamedModelToken` does
1608
+ * not declare them; this guard bridges the gap without a bare cast.
1609
+ */
1610
+ type CrossSpaceHandle = {
1611
+ readonly spaceId: string;
1612
+ readonly tableName?: string;
1613
+ readonly stageOne: { readonly namespace?: string };
1614
+ };
1615
+
1616
+ function isCrossSpaceHandle(value: unknown): value is CrossSpaceHandle {
1617
+ if (typeof value !== 'object' || value === null) {
1618
+ return false;
1619
+ }
1620
+ const rec = blindCast<
1621
+ Record<PropertyKey, unknown>,
1622
+ 'object null-check above; property access needed for runtime shape detection'
1623
+ >(value);
1624
+ return (
1625
+ typeof rec['spaceId'] === 'string' &&
1626
+ typeof rec['stageOne'] === 'object' &&
1627
+ rec['stageOne'] !== null
1628
+ );
1629
+ }
1630
+
1344
1631
  function belongsTo<
1345
1632
  Token extends AnyNamedModelToken,
1346
1633
  FromField extends string | readonly string[],
@@ -1364,11 +1651,31 @@ function belongsTo(
1364
1651
  readonly to: string | readonly string[];
1365
1652
  },
1366
1653
  ): RelationBuilder<BelongsToRelation> {
1654
+ // F-lazy: when the model is a lazy thunk (() => handle), resolve it before
1655
+ // the brand check so cross-space handles passed as lazy tokens are detected.
1656
+ // normalizeRelationModelSource still receives the original toModel (which
1657
+ // handles both lazy and non-lazy forms correctly for model-name resolution).
1658
+ const resolvedModel = typeof toModel === 'function' ? toModel() : toModel;
1659
+
1660
+ // Extract cross-space brand from the handle when it carries a spaceId.
1661
+ // ContractModelBuilder exposes spaceId/tableName at runtime even though
1662
+ // the AnyNamedModelToken interface does not declare them.
1663
+ const crossSpaceCoordinate = isCrossSpaceHandle(resolvedModel)
1664
+ ? {
1665
+ spaceId: resolvedModel.spaceId,
1666
+ ...(resolvedModel.tableName !== undefined ? { tableName: resolvedModel.tableName } : {}),
1667
+ ...(resolvedModel.stageOne.namespace !== undefined
1668
+ ? { namespaceId: resolvedModel.stageOne.namespace }
1669
+ : {}),
1670
+ }
1671
+ : undefined;
1672
+
1367
1673
  return new RelationBuilder({
1368
1674
  kind: 'belongsTo',
1369
1675
  toModel: normalizeRelationModelSource(toModel),
1370
1676
  from: options.from,
1371
1677
  to: options.to,
1678
+ ...(crossSpaceCoordinate !== undefined ? crossSpaceCoordinate : {}),
1372
1679
  });
1373
1680
  }
1374
1681