fimo 0.4.0-experimental.4 → 0.4.0-staging.1

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.
Files changed (33) hide show
  1. package/assets/content-templates/hooks-template.eta +66 -27
  2. package/assets/content-templates/types-template.eta +34 -15
  3. package/assets/skills/fimo/references/content.md +52 -35
  4. package/assets/skills/fimo-cli/references/content.md +12 -8
  5. package/assets/skills/fimo-migration/SKILL.md +1 -1
  6. package/assets/skills/fimo-migration/references/content.md +4 -4
  7. package/assets/skills/fimo-migration/references/import-contract.md +1 -1
  8. package/assets/skills/fimo-migration/references/manifest.md +1 -1
  9. package/assets/skills/fimo-migration/references/platforms.md +1 -1
  10. package/assets/skills/fimo-migration/references/workflow.md +1 -1
  11. package/assets/skills/fimo-migration/scripts/build-canonical-manifest.mjs +2 -2
  12. package/dist/cli/bundle.json +1 -1
  13. package/dist/cli/index.js +121 -23
  14. package/dist/runtime/primitives/index.d.ts +2 -2
  15. package/dist/runtime/primitives/index.d.ts.map +1 -1
  16. package/dist/runtime/primitives/lib/index.d.ts +2 -2
  17. package/dist/runtime/primitives/lib/index.d.ts.map +1 -1
  18. package/dist/runtime/primitives/lib/primitives.d.ts +5 -0
  19. package/dist/runtime/primitives/lib/primitives.d.ts.map +1 -1
  20. package/dist/runtime/primitives/lib/query.d.ts +12 -12
  21. package/dist/runtime/primitives/lib/query.d.ts.map +1 -1
  22. package/dist/runtime/primitives/public-surface.test.js +4 -4
  23. package/dist/scripts/generated-types.test.d.ts +1 -0
  24. package/dist/scripts/generated-types.test.js +172 -0
  25. package/dist/scripts/sync-schemas.js +25 -6
  26. package/dist/scripts/sync-schemas.test.js +10 -10
  27. package/dist/scripts/validate-schemas.js +48 -13
  28. package/dist/scripts/validate-schemas.test.js +13 -13
  29. package/package.json +1 -1
  30. package/scripts/lib/release-core.mjs +24 -0
  31. package/scripts/lib/release-core.test.ts +37 -2
  32. package/scripts/publish-npm.mjs +4 -9
  33. package/templates/react-router/package.json +1 -1
@@ -1,9 +1,10 @@
1
1
  <%
2
2
  /**
3
3
  * INPUT ▸ model = {
4
- * uid: "api::blog.blog", // UID in format "api::name.name"
5
- * pascal: "Blog" // PascalCase for type names
6
- * fields: {key: {type, required?, multiple?}} // Schema fields
4
+ * uid: "Post", // matches src/schemas/<uid>.json (letters, numbers, underscores)
5
+ * pascal: "Post", // pascalCase(uid), used for generated type names
6
+ * fields: {key: {type, required?, multiple?, many?, target?, targetPascal?}},
7
+ * relationImports: [{uid, pascal}] // relation targets other than this schema
7
8
  * }
8
9
  */
9
10
  %>
@@ -14,15 +15,15 @@ import { FimoString, FimoMedia, FimoBoolean, FimoRichText, useLocale } from "fim
14
15
  import fimoConfig from "virtual:fimo-config";
15
16
  import type { <%= model.pascal %>, <%= model.pascal %>Input, <%= model.pascal %>Populate, <%= model.pascal %>Result<% if (!model.isSingleton) { %>, <%= model.pascal %>Fields, <%= model.pascal %>Query<% } %> } from "./<%= model.pascal %>.types";
16
17
  <%
17
- if (model.relationImports && model.relationImports.length > 0) {
18
- model.relationImports.forEach((relationImport) => {
18
+ if (model.referenceImports && model.referenceImports.length > 0) {
19
+ model.referenceImports.forEach((referenceImport) => {
19
20
  -%>
20
- import { wrapWithSource as wrap<%= relationImport.pascal %>WithSource } from "./<%= relationImport.pascal %>";
21
+ import { wrapWithSource as wrap<%= referenceImport.pascal %>WithSource } from "./<%= referenceImport.pascal %>";
21
22
  <%
22
23
  });
23
24
  }
24
25
  -%>
25
- export type { <%= model.pascal %>, <%= model.pascal %>Fields, <%= model.pascal %>FimoMeta, <%= model.pascal %>Input, <%= model.pascal %>Populate, <%= model.pascal %>Query, <%= model.pascal %>Relations, <%= model.pascal %>Result, <%= model.pascal %>Sort, <%= model.pascal %>Where } from "./<%= model.pascal %>.types";
26
+ export type { <%= model.pascal %>, <%= model.pascal %>Fields, <%= model.pascal %>FimoMeta, <%= model.pascal %>Input, <%= model.pascal %>Populate, <%= model.pascal %>Query, <%= model.pascal %>References, <%= model.pascal %>Result, <%= model.pascal %>Sort, <%= model.pascal %>Where } from "./<%= model.pascal %>.types";
26
27
 
27
28
  /*───────────────────────────────────────────────────────────────*/
28
29
  /* ──────────── 🎁 Source wrapping ──────────── */
@@ -79,7 +80,21 @@ if (model.fields) {
79
80
  <%
80
81
  }
81
82
  break;
82
- case 'relation': {
83
+ case 'reference': {
84
+ if (Array.isArray(field.targetRefs) && field.targetRefs.length > 0) {
85
+ const wrapperFor = (ref) => ref.uid === model.uid ? 'wrapWithSource' : `wrap${ref.pascal}WithSource`;
86
+ const wrappers = `{ ${field.targetRefs.map((ref) => `"${ref.uid}": ${wrapperFor(ref)}`).join(', ')} }`;
87
+ if (field.many) {
88
+ -%>
89
+ <%= key %>: Array.isArray(entry.<%= key %>) ? entry.<%= key %>.map((value: any) => wrapPolymorphic(value, <%= wrappers %>)) : entry.<%= key %>,
90
+ <%
91
+ } else {
92
+ -%>
93
+ <%= key %>: wrapPolymorphic(entry.<%= key %>, <%= wrappers %>),
94
+ <%
95
+ }
96
+ break;
97
+ }
83
98
  const wrapper = field.target === model.uid ? 'wrapWithSource' : `wrap${field.targetPascal}WithSource`;
84
99
  if (field.many) {
85
100
  -%>
@@ -103,6 +118,29 @@ if (model.fields) {
103
118
  } as <%= model.pascal %>;
104
119
  }
105
120
 
121
+ <%
122
+ const hasPolymorphicReferences = model.fields && Object.values(model.fields).some((field) => field.type === 'reference' && Array.isArray(field.targetRefs) && field.targetRefs.length > 0);
123
+ if (hasPolymorphicReferences) {
124
+ -%>
125
+ /**
126
+ * Wraps one polymorphic reference value. Populated entries carry their type
127
+ * marker; the wrapper picks the right per-type wrapping and stamps a
128
+ * top-level $type so the generated union narrows in a switch. Raw
129
+ * (unpopulated) values pass through untouched.
130
+ */
131
+ function wrapPolymorphic(value: any, wrappers: Record<string, (entry: Record<string, any>) => any>): any {
132
+ if (value == null || typeof value !== "object" || typeof value.id !== "string") return value;
133
+ const contentType = value.__fimo && typeof value.__fimo.contentType === "string" ? value.__fimo.contentType : null;
134
+ const wrapper = contentType ? wrappers[contentType] : undefined;
135
+ if (!wrapper) return value;
136
+ const wrapped = wrapper(value) as Record<string, unknown>;
137
+ wrapped.$type = contentType;
138
+ return wrapped;
139
+ }
140
+
141
+ <%
142
+ }
143
+ -%>
106
144
  /*───────────────────────────────────────────────────────────────*/
107
145
  /* ──────────── 🔌 Low-level fetch helpers ──────────── */
108
146
  /*───────────────────────────────────────────────────────────────*/
@@ -119,7 +157,7 @@ function useQueryLocale(locale?: string): string {
119
157
  }
120
158
 
121
159
  const LOCALIZED_CONTENT = <%= model.localized ? 'true' : 'false' %>;
122
- const LOCALIZED_RELATION_FIELDS = new Set<string>(<%= JSON.stringify(model.localizedRelationFields) %>);
160
+ const LOCALIZED_REFERENCE_FIELDS = new Set<string>(<%= JSON.stringify(model.localizedReferenceFields) %>);
123
161
 
124
162
  function populateFields(populate: <%= model.pascal %>Populate | undefined): string[] {
125
163
  if (populate == null) return [];
@@ -131,7 +169,7 @@ function readLocale(
131
169
  ownerUsesLocale: boolean,
132
170
  fallbackLocale = defaultLocale()
133
171
  ): string | undefined {
134
- const populatesLocalizedTarget = populateFields(options.populate).some((field) => LOCALIZED_RELATION_FIELDS.has(field));
172
+ const populatesLocalizedTarget = populateFields(options.populate).some((field) => LOCALIZED_REFERENCE_FIELDS.has(field));
135
173
  return ownerUsesLocale || populatesLocalizedTarget ? options.locale ?? fallbackLocale : undefined;
136
174
  }
137
175
 
@@ -145,7 +183,7 @@ function fimoHeaders(extra?: Record<string, string>): Record<string, string> {
145
183
 
146
184
  function withReadOptions<TPopulate extends <%= model.pascal %>Populate | undefined>(
147
185
  url: string,
148
- options: { locale?: string; populate?: TPopulate } = {},
186
+ options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {},
149
187
  ownerUsesLocale = LOCALIZED_CONTENT
150
188
  ): string {
151
189
  const u = new URL(url, "http://fimo.local");
@@ -154,6 +192,7 @@ function withReadOptions<TPopulate extends <%= model.pascal %>Populate | undefin
154
192
  if (options.populate != null) {
155
193
  u.searchParams.set("populate", typeof options.populate === "string" ? options.populate : options.populate.join(","));
156
194
  }
195
+ if (options.localeFallbacks) u.searchParams.set("localeFallbacks", "true");
157
196
  return `${u.pathname}${u.search}`;
158
197
  }
159
198
 
@@ -178,7 +217,7 @@ function splitWritePayload(payload: Partial<<%= model.pascal %>Input>): Record<s
178
217
  <% if (!model.isSingleton) { -%>
179
218
  export async function getById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(
180
219
  id: string,
181
- options: { locale?: string; populate?: TPopulate } = {}
220
+ options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}
182
221
  ): Promise<<%= model.pascal %>Result<undefined, TPopulate>> {
183
222
  const res = await fetch(`${base}${withReadOptions(`/entries/<%= model.uid %>/${id}`, options, false)}`, { headers: fimoHeaders() });
184
223
  if (!res.ok) throw new Error(await res.text());
@@ -188,7 +227,7 @@ export async function getById<TPopulate extends <%= model.pascal %>Populate | un
188
227
 
189
228
  export async function getBySlug<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(
190
229
  slug: string,
191
- options: { locale?: string; populate?: TPopulate } = {}
230
+ options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}
192
231
  ): Promise<<%= model.pascal %>Result<undefined, TPopulate> | null> {
193
232
  const res = await fetch(`${base}${withReadOptions(`/entries/<%= model.uid %>/slug/${encodeURIComponent(slug)}`, options)}`, { headers: fimoHeaders() });
194
233
  if (res.status === 404) return null;
@@ -200,7 +239,7 @@ export async function getBySlug<TPopulate extends <%= model.pascal %>Populate |
200
239
  export async function getByField<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(
201
240
  fieldName: string,
202
241
  value: string,
203
- options: { locale?: string; populate?: TPopulate } = {}
242
+ options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}
204
243
  ): Promise<<%= model.pascal %>Result<undefined, TPopulate> | null> {
205
244
  const res = await fetch(`${base}${withReadOptions(`/entries/<%= model.uid %>/field/${encodeURIComponent(fieldName)}/${encodeURIComponent(value)}`, options)}`, { headers: fimoHeaders() });
206
245
  if (res.status === 404) return null;
@@ -282,7 +321,7 @@ export async function remove(id: string): Promise<void> {
282
321
  }
283
322
  <% } else { -%>
284
323
  export async function get<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(
285
- options: { locale?: string; populate?: TPopulate } = {}
324
+ options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}
286
325
  ): Promise<<%= model.pascal %>Result<undefined, TPopulate>> {
287
326
  const res = await fetch(`${base}${withReadOptions(`/documents/<%= model.uid %>`, options)}`, { headers: fimoHeaders() });
288
327
  if (!res.ok) throw new Error(await res.text());
@@ -311,25 +350,25 @@ export async function update(payload: Partial<<%= model.pascal %>Input>): Promis
311
350
  <% if (!model.isSingleton) { -%>
312
351
  const qk = {
313
352
  root : ["<%= model.pascal %>"] as const,
314
- byId : <TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate } = {}) => ["<%= model.pascal %>", id, options.locale, options.populate] as const,
315
- bySlug : <TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(slug: string, options: { locale?: string; populate?: TPopulate } = {}) => ["<%= model.pascal %>", "slug", slug, options.locale, options.populate] as const,
316
- byField : <TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(field: string, value: string, options: { locale?: string; populate?: TPopulate } = {}) => ["<%= model.pascal %>", field, value, options.locale, options.populate] as const,
353
+ byId : <TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) => ["<%= model.pascal %>", id, options.locale, options.populate] as const,
354
+ bySlug : <TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(slug: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) => ["<%= model.pascal %>", "slug", slug, options.locale, options.populate] as const,
355
+ byField : <TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(field: string, value: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) => ["<%= model.pascal %>", field, value, options.locale, options.populate] as const,
317
356
  list : (params: <%= model.pascal %>Query) => ["<%= model.pascal %>", params] as const
318
357
  };
319
358
 
320
- export function useGetById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate } = {}) {
359
+ export function useGetById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
321
360
  const locale = readLocale(options, false, useQueryLocale(options.locale));
322
361
  const localizedOptions = { ...options, locale };
323
362
  return useQuery<<%= model.pascal %>Result<undefined, TPopulate>, Error>({ queryKey: qk.byId(id, localizedOptions), queryFn: () => getById(id, localizedOptions) });
324
363
  }
325
364
 
326
- export function useGetBySlug<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(slug: string, options: { locale?: string; populate?: TPopulate } = {}) {
365
+ export function useGetBySlug<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(slug: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
327
366
  const locale = readLocale(options, LOCALIZED_CONTENT, useQueryLocale(options.locale));
328
367
  const localizedOptions = { ...options, locale };
329
368
  return useQuery<<%= model.pascal %>Result<undefined, TPopulate> | null, Error>({ queryKey: qk.bySlug(slug, localizedOptions), queryFn: () => getBySlug(slug, localizedOptions), enabled: !!slug });
330
369
  }
331
370
 
332
- export function useGetByField<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(fieldName: string, value: string, options: { locale?: string; populate?: TPopulate } = {}) {
371
+ export function useGetByField<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(fieldName: string, value: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
333
372
  const locale = readLocale(options, LOCALIZED_CONTENT, useQueryLocale(options.locale));
334
373
  const localizedOptions = { ...options, locale };
335
374
  return useQuery<<%= model.pascal %>Result<undefined, TPopulate> | null, Error>({ queryKey: qk.byField(fieldName, value, localizedOptions), queryFn: () => getByField(fieldName, value, localizedOptions), enabled: !!value });
@@ -344,19 +383,19 @@ export function useGet<
344
383
  return useQuery<<%= model.pascal %>Result<TFields, TPopulate>[], Error>({ queryKey: qk.list(localizedParams), queryFn: () => get(localizedParams) });
345
384
  }
346
385
 
347
- export function useSuspenseGetById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate } = {}) {
386
+ export function useSuspenseGetById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
348
387
  const locale = readLocale(options, false, useQueryLocale(options.locale));
349
388
  const localizedOptions = { ...options, locale };
350
389
  return useSuspenseQuery<<%= model.pascal %>Result<undefined, TPopulate>, Error>({ queryKey: qk.byId(id, localizedOptions), queryFn: () => getById(id, localizedOptions) });
351
390
  }
352
391
 
353
- export function useSuspenseGetBySlug<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(slug: string, options: { locale?: string; populate?: TPopulate } = {}) {
392
+ export function useSuspenseGetBySlug<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(slug: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
354
393
  const locale = readLocale(options, LOCALIZED_CONTENT, useQueryLocale(options.locale));
355
394
  const localizedOptions = { ...options, locale };
356
395
  return useSuspenseQuery<<%= model.pascal %>Result<undefined, TPopulate> | null, Error>({ queryKey: qk.bySlug(slug, localizedOptions), queryFn: () => getBySlug(slug, localizedOptions) });
357
396
  }
358
397
 
359
- export function useSuspenseGetByField<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(fieldName: string, value: string, options: { locale?: string; populate?: TPopulate } = {}) {
398
+ export function useSuspenseGetByField<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(fieldName: string, value: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
360
399
  const locale = readLocale(options, LOCALIZED_CONTENT, useQueryLocale(options.locale));
361
400
  const localizedOptions = { ...options, locale };
362
401
  return useSuspenseQuery<<%= model.pascal %>Result<undefined, TPopulate> | null, Error>({ queryKey: qk.byField(fieldName, value, localizedOptions), queryFn: () => getByField(fieldName, value, localizedOptions) });
@@ -403,16 +442,16 @@ export function useRemove() {
403
442
  <% } else { -%>
404
443
  const qk = {
405
444
  root: ["<%= model.pascal %>"] as const,
406
- document: <TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(options: { locale?: string; populate?: TPopulate } = {}) => ["<%= model.pascal %>", "document", options.locale, options.populate] as const
445
+ document: <TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) => ["<%= model.pascal %>", "document", options.locale, options.populate] as const
407
446
  };
408
447
 
409
- export function useGet<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(options: { locale?: string; populate?: TPopulate } = {}) {
448
+ export function useGet<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
410
449
  const locale = readLocale(options, LOCALIZED_CONTENT, useQueryLocale(options.locale));
411
450
  const localizedOptions = { ...options, locale };
412
451
  return useQuery<<%= model.pascal %>Result<undefined, TPopulate>, Error>({ queryKey: qk.document(localizedOptions), queryFn: () => get(localizedOptions) });
413
452
  }
414
453
 
415
- export function useSuspenseGet<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(options: { locale?: string; populate?: TPopulate } = {}) {
454
+ export function useSuspenseGet<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
416
455
  const locale = readLocale(options, LOCALIZED_CONTENT, useQueryLocale(options.locale));
417
456
  const localizedOptions = { ...options, locale };
418
457
  return useSuspenseQuery<<%= model.pascal %>Result<undefined, TPopulate>, Error>({ queryKey: qk.document(localizedOptions), queryFn: () => get(localizedOptions) });
@@ -1,19 +1,20 @@
1
1
  <%
2
2
  /**
3
3
  * INPUT ▸ model = {
4
- * uid: "api::blog.blog", // UID in format "api::name.name"
5
- * pascal: "Blog" // PascalCase for type names
6
- * fields: {key: {type, required?, multiple?}} // Schema fields
4
+ * uid: "Post", // matches src/schemas/<uid>.json (letters, numbers, underscores)
5
+ * pascal: "Post", // pascalCase(uid), used for generated type names
6
+ * fields: {key: {type, required?, multiple?, many?, target?, targetPascal?}},
7
+ * relationImports: [{uid, pascal}] // relation targets other than this schema
7
8
  * }
8
9
  */
9
10
  %>
10
11
  // AUTO-GENERATED ⚠️ – do not edit by hand
11
- import { type Fields, type FimoBoolean, type FimoMedia, type FimoReference, type FimoRichText, type FimoRichTextContent, type FimoString, type Populate, type PopulatedResult, type ProjectedWithPopulate, type Query, type Sort, type Where, type WithRelations } from "fimo/ui";
12
+ import { type Fields, type FimoBoolean, type FimoMedia, type FimoPolymorphicReference, type FimoReference, type FimoRichText, type FimoRichTextContent, type FimoString, type Populate, type PopulatedResult, type ProjectedWithPopulate, type Query, type Sort, type Where, type WithReferences } from "fimo/ui";
12
13
  <%
13
- if (model.relationImports && model.relationImports.length > 0) {
14
- model.relationImports.forEach((relationImport) => {
14
+ if (model.referenceImports && model.referenceImports.length > 0) {
15
+ model.referenceImports.forEach((referenceImport) => {
15
16
  -%>
16
- import type { <%= relationImport.pascal %> } from "./<%= relationImport.pascal %>.types";
17
+ import type { <%= referenceImport.pascal %> } from "./<%= referenceImport.pascal %>.types";
17
18
  <%
18
19
  });
19
20
  }
@@ -30,7 +31,7 @@ export interface <%= model.pascal %>FimoMeta {
30
31
  contentType: string;
31
32
  }
32
33
 
33
- export interface <%= model.pascal %> extends WithRelations<<%= model.pascal %>Relations> {
34
+ export interface <%= model.pascal %> extends WithReferences<<%= model.pascal %>References> {
34
35
  id: string;
35
36
  documentId: string;
36
37
  slug: FimoString;
@@ -51,6 +52,7 @@ if (model.fields) {
51
52
  });
52
53
  }
53
54
  -%>
55
+ }
54
56
 
55
57
  export interface <%= model.pascal %>Input {
56
58
  slug?: string;
@@ -93,8 +95,10 @@ function getTypeForField(field) {
93
95
  return 'Record<string, any>';
94
96
  case 'media':
95
97
  return field.multiple ? 'FimoMedia[]' : 'FimoMedia';
96
- case 'relation':
97
- return field.many ? 'FimoReference[]' : 'FimoReference | null';
98
+ case 'reference': {
99
+ const raw = Array.isArray(field.targets) && field.targets.length > 0 ? 'FimoPolymorphicReference' : 'FimoReference';
100
+ return field.many ? `${raw}[]` : `${raw} | null`;
101
+ }
98
102
  default:
99
103
  return 'any';
100
104
  }
@@ -118,20 +122,35 @@ function getInputTypeForField(field) {
118
122
  const media = 'Pick<FimoMedia, "url" | "alt" | "name" | "mime" | "width" | "height">';
119
123
  return field.multiple ? `${media}[]` : media;
120
124
  }
121
- case 'relation':
122
- return field.many ? 'FimoReference[]' : 'FimoReference | null';
125
+ case 'reference': {
126
+ const raw = Array.isArray(field.targets) && field.targets.length > 0 ? 'FimoPolymorphicReference' : 'FimoReference';
127
+ return field.many ? `${raw}[]` : `${raw} | null`;
128
+ }
123
129
  default:
124
130
  return 'unknown';
125
131
  }
126
132
  }
127
133
  -%>
128
- }
129
134
 
130
- export interface <%= model.pascal %>Relations {
135
+ export interface <%= model.pascal %>References {
131
136
  <%
132
137
  if (model.fields) {
133
138
  Object.entries(model.fields).forEach(([key, field]) => {
134
- if (field.type !== 'relation' || typeof field.targetPascal !== 'string') {
139
+ if (field.type !== 'reference') {
140
+ return;
141
+ }
142
+ // Polymorphic populated values form a discriminated union on $type —
143
+ // the generated wrapper stamps it, so `switch (item.$type)` narrows.
144
+ if (Array.isArray(field.targetRefs) && field.targetRefs.length > 0) {
145
+ const memberFor = (ref) => `(${ref.uid === model.uid ? model.pascal : ref.pascal} & { $type: "${ref.uid}" })`;
146
+ const union = field.targetRefs.map(memberFor).join(' | ');
147
+ const type = field.many ? `(${union})[]` : `${union} | null`;
148
+ -%>
149
+ <%= key %>: <%= type %>;
150
+ <%
151
+ return;
152
+ }
153
+ if (typeof field.targetPascal !== 'string') {
135
154
  return;
136
155
  }
137
156
  const type = field.many ? `${field.targetPascal}[]` : `${field.targetPascal} | null`;
@@ -18,8 +18,8 @@ This file covers the **package surface**: the JSON shape you write, the generate
18
18
  "featured": { "type": "boolean" },
19
19
  "coverImage": { "type": "media" },
20
20
  "metadata": { "type": "json" },
21
- "author": { "type": "relation", "target": "Author" },
22
- "contributors": { "type": "relation", "target": "Author", "many": true }
21
+ "author": { "type": "reference", "target": "Author" },
22
+ "contributors": { "type": "reference", "target": "Author", "many": true }
23
23
  }
24
24
  }
25
25
  ```
@@ -45,22 +45,22 @@ something else (e.g. `handle`, `permalink`).
45
45
 
46
46
  ## Field types
47
47
 
48
- | Type | `data` JSON shape | Use for |
49
- | ---------- | ------------------------------------------------------------------------------------- | --------------------------------- |
50
- | `string` | `"..."` | Titles, short text |
51
- | `richtext` | Tiptap JSONContent (see below) | Blog body, descriptions, articles |
52
- | `number` | `42` | Counts, quantities |
53
- | `boolean` | `true` / `false` | Flags |
54
- | `date` | ISO date string, e.g. `"2026-06-03"` | Publish dates, timestamps |
55
- | `media` | `{ "source": "local"\|"external", "url": "https://...", "alt": "..." }` | Images, video |
56
- | `json` | any JSON | Structured metadata |
57
- | `relation` | `{ "documentId": "..." }`, `null`, or an array of reference objects when `many: true` | References to another schema |
48
+ | Type | `data` JSON shape | Use for |
49
+ | ----------- | ------------------------------------------------------------------------------------- | --------------------------------- |
50
+ | `string` | `"..."` | Titles, short text |
51
+ | `richtext` | Tiptap JSONContent (see below) | Blog body, descriptions, articles |
52
+ | `number` | `42` | Counts, quantities |
53
+ | `boolean` | `true` / `false` | Flags |
54
+ | `date` | ISO date string, e.g. `"2026-06-03"` | Publish dates, timestamps |
55
+ | `media` | `{ "source": "local"\|"external", "url": "https://...", "alt": "..." }` | Images, video |
56
+ | `json` | any JSON | Structured metadata |
57
+ | `reference` | `{ "documentId": "..." }`, `null`, or an array of reference objects when `many: true` | References to another schema |
58
58
 
59
59
  A `date` field currently generates as `any` in the TS client, so for typed access (and the `<Date>` component) prefer a `string` field holding an ISO date — which is what the `publishDate` examples below do.
60
60
 
61
- ## Relation fields
61
+ ## Reference fields
62
62
 
63
- Use `relation` when one content entry references another content entry. Do not store entry IDs in a `json` field as a workaround.
63
+ Use `reference` when one content entry references another content entry. Do not store entry IDs in a `json` field as a workaround.
64
64
 
65
65
  ```json
66
66
  {
@@ -68,8 +68,8 @@ Use `relation` when one content entry references another content entry. Do not s
68
68
  "name": "Blog Posts",
69
69
  "fields": {
70
70
  "title": { "type": "string" },
71
- "author": { "type": "relation", "target": "Author" },
72
- "contributors": { "type": "relation", "target": "Author", "many": true }
71
+ "author": { "type": "reference", "target": "Author" },
72
+ "contributors": { "type": "reference", "target": "Author", "many": true }
73
73
  }
74
74
  }
75
75
  ```
@@ -77,21 +77,21 @@ Use `relation` when one content entry references another content entry. Do not s
77
77
  Rules:
78
78
 
79
79
  - `target` is required and must be the PascalCase schema `uid` of the referenced content type.
80
- - Omit `many` for a to-one relation. Use `"many": true` for a to-many relation.
81
- - Self-references are allowed, e.g. a `Category` schema can have `parent: { "type": "relation", "target": "Category" }`.
80
+ - Omit `many` for a to-one reference. Use `"many": true` for a to-many reference.
81
+ - Self-references are allowed, e.g. a `Category` schema can have `parent: { "type": "reference", "target": "Category" }`.
82
82
  - Entry values store reference objects with the target `documentId`, not row `id` values: `{ "documentId": "..." }`.
83
- - Do not include `contentType` for normal fixed-target relations; the schema's `target` already defines it.
84
- - Do not add `localized` or field-level `i18n` to a relation. The relation value belongs to the entry variant that stores it.
85
- - Use `null` for an empty to-one relation and `[]` for an empty to-many relation.
86
- - The API validates that referenced target documents exist and rejects duplicate values in to-many relation arrays.
83
+ - Do not include `contentType` for normal fixed-target references; the schema's `target` already defines it.
84
+ - Do not add `localized` or field-level `i18n` to a reference. The reference value belongs to the entry variant that stores it.
85
+ - Use `null` for an empty to-one reference and `[]` for an empty to-many reference.
86
+ - The API validates that referenced target documents exist and rejects duplicate values in to-many reference arrays.
87
87
 
88
88
  Entry payload example:
89
89
 
90
90
  ```json
91
91
  {
92
92
  "data": {
93
- "slug": "relation-example",
94
- "title": "Relation example",
93
+ "slug": "reference-example",
94
+ "title": "Reference example",
95
95
  "author": { "documentId": "750e8400-e29b-41d4-a716-446655440000" },
96
96
  "contributors": [{ "documentId": "750e8400-e29b-41d4-a716-446655440000" }]
97
97
  }
@@ -104,8 +104,8 @@ After editing schema JSON, run `fimo schemas push {Uid}` and then `fimo validate
104
104
 
105
105
  - Adding fields and changing labels preserve entries.
106
106
  - Removing a field removes only that field's values.
107
- - A one-to-many relation change wraps each existing reference in an array.
108
- - A many-to-one change, relation target change, scalar type change, or scalar/relation change clears only the changed field.
107
+ - A one-to-many reference change wraps each existing reference in an array.
108
+ - A many-to-one change, reference target change, scalar type change, or scalar/reference change clears only the changed field.
109
109
  - Collection-to-singleton removes that type's entries and cleans references to those documents.
110
110
  - Singleton-to-collection preserves the existing row.
111
111
 
@@ -222,9 +222,26 @@ For shared content, set `i18n: { "enabled": false }` on the schema and create ea
222
222
  - have no public `locale` property;
223
223
  - are returned in every locale context without duplication;
224
224
  - ignore locale on owner reads and writes;
225
- - still use the active/requested locale when a populated relation targets a localized schema.
225
+ - still use the active/requested locale when a populated reference targets a localized schema.
226
226
 
227
- Do not duplicate shared entries across locales, add field-level localization settings, or implement locale fallbacks.
227
+ Do not duplicate shared entries across locales or add field-level localization settings.
228
+
229
+ A populated reference whose target has no variant in the requested locale resolves to null. Reads accept an opt-in `localeFallbacks: true` that resolves those to the best available variant instead (project default first), marked by its `locale` field — never implement fallbacks by hand; public reads stay strict unless the option is passed.
230
+
231
+ **Polymorphic references:** a field may declare `targets: ["Post", "Video"]` instead of a single `target` (never both) when one slot genuinely holds different types — mixed feeds, hero blocks, curated collections. Prefer a fixed `target` whenever every value is the same type: fixed fields keep `where` filtering and simpler generated types. Polymorphic values store `{ contentType, documentId }`; populated values in generated hooks carry a top-level `$type` that narrows the union:
232
+
233
+ ```ts
234
+ for (const item of page.featured) {
235
+ switch (item.$type) {
236
+ case 'Post':
237
+ return item.title; // typed as Post here
238
+ case 'Video':
239
+ return item.clips; // typed as Video here
240
+ }
241
+ }
242
+ ```
243
+
244
+ Filtering on polymorphic fields is not supported yet.
228
245
 
229
246
  **Generated API:**
230
247
 
@@ -290,8 +307,8 @@ BlogPost.useGet({
290
307
  ```
291
308
 
292
309
  Operators: scalar equality, `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$contains`, `$startsWith`, `$null`.
293
- Relation field values are reference objects, but relation filters use the target `documentId` string, for example `where: { author: "750e..." }`; for to-many relations that means "contains this target document".
294
- For to-many relations, `$eq` and `$contains` mean membership, `$in` means any listed document is present, `$ne` and `$nin` mean none are present, and `$null: true` means empty or missing. Relation fields cannot be sorted.
310
+ Reference field values are reference objects, but reference filters use the target `documentId` string, for example `where: { author: "750e..." }`; for to-many references that means "contains this target document".
311
+ For to-many references, `$eq` and `$contains` mean membership, `$in` means any listed document is present, `$ne` and `$nin` mean none are present, and `$null: true` means empty or missing. Reference fields cannot be sorted.
295
312
 
296
313
  Use `fields` to keep list payloads small. The API always keeps `id`, and returns only the requested fields beyond that:
297
314
 
@@ -304,7 +321,7 @@ const { data: summaries = [] } = BlogPost.useGet({
304
321
  // summaries: Array<Pick<BlogPost, 'id' | 'slug' | 'title' | 'excerpt' | 'coverImage'>>
305
322
  ```
306
323
 
307
- Use `populate` to embed relation targets in read results:
324
+ Use `populate` to embed reference targets in read results:
308
325
 
309
326
  ```tsx
310
327
  const { data: posts = [] } = BlogPost.useGet({
@@ -327,11 +344,11 @@ type PostCardItem = BlogPostResult<
327
344
  >;
328
345
  ```
329
346
 
330
- Do not type populated hook data as `BlogPost[]`: plain `BlogPost` keeps relation fields as `FimoReference` objects, while `BlogPostResult<TFields, TPopulate>` models the projected/populated read result.
347
+ Do not type populated hook data as `BlogPost[]`: plain `BlogPost` keeps reference fields as `FimoReference` objects, while `BlogPostResult<TFields, TPopulate>` models the projected/populated read result.
331
348
 
332
- `BlogPostPopulate` is the generated input type for allowed populate values. It comes from the schema's relation fields, so a post schema with `author` and `contributors` only accepts those names in `populate`. Most app code never imports it directly; it is mainly used by `BlogPostQuery` and hook generics.
349
+ `BlogPostPopulate` is the generated input type for allowed populate values. It comes from the schema's reference fields, so a post schema with `author` and `contributors` only accepts those names in `populate`. Most app code never imports it directly; it is mainly used by `BlogPostQuery` and hook generics.
333
350
 
334
- Populate is depth-one. It only works for relation fields; trying to populate a scalar field returns 400. To-many relation order is preserved.
351
+ Populate is depth-one. It only works for reference fields; trying to populate a scalar field returns 400. To-many reference order is preserved.
335
352
 
336
353
  The generated hooks do **not** surface pagination meta (`total`, `hasMore`, `limit`, `offset`). If you need it, read it from a raw fetch to `/entries/<typeName>` (see the **Read path** section below), which returns the full `{ data, meta }` envelope.
337
354
 
@@ -348,7 +365,7 @@ The entry objects a hook returns are **not** plain JS values. The generated clie
348
365
  | `number` | `number` | plain — not wrapped |
349
366
  | `json` | `Record<string, any>` | plain — not wrapped |
350
367
  | `date` | `any` (raw ISO date string) | not wrapped (`FimoDate` is not emitted — see below) |
351
- | `relation` | `FimoReference` / `FimoReference[]` | reference object(s) with target `documentId`; use `populate` when embedded target rows are needed |
368
+ | `reference` | `FimoReference` / `FimoReference[]` | reference object(s) with target `documentId`; use `populate` when embedded target rows are needed |
352
369
 
353
370
  Rules of thumb:
354
371
 
@@ -29,7 +29,7 @@ The command is `fimo schemas` (plural). There is no singular `fimo schema` alias
29
29
 
30
30
  All commands accept `-C, --cwd <dir>` to point at a different project directory.
31
31
 
32
- Schema transitions are automatic. Adding fields and changing labels preserve content. Removing a field removes only that field's stored values. One-to-many relation changes wrap existing references in arrays. Field type changes, scalar/relation changes, relation target changes, and many-to-one changes clear only the changed field. Collection-to-singleton removes that type's entries and cleans inbound references; singleton-to-collection preserves its row. There are no reset or data-loss flags: changing the schema is the migration request. Run `fimo validate` after pushing to verify the regenerated client and whole project.
32
+ Schema transitions are automatic. Adding fields and changing labels preserve content. Removing a field removes only that field's stored values. One-to-many reference changes wrap existing references in arrays. Field type changes, scalar/reference changes, reference target changes, and many-to-one changes clear only the changed field. Collection-to-singleton removes that type's entries and cleans inbound references; singleton-to-collection preserves its row. There are no reset or data-loss flags: changing the schema is the migration request. Run `fimo validate` after pushing to verify the regenerated client and whole project.
33
33
 
34
34
  ## Entries CLI commands
35
35
 
@@ -60,20 +60,24 @@ fimo entries list BlogPost --fields slug,title,author --populate author
60
60
  ```
61
61
 
62
62
  Use the canonical read query flags: `--search`, `--sort`, `--where`, `--limit`, `--offset`, `--fields`, and `--populate`.
63
- `--where` is a JSON object keyed by schema field name. Simple values mean equality, so booleans, strings, and numbers should stay typed inside the JSON (`{"featured":true}`, `{"category":"react"}`, `{"rank":{"$gte":3}}`). Relation field values are reference objects, but relation filters use the target `documentId` string, for example `{"author":"750e..."}`; for to-many relations that means "contains this target document". Public Fimo-owned metadata lives under `__fimo`; currently `__fimo.contentType` is filterable. Operator objects support `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$contains`, `$startsWith`, and `$null`, subject to the field type. For to-many relations, `$eq` and `$contains` mean membership, `$in` means any listed document is present, `$ne` and `$nin` mean none are present, and `$null: true` means empty or missing. Relation fields cannot be sorted. Use `--sort field` for ascending and `--sort -field` for descending on scalar fields.
63
+ `--where` is a JSON object keyed by schema field name. Simple values mean equality and stay typed inside the JSON (`{"featured":true}`, `{"category":"react"}`, `{"rank":{"$gte":3}}`); operator objects support `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$contains`, `$startsWith`, and `$null`, subject to the field type. Public Fimo-owned metadata lives under `__fimo`; currently `__fimo.contentType` is filterable.
64
+
65
+ Reference filters take the target `documentId` string even though stored values are objects (`{"author":"750e..."}`). On a to-many field, `$eq` and `$contains` mean membership, `$in` means any listed document is present, `$ne` and `$nin` mean none are present, and `$null: true` means empty or missing. Reference fields cannot be sorted; use `--sort field` / `--sort -field` on scalar fields.
64
66
  `--fields` is a comma-separated projection for list payloads. The API always keeps `id`; pass only the fields your view needs, such as `slug,title,excerpt,coverImage`.
65
67
 
66
- `--populate` is a comma-separated list of relation fields to embed. Without `--populate`, relation fields return reference objects like `{ "documentId": "..." }`. With `--populate author,contributors`, those fields return the target entry objects for the requested locale. Only relation fields can be populated; asking to populate a scalar field returns 400.
68
+ `--populate` is a comma-separated list of reference fields to embed. Without it, reference fields return reference objects like `{ "documentId": "..." }`; with `--populate author,contributors`, those fields return the target entry objects for the requested locale. Only reference fields can be populated (scalar fields 400), and populate never selects which target fields come back. A populated target missing in the requested locale resolves to null unless you pass `localeFallbacks=true`, which falls back to the best available locale variant, marked by its `locale` field. Limits: 200 references per to-many write, 500 distinct populated documents per field per request (400 past either).
69
+
70
+ Polymorphic references declare `targets: ["Post", "Product"]` instead of `target` (never both) and their values carry both parts: `{ "contentType": "Post", "documentId": "..." }`. Populate returns mixed full entries, and generated hooks expose `$type` on each populated value to branch on. `--where` on polymorphic fields is not supported yet and fails with a clear error.
67
71
 
68
72
  In generated React code, the equivalent is `populate: ['author', 'contributors'] as const` in the schema hook/query params. Let hooks infer the result when possible; if a named TypeScript shape is needed, use the generated `BlogPostResult<TFields, TPopulate>` type documented in `fimo/references/content.md`. Writes use the separate generated `BlogPostInput` type.
69
73
 
70
- For relation schema JSON and entry value shape, load `fimo/references/content.md` § Relation fields. The short version:
74
+ For reference schema JSON and entry value shape, load `fimo/references/content.md` § Reference fields. The short version:
71
75
 
72
76
  ```json
73
77
  {
74
78
  "fields": {
75
- "author": { "type": "relation", "target": "Author" },
76
- "contributors": { "type": "relation", "target": "Author", "many": true }
79
+ "author": { "type": "reference", "target": "Author" },
80
+ "contributors": { "type": "reference", "target": "Author", "many": true }
77
81
  }
78
82
  }
79
83
  ```
@@ -84,7 +88,7 @@ Entry bodies store reference objects with target `documentId` values:
84
88
  fimo entries create BlogPost --body '{"data":{"slug":"hello","title":"Hello","author":{"documentId":"750e8400-e29b-41d4-a716-446655440000"},"contributors":[]}}'
85
89
  ```
86
90
 
87
- Do not add `localized` or field-level `i18n` to relation fields. The relation value belongs to the entry variant that stores it.
91
+ Do not add `localized` or field-level `i18n` to reference fields. The reference value belongs to the entry variant that stores it.
88
92
 
89
93
  ## Localized and shared content
90
94
 
@@ -92,7 +96,7 @@ Schemas are localized by default. Omit `--locale` for default-locale entries. Pa
92
96
 
93
97
  When creating a localized variant of an existing entry, pass the source entry's `documentId` with `--document-id`. Keep projects single-locale unless the user asks for multilingual or localized output; when multiple locales are configured, create and update every locale explicitly.
94
98
 
95
- For content shared across locales, define `"i18n": { "enabled": false }` at the schema root and create each entry once. Do not pass `--document-id` to manufacture locale variants. Shared entry responses omit `locale`; a read locale is only used to resolve populated relations that target localized schemas. No other `i18n` options and no field-level localization settings are supported.
99
+ For content shared across locales, define `"i18n": { "enabled": false }` at the schema root and create each entry once. Do not pass `--document-id` to manufacture locale variants. Shared entry responses omit `locale`; a read locale is only used to resolve populated references that target localized schemas. No other `i18n` options and no field-level localization settings are supported.
96
100
 
97
101
  Use `"isSingleton": true` for a type with exactly one logical document. Generated singleton clients expose `get`/`update` and their React Query hooks, not collection create/remove APIs.
98
102
 
@@ -97,7 +97,7 @@ node <this-skill>/scripts/write-migration-report.mjs <project-root>
97
97
 
98
98
  - `references/workflow.md` - full decision tree and phase order.
99
99
  - `references/manifest.md` - canonical migration ledger schema.
100
- - `references/content.md` - CMS, entries, relations, and source value rules.
100
+ - `references/content.md` - CMS, entries, references, and source value rules.
101
101
  - `references/media.md` - media inventory, upload, dedupe, and replacement rules.
102
102
  - `references/forms.md` - form classification and Fimo replacement rules.
103
103
  - `references/import-contract.md` - exact import obligations for media, entries,
@@ -73,20 +73,20 @@ For every content type:
73
73
  Do not wire a page to remote Fimo content until the matching schema exists and
74
74
  the page has a seed/empty state for local development.
75
75
 
76
- ## Relations
76
+ ## References
77
77
 
78
78
  Create references only when:
79
79
 
80
- - exports contain stable relation IDs
80
+ - exports contain stable reference IDs
81
81
  - repeated values clearly match another exported collection
82
82
  - the user confirms the relationship
83
83
 
84
84
  Use a two-pass import for references:
85
85
 
86
86
  1. import all referenced entries with source IDs and slugs
87
- 2. resolve relation fields to target IDs/slugs
87
+ 2. resolve reference fields to target IDs/slugs
88
88
 
89
- If relation confidence is low, keep a string/tag field and record an unresolved
89
+ If reference confidence is low, keep a string/tag field and record an unresolved
90
90
  item.
91
91
 
92
92
  ## Skips
@@ -50,7 +50,7 @@ Import requirements:
50
50
  - entries are created or updated in Fimo, not only written as seed JSON
51
51
  - source count, generated count, imported count, skipped count, and unresolved
52
52
  count are recorded per content type
53
- - relations are resolved in a second pass only when stable IDs or confirmed
53
+ - references are resolved in a second pass only when stable IDs or confirmed
54
54
  mappings exist
55
55
 
56
56
  Acceptance:
@@ -80,7 +80,7 @@ than first-party CSV/XML/JSON exports.
80
80
  "coverImage": { "type": "media", "source": "Main Image" },
81
81
  "publishedAt": { "type": "datetime", "source": "Published On" }
82
82
  },
83
- "relations": []
83
+ "references": []
84
84
  }
85
85
  ```
86
86