sonamu 0.9.16 → 0.9.18

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.
@@ -15,6 +15,10 @@ import {
15
15
  import { isSearchTextProp } from "../types/types";
16
16
  import { formatCode } from "../utils/formatter";
17
17
  import { differenceWith, intersectionBy } from "../utils/utils";
18
+ import {
19
+ normalizeIndexWherePredicate,
20
+ normalizeIndexWherePredicateForComparison,
21
+ } from "./index-where-predicate";
18
22
  import { PostgreSQLSchemaReader } from "./postgresql-schema-reader";
19
23
 
20
24
  /**
@@ -1612,15 +1616,28 @@ export function getAlterIndexesTo(entityIndexes: MigrationIndex[], dbIndexes: Mi
1612
1616
 
1613
1617
  const normalizedEntityIndexes = entityIndexes.map(setMigrationIndexDefaults);
1614
1618
  const normalizedDbIndexes = dbIndexes.map(setMigrationIndexDefaults);
1619
+ // 비교에는 정규화된 predicate를 쓰되, 실제 migration 출력은 원본 SQL을 보존한다.
1620
+ const comparisonEntityIndexes = entityIndexes.map(setMigrationIndexComparisonDefaults);
1621
+ const comparisonDbIndexes = dbIndexes.map(setMigrationIndexComparisonDefaults);
1615
1622
  const extraIndexes = {
1616
- db: diff(normalizedDbIndexes, normalizedEntityIndexes, identity),
1617
- entity: diff(normalizedEntityIndexes, normalizedDbIndexes, identity),
1623
+ db: diff(comparisonDbIndexes, comparisonEntityIndexes, identity),
1624
+ entity: diff(comparisonEntityIndexes, comparisonDbIndexes, identity),
1618
1625
  };
1619
1626
  if (extraIndexes.entity.length > 0) {
1620
- indexesTo.add = indexesTo.add.concat(extraIndexes.entity);
1627
+ const addIdentities = new Set(extraIndexes.entity.map(identity));
1628
+ indexesTo.add = indexesTo.add.concat(
1629
+ normalizedEntityIndexes.filter((_, index) =>
1630
+ addIdentities.has(identity(comparisonEntityIndexes[index])),
1631
+ ),
1632
+ );
1621
1633
  }
1622
1634
  if (extraIndexes.db.length > 0) {
1623
- indexesTo.drop = indexesTo.drop.concat(extraIndexes.db);
1635
+ const dropIdentities = new Set(extraIndexes.db.map(identity));
1636
+ indexesTo.drop = indexesTo.drop.concat(
1637
+ normalizedDbIndexes.filter((_, index) =>
1638
+ dropIdentities.has(identity(comparisonDbIndexes[index])),
1639
+ ),
1640
+ );
1624
1641
  }
1625
1642
 
1626
1643
  return indexesTo;
@@ -1639,10 +1656,24 @@ function genIndexDropDefinition(index: MigrationIndex) {
1639
1656
  * DB 조회 결과와 비교하기 위한 인덱스 기본값 설정
1640
1657
  */
1641
1658
  export function setMigrationIndexDefaults(index: MigrationIndex): MigrationIndex {
1659
+ return setMigrationIndexDefaultsBase(index, false);
1660
+ }
1661
+
1662
+ function setMigrationIndexComparisonDefaults(index: MigrationIndex): MigrationIndex {
1663
+ // DB canonical SQL과 entity SQL의 표현 차이를 diff identity에서만 흡수한다.
1664
+ return setMigrationIndexDefaultsBase(index, true);
1665
+ }
1666
+
1667
+ function setMigrationIndexDefaultsBase(
1668
+ index: MigrationIndex,
1669
+ normalizeWhereForComparison: boolean,
1670
+ ): MigrationIndex {
1642
1671
  const isVectorIndex = index.type === "hnsw" || index.type === "ivfflat";
1643
1672
  const supportsOrdering = !isVectorIndex && (!index.using || index.using === "btree");
1644
1673
  const normalizedUsing = isVectorIndex ? index.using : (index.using ?? "btree");
1645
- const normalizedWhere = normalizeIndexWherePredicate(index.where);
1674
+ const normalizedWhere = normalizeWhereForComparison
1675
+ ? normalizeIndexWherePredicateForComparison(index.where)
1676
+ : normalizeIndexWherePredicate(index.where);
1646
1677
 
1647
1678
  return {
1648
1679
  ...index,
@@ -1662,73 +1693,6 @@ export function setMigrationIndexDefaults(index: MigrationIndex): MigrationIndex
1662
1693
  };
1663
1694
  }
1664
1695
 
1665
- function normalizeIndexWherePredicate(where: string | undefined): string | undefined {
1666
- if (!where) {
1667
- return undefined;
1668
- }
1669
-
1670
- const trimmed = where.trim();
1671
- if (trimmed.length === 0) {
1672
- return undefined;
1673
- }
1674
-
1675
- if (trimmed.startsWith("(") && trimmed.endsWith(")")) {
1676
- const closeIndex = findMatchingParenthesisInSql(trimmed, 0);
1677
- if (closeIndex === trimmed.length - 1) {
1678
- return trimmed.slice(1, -1).trim();
1679
- }
1680
- }
1681
-
1682
- return trimmed;
1683
- }
1684
-
1685
- function findMatchingParenthesisInSql(source: string, openIndex: number): number {
1686
- let depth = 0;
1687
- let inSingleQuote = false;
1688
- let inDoubleQuote = false;
1689
-
1690
- for (let index = openIndex; index < source.length; index += 1) {
1691
- const char = source[index];
1692
- const nextChar = source[index + 1];
1693
-
1694
- if (char === "'" && !inDoubleQuote) {
1695
- if (inSingleQuote && nextChar === "'") {
1696
- index += 1;
1697
- continue;
1698
- }
1699
- inSingleQuote = !inSingleQuote;
1700
- continue;
1701
- }
1702
-
1703
- if (char === '"' && !inSingleQuote) {
1704
- if (inDoubleQuote && nextChar === '"') {
1705
- index += 1;
1706
- continue;
1707
- }
1708
- inDoubleQuote = !inDoubleQuote;
1709
- continue;
1710
- }
1711
-
1712
- if (inSingleQuote || inDoubleQuote) {
1713
- continue;
1714
- }
1715
-
1716
- if (char === "(") {
1717
- depth += 1;
1718
- continue;
1719
- }
1720
-
1721
- if (char === ")") {
1722
- depth -= 1;
1723
- if (depth === 0) {
1724
- return index;
1725
- }
1726
- }
1727
- }
1728
-
1729
- return -1;
1730
- }
1731
-
1732
1696
  /**
1733
1697
  * 테이블 변경 케이스 - Foreign Key 변경
1734
1698
  */
@@ -0,0 +1,392 @@
1
+ import SqlParser from "node-sql-parser";
2
+
3
+ const INDEX_WHERE_SQL_PARSER = new SqlParser.Parser();
4
+
5
+ /**
6
+ * Migration DDL 출력에 사용할 index predicate를 정리한다.
7
+ * 원본 SQL 표현은 보존하고, 빈 문자열과 전체를 감싼 괄호만 제거한다.
8
+ */
9
+ export function normalizeIndexWherePredicate(where: string | undefined): string | undefined {
10
+ if (!where) {
11
+ return undefined;
12
+ }
13
+
14
+ // 출력 경로에서는 사용자가 작성한 predicate를 최대한 그대로 유지한다.
15
+ const trimmed = removeOuterSqlParentheses(where.trim());
16
+ if (trimmed.length === 0) {
17
+ return undefined;
18
+ }
19
+
20
+ return trimmed;
21
+ }
22
+
23
+ /**
24
+ * Index diff 비교에 사용할 predicate identity를 만든다.
25
+ * PostgreSQL canonical 표현 차이는 AST 기반 정규화로 흡수한다.
26
+ */
27
+ export function normalizeIndexWherePredicateForComparison(
28
+ where: string | undefined,
29
+ ): string | undefined {
30
+ const normalized = normalizeIndexWherePredicate(where);
31
+ if (!normalized) {
32
+ return undefined;
33
+ }
34
+
35
+ // 파싱 가능한 predicate만 canonical form으로 바꾸고, 실패하면 기존 strict 비교를 유지한다.
36
+ return normalizeIndexWherePredicateByAst(normalized) ?? normalized;
37
+ }
38
+
39
+ function removeOuterSqlParentheses(source: string): string {
40
+ let trimmed = source.trim();
41
+
42
+ // 전체 predicate를 감싼 괄호만 제거하고 내부 grouping은 건드리지 않는다.
43
+ while (trimmed.startsWith("(") && trimmed.endsWith(")")) {
44
+ const closeIndex = findMatchingParenthesisInSql(trimmed, 0);
45
+ if (closeIndex === trimmed.length - 1) {
46
+ trimmed = trimmed.slice(1, -1).trim();
47
+ continue;
48
+ }
49
+
50
+ break;
51
+ }
52
+
53
+ return trimmed;
54
+ }
55
+
56
+ function normalizeIndexWherePredicateByAst(where: string): string | undefined {
57
+ try {
58
+ // node-sql-parser는 raw predicate를 직접 파싱하지 않으므로 임시 SELECT WHERE로 감싼다.
59
+ const parsed = INDEX_WHERE_SQL_PARSER.astify(
60
+ `SELECT * FROM __sonamu_index_predicate_source WHERE ${where}`,
61
+ { database: "postgresql" },
62
+ );
63
+ const statement = Array.isArray(parsed) ? parsed[0] : parsed;
64
+ if (!isSqlAstRecord(statement)) {
65
+ return undefined;
66
+ }
67
+
68
+ return serializeIndexWhereAst(statement.where);
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ function serializeIndexWhereAst(node: unknown): string {
75
+ // PostgreSQL이 IN을 ANY(ARRAY[...])로 재작성하므로 membership은 먼저 특수 처리한다.
76
+ const membership = serializeSqlMembershipPredicate(node);
77
+ if (membership) {
78
+ return membership;
79
+ }
80
+
81
+ if (!isSqlAstRecord(node)) {
82
+ return JSON.stringify(node);
83
+ }
84
+
85
+ const type = getSqlAstString(node, "type");
86
+ switch (type) {
87
+ case "binary_expr": {
88
+ const operator = normalizeSqlOperator(getSqlAstString(node, "operator"));
89
+ const left = serializeIndexWhereAst(node.left);
90
+ const right = serializeIndexWhereAst(node.right);
91
+ if (operator === "AND" || operator === "OR") {
92
+ // AND/OR는 순서가 diff 의미를 바꾸지 않으므로 stable identity로 정렬한다.
93
+ return `(${[left, right].toSorted().join(` ${operator} `)})`;
94
+ }
95
+ return `(${left} ${operator} ${right})`;
96
+ }
97
+ case "unary_expr": {
98
+ const operator = normalizeSqlOperator(getSqlAstString(node, "operator"));
99
+ return `(${operator} ${serializeIndexWhereAst(node.expr)})`;
100
+ }
101
+ case "column_ref": {
102
+ return serializeSqlColumnRef(node);
103
+ }
104
+ case "single_quote_string": {
105
+ return `string:${JSON.stringify(getSqlAstString(node, "value"))}`;
106
+ }
107
+ case "number": {
108
+ return `number:${String(node.value)}`;
109
+ }
110
+ case "bool": {
111
+ return `bool:${String(node.value).toLowerCase()}`;
112
+ }
113
+ case "null": {
114
+ return "null";
115
+ }
116
+ case "cast": {
117
+ const targetType = getSqlCastTargetType(node);
118
+ const expr = node.expr;
119
+ if (targetType && isTextLikeSqlType(targetType) && isSqlStringLiteralLike(expr)) {
120
+ return serializeIndexWhereAst(stripTextLikeSqlCast(expr));
121
+ }
122
+ return `cast(${serializeIndexWhereAst(expr)} as ${targetType ?? "unknown"})`;
123
+ }
124
+ case "function": {
125
+ return `fn:${getSqlFunctionName(node)}(${serializeSqlExprList(node.args).join(",")})`;
126
+ }
127
+ case "array": {
128
+ return `array:[${serializeSqlArrayElements(node).map(serializeIndexWhereAst).join(",")}]`;
129
+ }
130
+ case "expr_list": {
131
+ return `list:[${serializeSqlExprList(node).map(serializeIndexWhereAst).join(",")}]`;
132
+ }
133
+ case "default": {
134
+ return `ident:${normalizeSqlIdentifier(getSqlAstString(node, "value") ?? "")}`;
135
+ }
136
+ default: {
137
+ return serializeUnknownSqlAstRecord(node);
138
+ }
139
+ }
140
+ }
141
+
142
+ function serializeSqlMembershipPredicate(node: unknown): string | undefined {
143
+ if (!isSqlAstRecord(node) || getSqlAstString(node, "type") !== "binary_expr") {
144
+ return undefined;
145
+ }
146
+
147
+ // IN (...)과 = ANY(ARRAY[...])를 동일한 unordered membership 표현으로 맞춘다.
148
+ const operator = normalizeSqlOperator(getSqlAstString(node, "operator"));
149
+ const left = serializeIndexWhereAst(stripTextLikeSqlCast(node.left));
150
+ const values = (() => {
151
+ if (operator === "IN") {
152
+ return serializeSqlMembershipValues(serializeSqlExprList(node.right));
153
+ }
154
+
155
+ if (operator === "=") {
156
+ return serializeSqlMembershipValues(getSqlAnyArrayElements(node.right));
157
+ }
158
+
159
+ return undefined;
160
+ })();
161
+
162
+ if (!values) {
163
+ return undefined;
164
+ }
165
+
166
+ return `membership:${left}:in:[${values.join(",")}]`;
167
+ }
168
+
169
+ function serializeSqlMembershipValues(values: unknown[] | undefined): string[] | undefined {
170
+ if (!values) {
171
+ return undefined;
172
+ }
173
+
174
+ // membership 값의 순서와 중복은 의미가 없으므로 안정적인 identity로 정렬한다.
175
+ const serialized = values.map((value) => serializeIndexWhereAst(stripTextLikeSqlCast(value)));
176
+ return [...new Set(serialized)].toSorted();
177
+ }
178
+
179
+ function getSqlAnyArrayElements(node: unknown): unknown[] | undefined {
180
+ if (!isSqlAstRecord(node) || getSqlAstString(node, "type") !== "function") {
181
+ return undefined;
182
+ }
183
+ if (getSqlFunctionName(node) !== "any") {
184
+ return undefined;
185
+ }
186
+
187
+ // ANY()의 첫 번째 인자만 PostgreSQL 배열 membership 대상으로 해석한다.
188
+ const [arg] = serializeSqlExprList(node.args);
189
+ if (!arg) {
190
+ return undefined;
191
+ }
192
+
193
+ return serializeSqlArrayElements(stripTextLikeSqlCast(arg));
194
+ }
195
+
196
+ function serializeSqlArrayElements(node: unknown): unknown[] {
197
+ if (!isSqlAstRecord(node)) {
198
+ return [];
199
+ }
200
+
201
+ // parser 버전에 따라 배열 원소가 array.expr_list 또는 expr_list로 들어온다.
202
+ if (getSqlAstString(node, "type") === "array") {
203
+ return serializeSqlExprList(node.expr_list);
204
+ }
205
+
206
+ return serializeSqlExprList(node);
207
+ }
208
+
209
+ function serializeSqlExprList(node: unknown): unknown[] {
210
+ if (!isSqlAstRecord(node)) {
211
+ return [];
212
+ }
213
+
214
+ // expr_list는 실제 AST 노드 배열이고, 단일 표현식은 배열 하나로 감싸 통일한다.
215
+ if (getSqlAstString(node, "type") === "expr_list" && Array.isArray(node.value)) {
216
+ return node.value;
217
+ }
218
+
219
+ return [node];
220
+ }
221
+
222
+ function stripTextLikeSqlCast(node: unknown): unknown {
223
+ if (!isSqlAstRecord(node) || getSqlAstString(node, "type") !== "cast") {
224
+ return node;
225
+ }
226
+
227
+ // PostgreSQL은 varchar/text cast를 배열 또는 원소 쪽으로 옮길 수 있어 비교에서 제거한다.
228
+ const targetType = getSqlCastTargetType(node);
229
+ if (!targetType || !isTextLikeSqlType(targetType)) {
230
+ return node;
231
+ }
232
+
233
+ return stripTextLikeSqlCast(node.expr);
234
+ }
235
+
236
+ function isSqlStringLiteralLike(node: unknown): boolean {
237
+ // 문자열 literal에 붙은 text 계열 cast는 membership 비교에서 의미가 같다.
238
+ const stripped = stripTextLikeSqlCast(node);
239
+ return isSqlAstRecord(stripped) && getSqlAstString(stripped, "type") === "single_quote_string";
240
+ }
241
+
242
+ function getSqlCastTargetType(node: Record<string, unknown>): string | undefined {
243
+ if (!Array.isArray(node.target)) {
244
+ return undefined;
245
+ }
246
+
247
+ // character varying처럼 여러 token으로 오는 타입명을 하나의 정규화 문자열로 합친다.
248
+ const dataTypes = node.target
249
+ .map((target) => (isSqlAstRecord(target) ? getSqlAstString(target, "dataType") : undefined))
250
+ .filter((dataType): dataType is string => dataType !== undefined);
251
+
252
+ if (dataTypes.length === 0) {
253
+ return undefined;
254
+ }
255
+
256
+ return normalizeSqlDataType(dataTypes.join(" "));
257
+ }
258
+
259
+ function isTextLikeSqlType(type: string): boolean {
260
+ // 배열 cast(text[])도 원소 membership 비교에서는 text 계열 cast로 취급한다.
261
+ const normalized = normalizeSqlDataType(type).replace(/\[\]$/g, "");
262
+ return normalized === "text" || normalized === "varchar";
263
+ }
264
+
265
+ function normalizeSqlDataType(type: string): string {
266
+ return type
267
+ .trim()
268
+ .toLowerCase()
269
+ .replace(/\s+/g, " ")
270
+ .replace(/^character varying$/, "varchar");
271
+ }
272
+
273
+ function serializeSqlColumnRef(node: Record<string, unknown>): string {
274
+ const table = serializeSqlIdentifierNode(node.table);
275
+ const column = serializeSqlIdentifierNode(node.column);
276
+ return `column:${table ? `${table}.` : ""}${column}`;
277
+ }
278
+
279
+ function serializeSqlIdentifierNode(node: unknown): string {
280
+ if (typeof node === "string") {
281
+ return normalizeSqlIdentifier(node);
282
+ }
283
+
284
+ // quoted identifier와 function name의 AST shape 차이를 같은 identifier 문자열로 흡수한다.
285
+ if (isSqlAstRecord(node)) {
286
+ if (typeof node.value === "string") {
287
+ return normalizeSqlIdentifier(node.value);
288
+ }
289
+
290
+ if (isSqlAstRecord(node.expr) && typeof node.expr.value === "string") {
291
+ return normalizeSqlIdentifier(node.expr.value);
292
+ }
293
+ }
294
+
295
+ return "";
296
+ }
297
+
298
+ function getSqlFunctionName(node: Record<string, unknown>): string {
299
+ const name = node.name;
300
+ if (typeof name === "string") {
301
+ return normalizeSqlIdentifier(name);
302
+ }
303
+
304
+ // ANY처럼 name.name 배열로 오는 function AST를 schema-qualified 이름까지 지원한다.
305
+ if (isSqlAstRecord(name)) {
306
+ if (Array.isArray(name.name)) {
307
+ return name.name.map(serializeSqlIdentifierNode).join(".");
308
+ }
309
+
310
+ const nameParts = serializeSqlExprList(name);
311
+ if (nameParts.length > 0) {
312
+ return nameParts.map(serializeSqlIdentifierNode).join(".");
313
+ }
314
+ }
315
+
316
+ return "";
317
+ }
318
+
319
+ function normalizeSqlIdentifier(identifier: string): string {
320
+ return identifier.trim().toLowerCase();
321
+ }
322
+
323
+ function normalizeSqlOperator(operator: string | undefined): string {
324
+ return (operator ?? "").trim().toUpperCase().replace(/\s+/g, " ");
325
+ }
326
+
327
+ function serializeUnknownSqlAstRecord(node: Record<string, unknown>): string {
328
+ // 아직 명시적으로 다루지 않는 AST 노드는 key 정렬로 최소한 안정적인 비교값을 만든다.
329
+ const entries = Object.entries(node)
330
+ .filter(([key]) => key !== "parentheses")
331
+ .toSorted(([left], [right]) => left.localeCompare(right))
332
+ .map(([key, value]) => `${key}:${serializeIndexWhereAst(value)}`);
333
+
334
+ return `record:{${entries.join(",")}}`;
335
+ }
336
+
337
+ function isSqlAstRecord(value: unknown): value is Record<string, unknown> {
338
+ return typeof value === "object" && value !== null && !Array.isArray(value);
339
+ }
340
+
341
+ function getSqlAstString(node: Record<string, unknown>, key: string): string | undefined {
342
+ const value = node[key];
343
+ return typeof value === "string" ? value : undefined;
344
+ }
345
+
346
+ function findMatchingParenthesisInSql(source: string, openIndex: number): number {
347
+ let depth = 0;
348
+ let inSingleQuote = false;
349
+ let inDoubleQuote = false;
350
+
351
+ // SQL 문자열과 quoted identifier 내부 괄호는 grouping 괄호로 세지 않는다.
352
+ for (let index = openIndex; index < source.length; index += 1) {
353
+ const char = source[index];
354
+ const nextChar = source[index + 1];
355
+
356
+ if (char === "'" && !inDoubleQuote) {
357
+ if (inSingleQuote && nextChar === "'") {
358
+ index += 1;
359
+ continue;
360
+ }
361
+ inSingleQuote = !inSingleQuote;
362
+ continue;
363
+ }
364
+
365
+ if (char === '"' && !inSingleQuote) {
366
+ if (inDoubleQuote && nextChar === '"') {
367
+ index += 1;
368
+ continue;
369
+ }
370
+ inDoubleQuote = !inDoubleQuote;
371
+ continue;
372
+ }
373
+
374
+ if (inSingleQuote || inDoubleQuote) {
375
+ continue;
376
+ }
377
+
378
+ if (char === "(") {
379
+ depth += 1;
380
+ continue;
381
+ }
382
+
383
+ if (char === ")") {
384
+ depth -= 1;
385
+ if (depth === 0) {
386
+ return index;
387
+ }
388
+ }
389
+ }
390
+
391
+ return -1;
392
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expectTypeOf, it } from "vitest";
2
+ import { type infer as ZodInfer } from "zod";
3
+
4
+ import { type SonamuFile, type SonamuFileSchema } from "./types";
5
+
6
+ declare module "./types" {
7
+ interface SonamuFileExtend {
8
+ facility_id?: number;
9
+ }
10
+ }
11
+
12
+ describe("SonamuFileSchema", () => {
13
+ it("declaration merging으로 정의한 확장 키만 타입에서 허용한다", () => {
14
+ type Parsed = ZodInfer<typeof SonamuFileSchema>;
15
+
16
+ expectTypeOf<Parsed>().toEqualTypeOf<SonamuFile>();
17
+
18
+ const baseOnly: Parsed = {
19
+ name: "report.pdf",
20
+ url: "https://example.com/report.pdf",
21
+ mime_type: "application/pdf",
22
+ size: 1024,
23
+ };
24
+ expectTypeOf(baseOnly).toEqualTypeOf<Parsed>();
25
+
26
+ const withExtension: Parsed = {
27
+ name: "report.pdf",
28
+ url: "https://example.com/report.pdf",
29
+ mime_type: "application/pdf",
30
+ size: 1024,
31
+ facility_id: 1,
32
+ };
33
+ expectTypeOf(withExtension).toEqualTypeOf<Parsed>();
34
+
35
+ const withTypo: Parsed = {
36
+ name: "report.pdf",
37
+ url: "https://example.com/report.pdf",
38
+ mime_type: "application/pdf",
39
+ size: 1024,
40
+ // @ts-expect-error facility_id 오타는 SonamuFileExtend에 없으면 거부되어야 한다.
41
+ faciliy_id: 1,
42
+ };
43
+ expectTypeOf(withTypo).toEqualTypeOf<Parsed>();
44
+ });
45
+ });
@@ -339,14 +339,13 @@ export interface SonamuFile extends SonamuFileBase, SonamuFileExtend {}
339
339
  */
340
340
  export interface UploadParams {}
341
341
 
342
- export const SonamuFileSchema = z
343
- .object({
342
+ export const SonamuFileSchema: z.ZodType<SonamuFile> = z
343
+ .looseObject({
344
344
  name: z.string(),
345
345
  url: z.string(),
346
346
  mime_type: z.string(),
347
347
  size: z.number(),
348
348
  })
349
- .and(z.custom<SonamuFileExtend>())
350
349
  .describe("SonamuFile");
351
350
 
352
351
  export const SonamuFileArraySchema = z.array(SonamuFileSchema).describe("SonamuFile[]");