xpt-shared-types 1.0.4 → 1.3.0

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 (39) hide show
  1. package/README.md +121 -38
  2. package/dist/bracket.d.ts +56 -0
  3. package/dist/bracket.js +144 -0
  4. package/dist/contracts/index.d.ts +139 -0
  5. package/dist/contracts/index.js +8 -0
  6. package/dist/data/countriesList.d.ts +1 -1
  7. package/dist/data/countriesList.js +1 -1
  8. package/dist/generated/enums.d.ts +48 -0
  9. package/dist/generated/enums.js +5 -0
  10. package/dist/generated/index.d.ts +3 -0
  11. package/dist/generated/index.js +22 -0
  12. package/dist/generated/inputs.d.ts +420 -0
  13. package/dist/generated/inputs.js +5 -0
  14. package/dist/generated/models.d.ts +420 -0
  15. package/dist/generated/models.js +5 -0
  16. package/dist/index.d.ts +4 -3
  17. package/dist/index.js +9 -5
  18. package/dist/manual/index.d.ts +4 -0
  19. package/dist/manual/index.js +20 -0
  20. package/package.json +26 -23
  21. package/scripts/sync-from-strapi.js +281 -0
  22. package/src/bracket.ts +193 -0
  23. package/src/contracts/index.ts +158 -0
  24. package/src/data/countriesList.ts +1156 -1156
  25. package/src/generated/enums.ts +175 -0
  26. package/src/generated/index.ts +7 -0
  27. package/src/generated/inputs.ts +496 -0
  28. package/src/generated/models.ts +497 -0
  29. package/src/index.ts +14 -7
  30. package/src/manual/index.ts +4 -0
  31. package/dist/types/match.d.ts +0 -1
  32. package/dist/types/match.js +0 -2
  33. package/dist/types/tournament.d.ts +0 -1
  34. package/dist/types/tournament.js +0 -2
  35. package/src/types/match.ts +0 -8
  36. package/src/types/tournament.ts +0 -4
  37. /package/dist/{types → manual}/country.d.ts +0 -0
  38. /package/dist/{types → manual}/country.js +0 -0
  39. /package/src/{types → manual}/country.ts +0 -0
@@ -0,0 +1,281 @@
1
+ /* eslint-disable no-console */
2
+ /**
3
+ * Generates the shared API contract from the Strapi content-type schemas.
4
+ *
5
+ * `schema.json` is the source of truth — not `types/generated/contentTypes.d.ts`,
6
+ * which is gitignored in xpt-strapi and only exists after `strapi develop` has run.
7
+ *
8
+ * node scripts/sync-from-strapi.js [pathToStrapiRepo]
9
+ *
10
+ * Output is committed to this repo; run this whenever a content type changes.
11
+ */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const STRAPI_ROOT =
16
+ process.argv[2] ||
17
+ process.env.XPT_STRAPI_PATH ||
18
+ path.resolve(__dirname, '../../xpt-strapi');
19
+ const OUT = path.resolve(__dirname, '../src/generated');
20
+
21
+ const BANNER = [
22
+ '// AUTO-GENERATED by scripts/sync-from-strapi.js — DO NOT EDIT MANUALLY.',
23
+ '// Source: xpt-strapi/src/**/content-types/**/schema.json',
24
+ '// Regenerate with: yarn sync',
25
+ '',
26
+ ].join('\n');
27
+
28
+ // ── helpers ────────────────────────────────────────────────────────────────
29
+ /** Quote a property name only when it is not a bare identifier. */
30
+ const prop = (name) =>
31
+ /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
32
+
33
+ const pascal = (s) =>
34
+ String(s)
35
+ .replace(/[_-]+([a-zA-Z0-9])/g, (_, c) => c.toUpperCase())
36
+ .replace(/^([a-z])/, (_, c) => c.toUpperCase())
37
+ .replace(/[^A-Za-z0-9]/g, '');
38
+
39
+ /** api::tournament-participant.tournament-participant -> TournamentParticipant */
40
+ function modelNameFromUid(uid) {
41
+ if (uid === 'plugin::users-permissions.user') return 'User';
42
+ if (uid === 'plugin::users-permissions.role') return 'UserRole';
43
+ if (uid === 'plugin::upload.file') return 'StrapiMedia';
44
+ const m = /^api::[^.]+\.(.+)$/.exec(uid);
45
+ return m ? pascal(m[1]) : null;
46
+ }
47
+
48
+ const SCALARS = {
49
+ string: 'string',
50
+ text: 'string',
51
+ richtext: 'string',
52
+ uid: 'string',
53
+ email: 'string',
54
+ password: 'string',
55
+ datetime: 'string',
56
+ date: 'string',
57
+ time: 'string',
58
+ biginteger: 'string', // Strapi serialises bigint as string
59
+ integer: 'number',
60
+ float: 'number',
61
+ decimal: 'number',
62
+ boolean: 'boolean',
63
+ json: 'unknown',
64
+ blocks: 'BlocksContent',
65
+ };
66
+
67
+ // ── collect schemas ────────────────────────────────────────────────────────
68
+ function findSchemas(root) {
69
+ const out = [];
70
+ const roots = [
71
+ path.join(root, 'src', 'api'),
72
+ path.join(root, 'src', 'extensions'),
73
+ ];
74
+ for (const base of roots) {
75
+ if (!fs.existsSync(base)) continue;
76
+ for (const dir of fs.readdirSync(base)) {
77
+ const ctDir = path.join(base, dir, 'content-types');
78
+ if (!fs.existsSync(ctDir)) continue;
79
+ for (const ct of fs.readdirSync(ctDir)) {
80
+ const file = path.join(ctDir, ct, 'schema.json');
81
+ if (fs.existsSync(file)) out.push(file);
82
+ }
83
+ }
84
+ }
85
+ return out.sort();
86
+ }
87
+
88
+ const files = findSchemas(STRAPI_ROOT);
89
+ if (!files.length) {
90
+ console.error(
91
+ 'No schema.json found under ' +
92
+ STRAPI_ROOT +
93
+ '. Pass the xpt-strapi path as an argument.'
94
+ );
95
+ process.exit(1);
96
+ }
97
+
98
+ const collected = [];
99
+ for (const file of files) {
100
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
101
+ const info = raw.info || {};
102
+ const singular = info.singularName || path.basename(path.dirname(file));
103
+ // The users-permissions extension augments the plugin's own user model.
104
+ const isUserExt = file.split(path.sep).includes('users-permissions');
105
+ collected.push({
106
+ name: isUserExt ? 'User' : pascal(singular),
107
+ singular: isUserExt ? 'user' : singular,
108
+ attributes: raw.attributes || {},
109
+ });
110
+ }
111
+
112
+ // Merge any model declared more than once (e.g. the user extension).
113
+ const byName = new Map();
114
+ for (const m of collected) {
115
+ if (byName.has(m.name)) {
116
+ Object.assign(byName.get(m.name).attributes, m.attributes);
117
+ } else {
118
+ byName.set(m.name, m);
119
+ }
120
+ }
121
+ const allModels = [...byName.values()].sort((a, b) =>
122
+ a.name.localeCompare(b.name)
123
+ );
124
+
125
+ // ── enums ──────────────────────────────────────────────────────────────────
126
+ const enums = [];
127
+ for (const m of allModels) {
128
+ for (const [field, def] of Object.entries(m.attributes)) {
129
+ if (def.type !== 'enumeration' || !Array.isArray(def.enum)) continue;
130
+ enums.push({
131
+ name: m.name + pascal(field),
132
+ values: def.enum,
133
+ model: m.name,
134
+ field,
135
+ });
136
+ }
137
+ }
138
+ enums.sort((a, b) => a.name.localeCompare(b.name));
139
+
140
+ let enumsOut = BANNER + '\n';
141
+ for (const e of enums) {
142
+ enumsOut +=
143
+ '/** `' +
144
+ e.model +
145
+ '.' +
146
+ e.field +
147
+ '` */\nexport type ' +
148
+ e.name +
149
+ ' =\n' +
150
+ e.values.map((v) => ' | ' + JSON.stringify(v)).join('\n') +
151
+ ';\n\n';
152
+ }
153
+ enumsOut +=
154
+ '/** Readability alias for `tournament.currentStatus`. */\n' +
155
+ 'export type TournamentStatus = TournamentCurrentStatus;\n';
156
+
157
+ // ── models (read shapes) ───────────────────────────────────────────────────
158
+ const enumLookup = new Map(enums.map((e) => [e.model + '.' + e.field, e.name]));
159
+ const usedEnums = new Set();
160
+
161
+ function readTypeFor(model, field, def) {
162
+ if (def.type === 'enumeration') {
163
+ return enumLookup.get(model.name + '.' + field) || 'string';
164
+ }
165
+ if (def.type === 'media') {
166
+ return def.multiple ? 'StrapiMedia[]' : 'StrapiMedia';
167
+ }
168
+ if (def.type === 'relation') {
169
+ const target = modelNameFromUid(def.target);
170
+ if (!target) return 'unknown';
171
+ const many =
172
+ def.relation === 'oneToMany' || def.relation === 'manyToMany';
173
+ return many ? target + '[]' : target;
174
+ }
175
+ return SCALARS[def.type] || 'unknown';
176
+ }
177
+
178
+ let modelsBody = '';
179
+ for (const m of allModels) {
180
+ const lines = [];
181
+ for (const [field, def] of Object.entries(m.attributes)) {
182
+ // `private` attributes are never returned by the content API.
183
+ if (def.private) continue;
184
+ const t = readTypeFor(m, field, def);
185
+ if (def.type === 'enumeration') usedEnums.add(t);
186
+ // Everything except id/documentId is optional, because nothing else is
187
+ // guaranteed on the wire:
188
+ // - relations and media appear only when the query populates them;
189
+ // - any scalar disappears when the query uses `fields`, which this
190
+ // codebase does pervasively — so even a schema-`required` field like
191
+ // user.email is absent from most responses.
192
+ // The base model is therefore the minimum guarantee; use `Populated<T, K>`
193
+ // at a call site to record what that particular query actually returned.
194
+ // Strapi also sends `null` rather than omitting an unset nullable value,
195
+ // so single-valued optionals are `?: T | null`. Lists come back as `[]`.
196
+ const isList = t.endsWith('[]');
197
+ const rendered = isList ? t : t + ' | null';
198
+ lines.push(' ' + prop(field) + '?: ' + rendered + ';');
199
+ }
200
+ modelsBody +=
201
+ '/** `' +
202
+ m.singular +
203
+ '` */\nexport interface ' +
204
+ m.name +
205
+ ' extends StrapiDocument {\n' +
206
+ lines.join('\n') +
207
+ '\n}\n\n';
208
+ }
209
+
210
+ const enumImportList = [...usedEnums]
211
+ .filter((e) => !e.endsWith('[]'))
212
+ .sort()
213
+ .map((e) => ' ' + e + ',')
214
+ .join('\n');
215
+
216
+ const modelsOut =
217
+ BANNER +
218
+ "\nimport type {\n BlocksContent,\n StrapiDocument,\n StrapiMedia,\n UserRole,\n} from '../contracts';\n" +
219
+ (enumImportList ? 'import type {\n' + enumImportList + "\n} from './enums';\n" : '') +
220
+ '\n' +
221
+ modelsBody;
222
+
223
+ // ── inputs (write shapes) ──────────────────────────────────────────────────
224
+ // Write payloads differ structurally from read shapes: relations are sent as
225
+ // documentIds or connect/set/disconnect, media as numeric file ids.
226
+ let inputsBody = '';
227
+ for (const m of allModels) {
228
+ const lines = [];
229
+ for (const [field, def] of Object.entries(m.attributes)) {
230
+ if (def.private) continue;
231
+ let t;
232
+ if (def.type === 'relation') {
233
+ const many =
234
+ def.relation === 'oneToMany' || def.relation === 'manyToMany';
235
+ t = many ? 'RelationInput | RelationInput[]' : 'RelationInput';
236
+ } else if (def.type === 'media') {
237
+ t = def.multiple ? 'MediaInput[]' : 'MediaInput';
238
+ } else if (def.type === 'enumeration') {
239
+ t = enumLookup.get(m.name + '.' + field) || 'string';
240
+ } else {
241
+ t = SCALARS[def.type] || 'unknown';
242
+ }
243
+ lines.push(' ' + prop(field) + '?: ' + t + ';');
244
+ }
245
+ inputsBody +=
246
+ '/** Write payload for `' +
247
+ m.singular +
248
+ '`. */\nexport interface ' +
249
+ m.name +
250
+ 'Input {\n' +
251
+ lines.join('\n') +
252
+ '\n}\n\n';
253
+ }
254
+
255
+ const inputsOut =
256
+ BANNER +
257
+ "\nimport type {\n BlocksContent,\n MediaInput,\n RelationInput,\n} from '../contracts';\n" +
258
+ (enumImportList ? 'import type {\n' + enumImportList + "\n} from './enums';\n" : '') +
259
+ '\n' +
260
+ inputsBody;
261
+
262
+ // ── write ──────────────────────────────────────────────────────────────────
263
+ fs.mkdirSync(OUT, { recursive: true });
264
+ fs.writeFileSync(path.join(OUT, 'enums.ts'), enumsOut);
265
+ fs.writeFileSync(path.join(OUT, 'models.ts'), modelsOut);
266
+ fs.writeFileSync(path.join(OUT, 'inputs.ts'), inputsOut);
267
+ fs.writeFileSync(
268
+ path.join(OUT, 'index.ts'),
269
+ BANNER +
270
+ "\nexport * from './enums';\nexport * from './models';\nexport * from './inputs';\n"
271
+ );
272
+
273
+ console.log(
274
+ 'Generated ' +
275
+ allModels.length +
276
+ ' models and ' +
277
+ enums.length +
278
+ ' enums from ' +
279
+ files.length +
280
+ ' schemas.'
281
+ );
package/src/bracket.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Bracket shape — how many rounds a single-elimination tournament has, what
3
+ * each is called, how matches are numbered, and where each winner goes.
4
+ *
5
+ * This is the single source of truth for both apps: xpt-strapi re-exports it
6
+ * from `src/utils/bracket.ts` (adding the Strapi-coupled stage sync), and
7
+ * xpt-client's Storybook fixtures consume it directly — a fixture built any
8
+ * other way could show a bracket the backend can never produce.
9
+ */
10
+
11
+ import type { MatchRound } from './generated';
12
+
13
+ /** The largest bracket the round names can describe. */
14
+ export const MAX_BRACKET_SIZE = 128;
15
+
16
+ export function nextPowerOf2(n: number): number {
17
+ if (n <= 2) return 2;
18
+ let p = 2;
19
+ while (p < n) p *= 2;
20
+ return p;
21
+ }
22
+
23
+ export interface BracketRound {
24
+ name: MatchRound;
25
+ matchCount: number;
26
+ }
27
+
28
+ /**
29
+ * The name of the round that *starts* with this many players left. Halving
30
+ * from the bracket size down to 2 walks the whole bracket, so there is no
31
+ * table of per-size name lists to fall out of sync — the one way a size can
32
+ * go wrong is a size that is not a power of two in range, and that throws.
33
+ */
34
+ const ROUND_NAME_BY_SIZE: Record<number, MatchRound> = {
35
+ 128: 'Round 128',
36
+ 64: 'Round 64',
37
+ 32: 'Round 32',
38
+ 16: 'Round 16',
39
+ 8: 'Quarter Final',
40
+ 4: 'Semi-Final',
41
+ 2: 'Finals',
42
+ };
43
+
44
+ export function buildBracketRounds(
45
+ bracketSize: number,
46
+ hasThirdPlace: boolean
47
+ ): BracketRound[] {
48
+ // An unrecognised size used to fall back to a single round called "Finals"
49
+ // holding `bracketSize / 2` matches. An unplayable bracket is worse than no
50
+ // bracket.
51
+ if (!ROUND_NAME_BY_SIZE[bracketSize]) {
52
+ throw new Error(
53
+ `Unsupported bracket size ${bracketSize} — must be a power of two between 2 and ${MAX_BRACKET_SIZE}`
54
+ );
55
+ }
56
+
57
+ const rounds: BracketRound[] = [];
58
+
59
+ for (let size = bracketSize; size >= 2; size /= 2) {
60
+ rounds.push({ name: ROUND_NAME_BY_SIZE[size], matchCount: size / 2 });
61
+ }
62
+
63
+ // Inject Third Round slot before Finals when enabled (4+ players)
64
+ if (hasThirdPlace && bracketSize >= 4) {
65
+ const finalsIdx = rounds.findIndex((r) => r.name === 'Finals');
66
+ if (finalsIdx > 0) {
67
+ rounds.splice(finalsIdx, 0, { name: 'Third Round', matchCount: 1 });
68
+ }
69
+ }
70
+
71
+ return rounds;
72
+ }
73
+
74
+ /** The rounds a tournament of this many entrants will be played over. */
75
+ export function bracketRoundsForParticipants(
76
+ participants: number,
77
+ hasThirdPlace: boolean
78
+ ): BracketRound[] {
79
+ return buildBracketRounds(nextPowerOf2(participants), hasThirdPlace);
80
+ }
81
+
82
+ /** Uniform Fisher–Yates shuffle. A `sort(() => Math.random() - 0.5)` is
83
+ * biased — comparison sorts assume a consistent comparator, so some orderings
84
+ * come up measurably more often than others. */
85
+ export function shuffle<T>(items: readonly T[]): T[] {
86
+ const result = [...items];
87
+ for (let i = result.length - 1; i > 0; i--) {
88
+ const j = Math.floor(Math.random() * (i + 1));
89
+ [result[i], result[j]] = [result[j], result[i]];
90
+ }
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * Standard bracket seed order: which seed (0-based) sits in each first-round
96
+ * slot. Built by the usual doubling rule — each seed is paired with its
97
+ * complement, so seed 0 meets seed 1 only in the final.
98
+ *
99
+ * This is also the bye-safety property: slots are paired (s, size-1-s), and
100
+ * byes are the seeds from `checkedIn` upward. Both sides of a pair being byes
101
+ * would need `checkedIn <= (size-1)/2`, but `nextPowerOf2` guarantees
102
+ * `checkedIn > size/2` — so a bye always faces a real entrant.
103
+ */
104
+ export function seedOrder(bracketSize: number): number[] {
105
+ if (!ROUND_NAME_BY_SIZE[bracketSize]) {
106
+ throw new Error(
107
+ `Unsupported bracket size ${bracketSize} — must be a power of two between 2 and ${MAX_BRACKET_SIZE}`
108
+ );
109
+ }
110
+
111
+ let order = [0];
112
+ while (order.length < bracketSize) {
113
+ const size = order.length * 2;
114
+ const next: number[] = [];
115
+ for (const seed of order) {
116
+ next.push(seed, size - 1 - seed);
117
+ }
118
+ order = next;
119
+ }
120
+ return order;
121
+ }
122
+
123
+ export interface PlannedMatch {
124
+ round: MatchRound;
125
+ matchNumber: number;
126
+ /** Where this match's winner goes; null for Finals and Third Round. */
127
+ nextMatchNumber: number | null;
128
+ winnerSlot: 'home' | 'away' | null;
129
+ /** First-round matches only: index into the seeded slot array (null = later round). */
130
+ homeSlot: number | null;
131
+ awaySlot: number | null;
132
+ }
133
+
134
+ export interface BracketPlan {
135
+ bracketSize: number;
136
+ rounds: BracketRound[];
137
+ matches: PlannedMatch[];
138
+ }
139
+
140
+ /**
141
+ * The full bracket as data: every match, its number, and where its winner
142
+ * goes. Numbering is sequential across rounds in bracket order, with Third
143
+ * Round (when present) sitting before Finals.
144
+ */
145
+ export function buildBracketPlan(
146
+ bracketSize: number,
147
+ hasThirdPlace: boolean
148
+ ): BracketPlan {
149
+ const rounds = buildBracketRounds(bracketSize, hasThirdPlace);
150
+
151
+ let counter = 1;
152
+ const roundMatchNumbers = rounds.map((round) =>
153
+ Array.from({ length: round.matchCount }, () => counter++)
154
+ );
155
+
156
+ // Winner linkage skips Third Round — it receives losers, not winners.
157
+ const linkage: Record<
158
+ number,
159
+ { nextMatchNumber: number; winnerSlot: 'home' | 'away' }
160
+ > = {};
161
+ const nonThirdRounds = rounds
162
+ .map((round, index) => ({ round, index }))
163
+ .filter(({ round }) => round.name !== 'Third Round');
164
+
165
+ for (let ri = 0; ri < nonThirdRounds.length - 1; ri++) {
166
+ const currentNums = roundMatchNumbers[nonThirdRounds[ri].index];
167
+ const nextNums = roundMatchNumbers[nonThirdRounds[ri + 1].index];
168
+
169
+ currentNums.forEach((num, mi) => {
170
+ linkage[num] = {
171
+ nextMatchNumber: nextNums[Math.floor(mi / 2)],
172
+ winnerSlot: mi % 2 === 0 ? 'home' : 'away',
173
+ };
174
+ });
175
+ }
176
+
177
+ const matches: PlannedMatch[] = [];
178
+ rounds.forEach((round, ri) => {
179
+ roundMatchNumbers[ri].forEach((num, mi) => {
180
+ const link = linkage[num] ?? { nextMatchNumber: null, winnerSlot: null };
181
+ matches.push({
182
+ round: round.name,
183
+ matchNumber: num,
184
+ nextMatchNumber: link.nextMatchNumber,
185
+ winnerSlot: link.winnerSlot,
186
+ homeSlot: ri === 0 ? mi * 2 : null,
187
+ awaySlot: ri === 0 ? mi * 2 + 1 : null,
188
+ });
189
+ });
190
+ });
191
+
192
+ return { bracketSize, rounds, matches };
193
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Hand-written contract primitives.
3
+ *
4
+ * Everything here is maintained by hand — the generated models in
5
+ * `src/generated` import from this module, so keep the names stable.
6
+ */
7
+
8
+ /** Strapi 5 returns flat documents: no `attributes` envelope. */
9
+ export interface StrapiDocument {
10
+ id: number;
11
+ documentId: string;
12
+ createdAt?: string | null;
13
+ updatedAt?: string | null;
14
+ publishedAt?: string | null;
15
+ locale?: string | null;
16
+ }
17
+
18
+ /** A single size variant produced by the upload plugin. */
19
+ export interface StrapiMediaFormat {
20
+ name: string;
21
+ hash: string;
22
+ ext: string;
23
+ mime: string;
24
+ width: number;
25
+ height: number;
26
+ size: number;
27
+ url: string;
28
+ path?: string | null;
29
+ }
30
+
31
+ /**
32
+ * Size variants the upload plugin generates. The four named breakpoints are
33
+ * Strapi's defaults; the index signature covers custom ones.
34
+ */
35
+ export interface StrapiMediaFormats {
36
+ thumbnail?: StrapiMediaFormat;
37
+ small?: StrapiMediaFormat;
38
+ medium?: StrapiMediaFormat;
39
+ large?: StrapiMediaFormat;
40
+ [breakpoint: string]: StrapiMediaFormat | undefined;
41
+ }
42
+
43
+ /** `plugin::upload.file` */
44
+ export interface StrapiMedia {
45
+ id: number;
46
+ documentId?: string;
47
+ name: string;
48
+ alternativeText?: string | null;
49
+ caption?: string | null;
50
+ width?: number | null;
51
+ height?: number | null;
52
+ formats?: StrapiMediaFormats | null;
53
+ hash: string;
54
+ ext?: string | null;
55
+ mime: string;
56
+ size: number;
57
+ url: string;
58
+ previewUrl?: string | null;
59
+ provider?: string;
60
+ provider_metadata?: unknown;
61
+ folderPath?: string;
62
+ createdAt?: string;
63
+ updatedAt?: string;
64
+ publishedAt?: string;
65
+ locale?: string | null;
66
+ }
67
+
68
+ /** Rich-text `blocks` payload; shape is defined by the blocks renderer. */
69
+ export type BlocksContent = unknown[];
70
+
71
+ // ── write payloads ─────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * How a relation is sent on create/update. Strapi accepts a bare documentId,
75
+ * a numeric id, or the connect/set/disconnect long form.
76
+ */
77
+ export type RelationInput =
78
+ | string
79
+ | number
80
+ | { id: number }
81
+ | { documentId: string }
82
+ | { connect?: Array<string | number>; disconnect?: Array<string | number> }
83
+ | { set: Array<string | number> };
84
+
85
+ /** Media is written as the uploaded file's numeric id. */
86
+ export type MediaInput = number | number[] | null;
87
+
88
+ // ── responses ──────────────────────────────────────────────────────────────
89
+
90
+ export interface Pagination {
91
+ page: number;
92
+ pageSize: number;
93
+ pageCount: number;
94
+ total: number;
95
+ }
96
+
97
+ export interface Meta {
98
+ pagination?: Pagination;
99
+ [key: string]: unknown;
100
+ }
101
+
102
+ /** Single-entity response. Strapi still sends `meta`, usually `{}`. */
103
+ export interface StrapiResponse<T> {
104
+ data: T;
105
+ meta?: Meta;
106
+ }
107
+
108
+ /** Collection response. */
109
+ export interface StrapiCollectionResponse<T> {
110
+ data: T[];
111
+ meta?: Meta;
112
+ }
113
+
114
+ export interface StrapiError {
115
+ status: number;
116
+ name: string;
117
+ message: string;
118
+ details?: unknown;
119
+ }
120
+
121
+ export interface StrapiErrorResponse {
122
+ data: null;
123
+ error: StrapiError;
124
+ }
125
+
126
+ // ── populate awareness ─────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Marks relations as present.
130
+ *
131
+ * Generated models leave every relation optional, because whether one comes
132
+ * back depends entirely on the query's `populate`. Use this at a call site to
133
+ * record what that particular query actually asked for:
134
+ *
135
+ * ```ts
136
+ * type WithGame = Populated<Tournament, 'game' | 'prizes'>;
137
+ * ```
138
+ *
139
+ * This is the type-level counterpart to remembering to populate — if the query
140
+ * and the annotation disagree, that is a bug the reader can see.
141
+ */
142
+ export type Populated<T, K extends keyof T> = T & {
143
+ [P in K]-?: NonNullable<T[P]>;
144
+ };
145
+
146
+ /** Recursively marks every relation on `T` as optional (the default shape). */
147
+ export type Unpopulated<T> = {
148
+ [P in keyof T]?: T[P];
149
+ };
150
+
151
+ /** `plugin::users-permissions.role` */
152
+ export interface UserRole {
153
+ id: number;
154
+ documentId?: string;
155
+ name: string;
156
+ description?: string | null;
157
+ type?: string | null;
158
+ }