@prisma-next/sql-contract-ts 0.12.0 → 0.13.0-dev.2

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.
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  ColumnDefault,
3
3
  ColumnDefaultLiteralInputValue,
4
+ ControlPolicy,
4
5
  ExecutionMutationDefaultPhases,
5
6
  ExecutionMutationDefaultValue,
6
7
  } from '@prisma-next/contract/types';
@@ -20,8 +21,11 @@ import type {
20
21
  SqlNamespaceTablesInput,
21
22
  StorageTypeInstance,
22
23
  } from '@prisma-next/sql-contract/types';
24
+ import { blindCast } from '@prisma-next/utils/casts';
23
25
  import { ifDefined } from '@prisma-next/utils/defined';
24
26
  import type { NamedConstraintSpec } from './authoring-type-utils';
27
+ import type { EnumTypeHandle } from './enum-type';
28
+ import { isEnumTypeHandle } from './enum-type';
25
29
 
26
30
  export type NamingStrategy = 'identity' | 'snake_case';
27
31
 
@@ -30,7 +34,7 @@ export type NamingConfig = {
30
34
  readonly columns?: NamingStrategy;
31
35
  };
32
36
 
33
- type NamedStorageTypeRef = string | StorageTypeInstance | PostgresEnumStorageEntry;
37
+ type NamedStorageTypeRef = string | StorageTypeInstance | PostgresEnumStorageEntry | EnumTypeHandle;
34
38
 
35
39
  type NamedConstraintNameSpec<Name extends string = string> = {
36
40
  readonly name: Name;
@@ -154,6 +158,16 @@ export class ScalarFieldBuilder<State extends AnyScalarFieldState = AnyScalarFie
154
158
 
155
159
  constructor(private readonly state: State) {}
156
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
+
157
171
  optional(): ScalarFieldBuilder<
158
172
  State extends ScalarFieldState<
159
173
  infer CodecId,
@@ -357,9 +371,19 @@ function namedTypeField<TypeRef extends StorageTypeInstance>(
357
371
  function namedTypeField<TypeRef extends PostgresEnumStorageEntry>(
358
372
  typeRef: TypeRef,
359
373
  ): ScalarFieldBuilder<ScalarFieldState<string, TypeRef, false, undefined>>;
374
+ function namedTypeField<Handle extends EnumTypeHandle>(
375
+ typeRef: Handle,
376
+ ): ScalarFieldBuilder<ScalarFieldState<Handle['codecId'], Handle, false, undefined>>;
360
377
  function namedTypeField(
361
378
  typeRef: NamedStorageTypeRef,
362
379
  ): ScalarFieldBuilder<ScalarFieldState<string, NamedStorageTypeRef, false, undefined>> {
380
+ if (isEnumTypeHandle(typeRef)) {
381
+ return new ScalarFieldBuilder({
382
+ kind: 'scalar',
383
+ typeRef,
384
+ nullable: false,
385
+ });
386
+ }
363
387
  return new ScalarFieldBuilder({
364
388
  kind: 'scalar',
365
389
  typeRef,
@@ -429,6 +453,22 @@ type BelongsToRelation<
429
453
  readonly from: FromField;
430
454
  readonly to: ToField;
431
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;
432
472
  };
433
473
 
434
474
  type HasManyRelation<
@@ -520,27 +560,69 @@ export class RelationBuilder<State extends RelationState = AnyRelationState> {
520
560
  }
521
561
  }
522
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
+ */
523
570
  export type ColumnRef<FieldName extends string = string> = {
524
571
  readonly kind: 'columnRef';
525
572
  readonly fieldName: FieldName;
526
573
  };
527
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
+ */
528
585
  export type TargetFieldRef<
529
586
  ModelName extends string = string,
530
587
  FieldName extends string = string,
531
- Source extends TargetFieldRefSource = TargetFieldRefSource,
588
+ TSpaceId extends string = string,
532
589
  > = {
533
590
  readonly kind: 'targetFieldRef';
534
- readonly source: Source;
591
+ readonly source: TargetFieldRefSource;
535
592
  readonly modelName: ModelName;
536
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;
537
618
  };
538
619
 
539
620
  export type ModelTokenRefs<
540
621
  ModelName extends string,
541
622
  Fields extends Record<string, ScalarFieldBuilder>,
623
+ TSpaceId extends string = '<self>',
542
624
  > = {
543
- readonly [K in keyof Fields]: TargetFieldRef<ModelName, K & string>;
625
+ readonly [K in keyof Fields]: TargetFieldRef<ModelName, K & string, TSpaceId>;
544
626
  };
545
627
 
546
628
  type ConstraintOptions<Name extends string | undefined = string | undefined> = {
@@ -612,6 +694,22 @@ export type ForeignKeyConstraint<
612
694
  readonly targetModel: TargetModelName;
613
695
  readonly targetFields: TargetFieldNames;
614
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;
615
713
  readonly name?: Name;
616
714
  readonly onDelete?: 'noAction' | 'restrict' | 'cascade' | 'setNull' | 'setDefault';
617
715
  readonly onUpdate?: 'noAction' | 'restrict' | 'cascade' | 'setNull' | 'setDefault';
@@ -627,6 +725,9 @@ function normalizeTargetFieldRefInput(input: TargetFieldRef | readonly TargetFie
627
725
  readonly modelName: string;
628
726
  readonly fieldNames: readonly string[];
629
727
  readonly source: TargetFieldRefSource;
728
+ readonly spaceId: string | undefined;
729
+ readonly namespaceId: string | undefined;
730
+ readonly tableName: string | undefined;
630
731
  } {
631
732
  const refs = Array.isArray(input) ? input : [input];
632
733
  const [first] = refs;
@@ -636,10 +737,33 @@ function normalizeTargetFieldRefInput(input: TargetFieldRef | readonly TargetFie
636
737
  if (refs.some((ref) => ref.modelName !== first.modelName)) {
637
738
  throw new Error('All target refs in a foreign key must point to the same model');
638
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
+ }
639
758
  return {
640
759
  modelName: first.modelName,
641
- 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),
642
763
  source: refs.some((ref) => ref.source === 'string') ? 'string' : 'token',
764
+ spaceId: first.spaceId,
765
+ namespaceId: first.namespaceId,
766
+ tableName: first.tableName,
643
767
  };
644
768
  }
645
769
 
@@ -759,6 +883,15 @@ function createConstraintsDsl<IndexTypes extends IndexTypeMap = Record<never, ne
759
883
  targetModel: normalizedTarget.modelName,
760
884
  targetFields: normalizedTarget.fieldNames,
761
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
+ : {}),
762
895
  ...(options?.name ? { name: options.name } : {}),
763
896
  ...(options?.onDelete ? { onDelete: options.onDelete } : {}),
764
897
  ...(options?.onUpdate ? { onUpdate: options.onUpdate } : {}),
@@ -785,6 +918,7 @@ export type ModelAttributesSpec = {
785
918
 
786
919
  export type SqlStageSpec = {
787
920
  readonly table?: string;
921
+ readonly control?: ControlPolicy;
788
922
  readonly indexes?: readonly IndexConstraint[];
789
923
  readonly foreignKeys?: readonly ForeignKeyConstraint[];
790
924
  };
@@ -833,17 +967,40 @@ function createFieldRefs<Fields extends Record<string, ScalarFieldBuilder>>(
833
967
  function createModelTokenRefs<
834
968
  ModelName extends string,
835
969
  Fields extends Record<string, ScalarFieldBuilder>,
836
- >(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> {
837
980
  const refs = {} as Record<string, TargetFieldRef>;
838
- 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;
839
984
  refs[fieldName] = {
840
985
  kind: 'targetFieldRef',
841
986
  source: 'token',
842
987
  modelName,
843
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
+ : {}),
844
1001
  };
845
1002
  }
846
- return refs as ModelTokenRefs<ModelName, Fields>;
1003
+ return refs as ModelTokenRefs<ModelName, Fields, TSpaceId>;
847
1004
  }
848
1005
 
849
1006
  type StageInput<Context, Spec> = Spec | ((context: Context) => Spec);
@@ -988,6 +1145,7 @@ export class ContractModelBuilder<
988
1145
  AttributesSpec extends ModelAttributesSpec | undefined = undefined,
989
1146
  SqlSpec extends SqlStageSpec | undefined = undefined,
990
1147
  IndexTypes extends IndexTypeMap = Record<never, never>,
1148
+ TSpaceId extends string = '<self>',
991
1149
  > {
992
1150
  declare readonly __name: ModelName;
993
1151
  declare readonly __fields: Fields;
@@ -995,7 +1153,8 @@ export class ContractModelBuilder<
995
1153
  declare readonly __attributes: AttributesSpec;
996
1154
  declare readonly __sql: SqlSpec;
997
1155
  declare readonly __indexTypes: IndexTypes;
998
- 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;
999
1158
 
1000
1159
  constructor(
1001
1160
  readonly stageOne: {
@@ -1006,10 +1165,25 @@ export class ContractModelBuilder<
1006
1165
  },
1007
1166
  readonly attributesFactory?: StageInput<AttributeContext<Fields>, AttributesSpec>,
1008
1167
  readonly sqlFactory?: StageInput<SqlContext<Fields, IndexTypes>, SqlSpec>,
1168
+ readonly spaceId?: TSpaceId,
1169
+ readonly tableName?: string,
1009
1170
  ) {
1010
- this.refs = (
1011
- stageOne.modelName ? createModelTokenRefs(stageOne.modelName, stageOne.fields) : undefined
1012
- ) 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
+ );
1013
1187
  }
1014
1188
 
1015
1189
  ref<FieldName extends keyof Fields & string>(
@@ -1039,7 +1213,8 @@ export class ContractModelBuilder<
1039
1213
  Relations & NextRelations,
1040
1214
  AttributesSpec,
1041
1215
  SqlSpec,
1042
- IndexTypes
1216
+ IndexTypes,
1217
+ TSpaceId
1043
1218
  > {
1044
1219
  const duplicateRelationName = findDuplicateRelationName(this.stageOne.relations, relations);
1045
1220
  if (duplicateRelationName) {
@@ -1058,6 +1233,8 @@ export class ContractModelBuilder<
1058
1233
  },
1059
1234
  this.attributesFactory,
1060
1235
  this.sqlFactory,
1236
+ this.spaceId,
1237
+ this.tableName,
1061
1238
  );
1062
1239
  }
1063
1240
 
@@ -1066,17 +1243,61 @@ export class ContractModelBuilder<
1066
1243
  AttributeContext<Fields>,
1067
1244
  ValidateAttributesStageSpec<Fields, SqlSpec, NextAttributesSpec>
1068
1245
  >,
1069
- ): ContractModelBuilder<ModelName, Fields, Relations, NextAttributesSpec, SqlSpec, IndexTypes> {
1070
- 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
+ );
1071
1262
  }
1072
1263
 
1073
1264
  sql<const NextSqlSpec extends SqlStageSpec>(
1074
1265
  specOrFactory: StageInput<SqlContext<Fields, IndexTypes>, NextSqlSpec>,
1075
1266
  ): [ValidateSqlStageSpec<Fields, AttributesSpec, NextSqlSpec>] extends [never]
1076
- ? ContractModelBuilder<ModelName, Fields, Relations, AttributesSpec, never, IndexTypes>
1077
- : 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
+ > {
1078
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).
1079
- 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
+ );
1080
1301
  }
1081
1302
 
1082
1303
  buildAttributesSpec(): AttributesSpec {
@@ -1216,6 +1437,7 @@ export type ContractInput<
1216
1437
  readonly naming?: NamingConfig;
1217
1438
  readonly storageHash?: string;
1218
1439
  readonly foreignKeyDefaults?: ForeignKeyDefaultsState;
1440
+ readonly defaultControlPolicy?: ControlPolicy;
1219
1441
  /**
1220
1442
  * Declared namespace coordinates the contract recognises. Per-model
1221
1443
  * `namespace` references must reference an entry in this list (or the
@@ -1264,6 +1486,12 @@ export type ContractInput<
1264
1486
  readonly types?: Types;
1265
1487
  readonly models?: Models;
1266
1488
  readonly codecLookup?: CodecLookup;
1489
+ /**
1490
+ * Domain enum handles authored via `enumType()`. Each handle lowers to a
1491
+ * domain `enum` entry and a storage `valueSet` entry in the target's
1492
+ * default namespace. Fields reference the enum via `field.namedType(handle)`.
1493
+ */
1494
+ readonly enums?: Record<string, import('./enum-type').EnumTypeHandle>;
1267
1495
  };
1268
1496
 
1269
1497
  export function model<
@@ -1320,6 +1548,86 @@ export function model<
1320
1548
  });
1321
1549
  }
1322
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
+
1323
1631
  function belongsTo<
1324
1632
  Token extends AnyNamedModelToken,
1325
1633
  FromField extends string | readonly string[],
@@ -1343,11 +1651,31 @@ function belongsTo(
1343
1651
  readonly to: string | readonly string[];
1344
1652
  },
1345
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
+
1346
1673
  return new RelationBuilder({
1347
1674
  kind: 'belongsTo',
1348
1675
  toModel: normalizeRelationModelSource(toModel),
1349
1676
  from: options.from,
1350
1677
  to: options.to,
1678
+ ...(crossSpaceCoordinate !== undefined ? crossSpaceCoordinate : {}),
1351
1679
  });
1352
1680
  }
1353
1681