nextly 0.0.1 → 0.0.2-alpha.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.
- package/LICENSE +22 -0
- package/README.md +122 -0
- package/dist/_dts-chunks/collections-handler.d-DjgO74Wt.d.ts +20540 -0
- package/dist/_dts-chunks/config.d-DNwsDnjs.d.ts +2589 -0
- package/dist/_dts-chunks/define-component.d-BUgTHmt3.d.ts +1149 -0
- package/dist/_dts-chunks/image-processor.d-OO1PmMrv.d.ts +335 -0
- package/dist/_dts-chunks/index.d-axCAzZ7m.d.ts +17842 -0
- package/dist/_dts-chunks/media.d-DjDOZo4B.d.ts +117 -0
- package/dist/_dts-chunks/on-error.d-CHIKWNxd.d.ts +38 -0
- package/dist/_dts-chunks/storage.d-BUhQ2we_.d.ts +404 -0
- package/dist/actions/index.d.ts +239 -0
- package/dist/actions/index.mjs +281 -0
- package/dist/api/auth-state.d.ts +5 -0
- package/dist/api/auth-state.mjs +131 -0
- package/dist/api/collections-schema-detail.d.ts +56 -0
- package/dist/api/collections-schema-detail.mjs +244 -0
- package/dist/api/collections-schema-export.d.ts +56 -0
- package/dist/api/collections-schema-export.mjs +129 -0
- package/dist/api/collections-schema.d.ts +59 -0
- package/dist/api/collections-schema.mjs +207 -0
- package/dist/api/components-detail.d.ts +50 -0
- package/dist/api/components-detail.mjs +132 -0
- package/dist/api/components.d.ts +69 -0
- package/dist/api/components.mjs +144 -0
- package/dist/api/email-providers-default.d.ts +40 -0
- package/dist/api/email-providers-default.mjs +75 -0
- package/dist/api/email-providers-detail.d.ts +81 -0
- package/dist/api/email-providers-detail.mjs +109 -0
- package/dist/api/email-providers-test.d.ts +43 -0
- package/dist/api/email-providers-test.mjs +114 -0
- package/dist/api/email-providers.d.ts +69 -0
- package/dist/api/email-providers.mjs +110 -0
- package/dist/api/email-send-template.d.ts +41 -0
- package/dist/api/email-send-template.mjs +58 -0
- package/dist/api/email-send.d.ts +42 -0
- package/dist/api/email-send.mjs +58 -0
- package/dist/api/email-templates-detail.d.ts +74 -0
- package/dist/api/email-templates-detail.mjs +112 -0
- package/dist/api/email-templates-layout.d.ts +55 -0
- package/dist/api/email-templates-layout.mjs +92 -0
- package/dist/api/email-templates-preview.d.ts +48 -0
- package/dist/api/email-templates-preview.mjs +93 -0
- package/dist/api/email-templates.d.ts +61 -0
- package/dist/api/email-templates.mjs +118 -0
- package/dist/api/health.d.ts +68 -0
- package/dist/api/health.mjs +67 -0
- package/dist/api/index.d.ts +54 -0
- package/dist/api/index.mjs +16 -0
- package/dist/api/media-bulk.d.ts +74 -0
- package/dist/api/media-bulk.mjs +196 -0
- package/dist/api/media-folders.d.ts +112 -0
- package/dist/api/media-folders.mjs +187 -0
- package/dist/api/media-handlers.d.ts +102 -0
- package/dist/api/media-handlers.mjs +437 -0
- package/dist/api/media.d.ts +117 -0
- package/dist/api/media.mjs +242 -0
- package/dist/api/singles-detail.d.ts +87 -0
- package/dist/api/singles-detail.mjs +170 -0
- package/dist/api/singles-schema-detail.d.ts +54 -0
- package/dist/api/singles-schema-detail.mjs +182 -0
- package/dist/api/singles.d.ts +34 -0
- package/dist/api/singles.mjs +94 -0
- package/dist/api/storage-upload-url.d.ts +48 -0
- package/dist/api/storage-upload-url.mjs +202 -0
- package/dist/api/uploads.d.ts +109 -0
- package/dist/api/uploads.mjs +359 -0
- package/dist/auth/index.d.ts +425 -0
- package/dist/auth/index.mjs +199 -0
- package/dist/boot-apply-PQSYLDIN.mjs +7 -0
- package/dist/chunk-2OALJTK6.mjs +489 -0
- package/dist/chunk-2Q2SX2CS.mjs +365 -0
- package/dist/chunk-2TFX4ND3.mjs +13 -0
- package/dist/chunk-2TWPDSYD.mjs +87 -0
- package/dist/chunk-2W3DVD7S.mjs +647 -0
- package/dist/chunk-2ZFKXPQM.mjs +88 -0
- package/dist/chunk-3FA7FKAV.mjs +832 -0
- package/dist/chunk-3NZ2KMBL.mjs +58 -0
- package/dist/chunk-4MJLT6PZ.mjs +0 -0
- package/dist/chunk-56WO4WX7.mjs +0 -0
- package/dist/chunk-5APFUGAD.mjs +89 -0
- package/dist/chunk-5HMZ644B.mjs +108 -0
- package/dist/chunk-67GXH6PR.mjs +32 -0
- package/dist/chunk-6JNEPWRW.mjs +14368 -0
- package/dist/chunk-6NFHQIJD.mjs +45 -0
- package/dist/chunk-7P6ASYW6.mjs +9 -0
- package/dist/chunk-A3WPLSDT.mjs +1364 -0
- package/dist/chunk-AGJ6F2T3.mjs +144 -0
- package/dist/chunk-AK6Z23OX.mjs +1464 -0
- package/dist/chunk-APKKRD2G.mjs +102 -0
- package/dist/chunk-B2GV2BWH.mjs +73 -0
- package/dist/chunk-D5HQBNUB.mjs +74 -0
- package/dist/chunk-DNNG377Z.mjs +204 -0
- package/dist/chunk-DP3G27G5.mjs +135 -0
- package/dist/chunk-DV6WVX2Q.mjs +0 -0
- package/dist/chunk-DXGGXIUZ.mjs +57 -0
- package/dist/chunk-EGXBZCGC.mjs +943 -0
- package/dist/chunk-ERCNLX3V.mjs +176 -0
- package/dist/chunk-FQULBZ53.mjs +850 -0
- package/dist/chunk-G2AA4QLC.mjs +262 -0
- package/dist/chunk-GDBJ5JCU.mjs +488 -0
- package/dist/chunk-GJNSJU4S.mjs +19 -0
- package/dist/chunk-GZ6DCQKC.mjs +69 -0
- package/dist/chunk-H26B4FYG.mjs +167 -0
- package/dist/chunk-I4JMR3UR.mjs +21 -0
- package/dist/chunk-INV7QKLG.mjs +508 -0
- package/dist/chunk-IUDOC7N7.mjs +46 -0
- package/dist/chunk-IZWPRDC3.mjs +206 -0
- package/dist/chunk-KIMNCZGV.mjs +15 -0
- package/dist/chunk-L6HW2DA7.mjs +15 -0
- package/dist/chunk-LAZXX4HR.mjs +100 -0
- package/dist/chunk-LDKCUMHK.mjs +95 -0
- package/dist/chunk-LRXMECUA.mjs +0 -0
- package/dist/chunk-M52VMPGA.mjs +119 -0
- package/dist/chunk-MGUWEEI6.mjs +160 -0
- package/dist/chunk-NRUWQ5Z7.mjs +419 -0
- package/dist/chunk-NSEFNNU4.mjs +25360 -0
- package/dist/chunk-NTHVDFGO.mjs +138 -0
- package/dist/chunk-O3QHXMOX.mjs +3166 -0
- package/dist/chunk-P7NH2OSC.mjs +2605 -0
- package/dist/chunk-PKMABBB5.mjs +184 -0
- package/dist/chunk-PWS6XGJK.mjs +76 -0
- package/dist/chunk-R6JJQHFC.mjs +20 -0
- package/dist/chunk-RJLLGGPG.mjs +0 -0
- package/dist/chunk-SBACDPNX.mjs +689 -0
- package/dist/chunk-TO5AFLVQ.mjs +124 -0
- package/dist/chunk-TS7GHTG2.mjs +5436 -0
- package/dist/chunk-UJ2IMJ4W.mjs +133 -0
- package/dist/chunk-UOP63Q54.mjs +102 -0
- package/dist/chunk-UUOFWCM6.mjs +78 -0
- package/dist/chunk-V4EQTOA4.mjs +893 -0
- package/dist/chunk-VJ66NCL4.mjs +193 -0
- package/dist/chunk-VQJQHVEV.mjs +29 -0
- package/dist/chunk-VTJADRO3.mjs +141 -0
- package/dist/chunk-VWF3JO32.mjs +0 -0
- package/dist/chunk-W4MGXIRR.mjs +27 -0
- package/dist/chunk-W5KKPZT5.mjs +1204 -0
- package/dist/chunk-WD34YQ6T.mjs +381 -0
- package/dist/chunk-WZBYMYVW.mjs +14 -0
- package/dist/chunk-X23WKS3Z.mjs +50 -0
- package/dist/chunk-X7TXCYYN.mjs +6496 -0
- package/dist/chunk-XGI4EMS3.mjs +140 -0
- package/dist/chunk-XZKLBMN6.mjs +1153 -0
- package/dist/chunk-YB7INWPY.mjs +0 -0
- package/dist/chunk-YV4Y7SDL.mjs +83 -0
- package/dist/chunk-YZNBLFIW.mjs +1688 -0
- package/dist/chunk-YZZCTONM.mjs +263 -0
- package/dist/chunk-ZE6A3FYH.mjs +289 -0
- package/dist/cli/nextly.mjs +68 -0
- package/dist/cli/utils/index.d.ts +449 -0
- package/dist/cli/utils/index.mjs +49 -0
- package/dist/component-schema-service-5577KVW6.mjs +11 -0
- package/dist/config-loader-23YEMC3Z.mjs +23 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.mjs +109 -0
- package/dist/container-ORGFGYSZ.mjs +9 -0
- package/dist/database/index.d.ts +12 -0
- package/dist/database/index.mjs +40 -0
- package/dist/database/seeders/index.d.ts +93 -0
- package/dist/database/seeders/index.mjs +47 -0
- package/dist/db-sync-demote-LJGKLB3S.mjs +117 -0
- package/dist/db-sync-promote-B26VSYQF.mjs +113 -0
- package/dist/dev-reload-broadcaster-B73IQ53V.mjs +25 -0
- package/dist/dist-M2NOU37V.mjs +19 -0
- package/dist/drizzle-kit-lazy-D2M2PXR2.mjs +13 -0
- package/dist/dynamic-collection-schema-service-IEXTPIZ7.mjs +8 -0
- package/dist/errors/index.d.ts +159 -0
- package/dist/errors/index.mjs +10 -0
- package/dist/factory-IWMBKUJM.mjs +15 -0
- package/dist/first-run-QIVKWJIF.mjs +63 -0
- package/dist/fresh-push-NR67DC3R.mjs +8 -0
- package/dist/index.d.ts +4175 -0
- package/dist/index.mjs +1336 -0
- package/dist/local-plugin-PTET4NAT.mjs +7 -0
- package/dist/logger-NU46DXNY.mjs +15 -0
- package/dist/logger-YE4TC7ZN.mjs +9 -0
- package/dist/migration-journal-EP532Y4L.mjs +139 -0
- package/dist/migrations/mysql/0000_eager_sentry.sql +174 -0
- package/dist/migrations/mysql/0001_soft_giant_girl.sql +27 -0
- package/dist/migrations/mysql/0002_media_table.sql +24 -0
- package/dist/migrations/mysql/0003_dynamic_singles.sql +37 -0
- package/dist/migrations/mysql/0004_dynamic_components.sql +35 -0
- package/dist/migrations/mysql/0005_user_management_tables.sql +92 -0
- package/dist/migrations/mysql/0006_api_keys.sql +36 -0
- package/dist/migrations/mysql/0007_general_settings.sql +20 -0
- package/dist/migrations/mysql/0008_site_settings_logo_url.sql +9 -0
- package/dist/migrations/mysql/0009_activity_log.sql +30 -0
- package/dist/migrations/mysql/0010_site_settings_sidebar.sql +13 -0
- package/dist/migrations/mysql/0011_missing_tables_and_columns.sql +54 -0
- package/dist/migrations/mysql/0012_image_sizes_and_focal_point.sql +30 -0
- package/dist/migrations/mysql/0012_media_folders.sql +43 -0
- package/dist/migrations/mysql/0013_user_brute_force_protection.sql +31 -0
- package/dist/migrations/mysql/0014_email_template_attachments.sql +12 -0
- package/dist/migrations/mysql/0015_media_uploaded_by_nullable.sql +15 -0
- package/dist/migrations/mysql/20260429_000000_000_initial_journal.sql +22 -0
- package/dist/migrations/mysql/20260501_000000_journal_batch.sql +17 -0
- package/dist/migrations/mysql/20260501_000001_audit_log.sql +24 -0
- package/dist/migrations/mysql/20260504_000000_nextly_meta.sql +21 -0
- package/dist/migrations/mysql/meta/0000_snapshot.json +1005 -0
- package/dist/migrations/mysql/meta/0001_snapshot.json +1099 -0
- package/dist/migrations/mysql/meta/_journal.json +41 -0
- package/dist/migrations/postgresql/0000_misty_king_bedlam.sql +169 -0
- package/dist/migrations/postgresql/0001_perpetual_captain_marvel.sql +8 -0
- package/dist/migrations/postgresql/0002_sad_spectrum.sql +16 -0
- package/dist/migrations/postgresql/0003_hesitant_ultron.sql +17 -0
- package/dist/migrations/postgresql/0004_media_table.sql +24 -0
- package/dist/migrations/postgresql/0005_media_folders.sql +36 -0
- package/dist/migrations/postgresql/0006_dynamic_collections_update.sql +50 -0
- package/dist/migrations/postgresql/0007_dynamic_singles.sql +38 -0
- package/dist/migrations/postgresql/0008_dynamic_components.sql +37 -0
- package/dist/migrations/postgresql/0009_user_management_tables.sql +95 -0
- package/dist/migrations/postgresql/0010_api_keys.sql +34 -0
- package/dist/migrations/postgresql/0011_general_settings.sql +20 -0
- package/dist/migrations/postgresql/0012_site_settings_logo_url.sql +9 -0
- package/dist/migrations/postgresql/0013_activity_log.sql +29 -0
- package/dist/migrations/postgresql/0014_image_sizes_and_focal_point.sql +33 -0
- package/dist/migrations/postgresql/0014_site_settings_sidebar.sql +13 -0
- package/dist/migrations/postgresql/0015_user_brute_force_protection.sql +29 -0
- package/dist/migrations/postgresql/0016_email_template_attachments.sql +12 -0
- package/dist/migrations/postgresql/0017_media_uploaded_by_nullable.sql +15 -0
- package/dist/migrations/postgresql/20260429_000000_000_initial_journal.sql +24 -0
- package/dist/migrations/postgresql/20260501_000000_journal_batch.sql +17 -0
- package/dist/migrations/postgresql/20260501_000001_audit_log.sql +24 -0
- package/dist/migrations/postgresql/20260504_000000_nextly_meta.sql +22 -0
- package/dist/migrations/postgresql/meta/0000_snapshot.json +1286 -0
- package/dist/migrations/postgresql/meta/0001_snapshot.json +1407 -0
- package/dist/migrations/postgresql/meta/0002_snapshot.json +1552 -0
- package/dist/migrations/postgresql/meta/0003_snapshot.json +1695 -0
- package/dist/migrations/postgresql/meta/0010_snapshot.json +2345 -0
- package/dist/migrations/postgresql/meta/_journal.json +90 -0
- package/dist/migrations/sqlite/0000_api_keys.sql +34 -0
- package/dist/migrations/sqlite/0001_general_settings.sql +20 -0
- package/dist/migrations/sqlite/0002_site_settings_logo_url.sql +9 -0
- package/dist/migrations/sqlite/0003_activity_log.sql +29 -0
- package/dist/migrations/sqlite/0004_image_sizes_and_focal_point.sql +29 -0
- package/dist/migrations/sqlite/0004_site_settings_sidebar.sql +11 -0
- package/dist/migrations/sqlite/0005_user_brute_force_protection.sql +29 -0
- package/dist/migrations/sqlite/0006_email_template_attachments.sql +12 -0
- package/dist/migrations/sqlite/0007_media_uploaded_by_nullable.sql +111 -0
- package/dist/migrations/sqlite/20260429_000000_000_initial_journal.sql +24 -0
- package/dist/migrations/sqlite/20260501_000000_journal_batch.sql +19 -0
- package/dist/migrations/sqlite/20260501_000001_audit_log.sql +24 -0
- package/dist/migrations/sqlite/20260504_000000_nextly_meta.sql +21 -0
- package/dist/migrations/sqlite/20260505_000000_user_management_tables.sql +77 -0
- package/dist/next.d.ts +57 -0
- package/dist/next.mjs +55 -0
- package/dist/observability/index.d.ts +87 -0
- package/dist/observability/index.mjs +57 -0
- package/dist/permissions-3DZZQZMI.mjs +39 -0
- package/dist/pipeline-YOML7SWF.mjs +29 -0
- package/dist/preview-ZZTR3QGS.mjs +9 -0
- package/dist/program-PW6UB2ZC.mjs +5934 -0
- package/dist/reconcile-single-tables-7ENVXJGB.mjs +7 -0
- package/dist/register-SF6E6FVU.mjs +49 -0
- package/dist/reload-config-HWQ4G5MM.mjs +23 -0
- package/dist/resolve-single-table-name-JSOMUB3R.mjs +7 -0
- package/dist/routeHandler-UNMMJIBM.mjs +77 -0
- package/dist/runtime-schema-generator-NRA6A6Z6.mjs +8 -0
- package/dist/runtime.d.ts +120 -0
- package/dist/runtime.mjs +73 -0
- package/dist/schema-hash-FMMG6VPJ.mjs +13 -0
- package/dist/schema-registry-EQ36FZDP.mjs +7 -0
- package/dist/scripts/load-env.mjs +42 -0
- package/dist/storage/index.d.ts +566 -0
- package/dist/storage/index.mjs +45 -0
- package/dist/super-admin-G5ZK5F4T.mjs +39 -0
- package/dist/system-table-service-WGSRVEGT.mjs +17 -0
- package/dist/users-7KELGRYJ.mjs +38 -0
- package/package.json +308 -9
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
env
|
|
3
|
+
} from "./chunk-UJ2IMJ4W.mjs";
|
|
4
|
+
|
|
5
|
+
// src/domains/dynamic-collections/services/dynamic-collection-validation-service.ts
|
|
6
|
+
import safeRegex from "safe-regex2";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
var MAX_REGEX_PATTERN_LENGTH = 200;
|
|
9
|
+
var REGEX_DDL_FORBIDDEN_CHARS = /[;\\\n\r\0]/;
|
|
10
|
+
var SQL_KEYWORDS = [
|
|
11
|
+
"select",
|
|
12
|
+
"insert",
|
|
13
|
+
"update",
|
|
14
|
+
"delete",
|
|
15
|
+
"drop",
|
|
16
|
+
"create",
|
|
17
|
+
"alter",
|
|
18
|
+
"table",
|
|
19
|
+
"from",
|
|
20
|
+
"where",
|
|
21
|
+
"join",
|
|
22
|
+
"union",
|
|
23
|
+
"order",
|
|
24
|
+
"group",
|
|
25
|
+
"having",
|
|
26
|
+
"limit",
|
|
27
|
+
"offset",
|
|
28
|
+
"index",
|
|
29
|
+
"constraint",
|
|
30
|
+
"primary",
|
|
31
|
+
"foreign",
|
|
32
|
+
"key",
|
|
33
|
+
"references",
|
|
34
|
+
"on",
|
|
35
|
+
"null",
|
|
36
|
+
"not",
|
|
37
|
+
"and",
|
|
38
|
+
"or",
|
|
39
|
+
"in",
|
|
40
|
+
"like",
|
|
41
|
+
"between",
|
|
42
|
+
"exists",
|
|
43
|
+
"case",
|
|
44
|
+
"when",
|
|
45
|
+
"then",
|
|
46
|
+
"else",
|
|
47
|
+
"end",
|
|
48
|
+
"as",
|
|
49
|
+
"distinct",
|
|
50
|
+
"all",
|
|
51
|
+
"any",
|
|
52
|
+
"some",
|
|
53
|
+
"into",
|
|
54
|
+
"values",
|
|
55
|
+
"set",
|
|
56
|
+
"cascade"
|
|
57
|
+
];
|
|
58
|
+
var RESERVED_COLLECTION_NAMES = [
|
|
59
|
+
"users",
|
|
60
|
+
"roles",
|
|
61
|
+
"permissions",
|
|
62
|
+
"sessions",
|
|
63
|
+
"accounts",
|
|
64
|
+
"dynamic_collections"
|
|
65
|
+
];
|
|
66
|
+
var RESERVED_FIELD_NAMES = [
|
|
67
|
+
"id",
|
|
68
|
+
"title",
|
|
69
|
+
"slug",
|
|
70
|
+
"created_at",
|
|
71
|
+
"updated_at"
|
|
72
|
+
];
|
|
73
|
+
var collectionNameSchema = z.string().min(1, "Collection name cannot be empty").max(50, "Collection name must be 50 characters or less").regex(
|
|
74
|
+
/^[a-z][a-z0-9_]*$/,
|
|
75
|
+
"Collection name must start with lowercase letter and contain only lowercase letters, numbers, and underscores"
|
|
76
|
+
).refine(
|
|
77
|
+
(name) => !RESERVED_COLLECTION_NAMES.includes(name),
|
|
78
|
+
{
|
|
79
|
+
message: "Collection name is reserved"
|
|
80
|
+
}
|
|
81
|
+
).refine(
|
|
82
|
+
(name) => !SQL_KEYWORDS.includes(name.toLowerCase()),
|
|
83
|
+
{
|
|
84
|
+
message: "Collection name is a reserved SQL keyword"
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
var fieldNameSchema = z.string().min(1, "Field name cannot be empty").max(50, "Field name must be 50 characters or less").regex(
|
|
88
|
+
/^[a-z][a-z0-9_]*$/,
|
|
89
|
+
"Field name must start with lowercase letter and contain only lowercase letters, numbers, and underscores"
|
|
90
|
+
).refine((name) => !RESERVED_FIELD_NAMES.includes(name), {
|
|
91
|
+
message: "Field name is reserved"
|
|
92
|
+
}).refine(
|
|
93
|
+
(name) => !SQL_KEYWORDS.includes(name.toLowerCase()),
|
|
94
|
+
{
|
|
95
|
+
message: "Field name is a reserved SQL keyword"
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
var fieldsArraySchema = z.array(z.any()).min(0).max(100, "Collection cannot have more than 100 fields");
|
|
99
|
+
var DynamicCollectionValidationService = class {
|
|
100
|
+
/**
|
|
101
|
+
* @throws Error if the name is invalid
|
|
102
|
+
*/
|
|
103
|
+
validateCollectionName(name) {
|
|
104
|
+
const result = collectionNameSchema.safeParse(name);
|
|
105
|
+
if (!result.success) {
|
|
106
|
+
const errorMessage = result.error.issues[0]?.message || "Invalid collection name";
|
|
107
|
+
throw new Error(errorMessage);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* @throws Error if any field name is invalid or duplicated
|
|
112
|
+
*/
|
|
113
|
+
validateFieldNames(fields) {
|
|
114
|
+
const countResult = fieldsArraySchema.safeParse(fields);
|
|
115
|
+
if (!countResult.success) {
|
|
116
|
+
const errorMessage = countResult.error.issues[0]?.message || "Invalid fields array";
|
|
117
|
+
throw new Error(errorMessage);
|
|
118
|
+
}
|
|
119
|
+
const fieldNames = /* @__PURE__ */ new Set();
|
|
120
|
+
const duplicates = [];
|
|
121
|
+
for (const field of fields) {
|
|
122
|
+
if (fieldNames.has(field.name)) {
|
|
123
|
+
duplicates.push(field.name);
|
|
124
|
+
}
|
|
125
|
+
fieldNames.add(field.name);
|
|
126
|
+
}
|
|
127
|
+
if (duplicates.length > 0) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Duplicate field names found: ${duplicates.join(", ")}. Each field must have a unique name.`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
for (const field of fields) {
|
|
133
|
+
const result = fieldNameSchema.safeParse(field.name);
|
|
134
|
+
if (!result.success) {
|
|
135
|
+
const errorMessage = result.error.issues[0]?.message || `Invalid field name "${field.name}"`;
|
|
136
|
+
throw new Error(errorMessage);
|
|
137
|
+
}
|
|
138
|
+
if (field.type === "relationship") {
|
|
139
|
+
this.validateRelationshipField(field);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* @throws Error if the relationship configuration is invalid
|
|
145
|
+
*/
|
|
146
|
+
validateRelationshipField(field) {
|
|
147
|
+
if (!field.options?.target) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Relationship field "${field.name}" must specify a target collection`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (!field.options?.relationType) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Relationship field "${field.name}" must specify a relationType (oneToOne, oneToMany, manyToOne, or manyToMany)`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const validTypes = ["oneToOne", "oneToMany", "manyToOne", "manyToMany"];
|
|
158
|
+
if (!validTypes.includes(field.options.relationType)) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Invalid relationType "${field.options.relationType}" for field "${field.name}". Must be one of: ${validTypes.join(", ")}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (field.options.onDelete) {
|
|
164
|
+
const validActions = ["cascade", "set null", "restrict", "no action"];
|
|
165
|
+
if (!validActions.includes(field.options.onDelete.toLowerCase())) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Invalid onDelete action "${field.options.onDelete}" for field "${field.name}". Must be one of: ${validActions.join(", ")}`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (field.options.onUpdate) {
|
|
172
|
+
const validActions = ["cascade", "set null", "restrict", "no action"];
|
|
173
|
+
if (!validActions.includes(field.options.onUpdate.toLowerCase())) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Invalid onUpdate action "${field.options.onUpdate}" for field "${field.name}". Must be one of: ${validActions.join(", ")}`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (field.options.relationType === "oneToOne" && !field.unique && !field.required) {
|
|
180
|
+
console.warn(
|
|
181
|
+
`One-to-one relationship "${field.name}" should typically be unique. Consider setting unique: true.`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (field.options.relationType === "manyToMany" && field.required) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Many-to-many relationship "${field.name}" cannot be marked as required. Use validation in your application logic instead.`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The previous check only blocked `(?{` and `(?>` —
|
|
192
|
+
* neither of which is even valid JS regex syntax, so the function
|
|
193
|
+
* accepted catastrophic patterns like `(a+)+b` that DoS the database
|
|
194
|
+
* regex engine on subsequent writes.
|
|
195
|
+
*
|
|
196
|
+
* The new gate has three layers:
|
|
197
|
+
*
|
|
198
|
+
* 1. **Length cap** (≤200 chars). Real validation patterns are
|
|
199
|
+
* short; long patterns are usually obfuscated.
|
|
200
|
+
* 2. **JS parse**. If `new RegExp(pattern)` throws, it's malformed
|
|
201
|
+
* regardless of the runtime that will execute it.
|
|
202
|
+
* 3. **`safe-regex2`** static analysis. Detects nested-quantifier
|
|
203
|
+
* and alternation explosion patterns (the standard ReDoS shapes).
|
|
204
|
+
*
|
|
205
|
+
* In this codebase the runtime engine is the database (Postgres `~`
|
|
206
|
+
* or MySQL `REGEXP`), not Node — JS-side runtime matching of admin-
|
|
207
|
+
* supplied patterns does not exist here. So we don't pull in the
|
|
208
|
+
* native re2 binding; the static `safe-regex2` check + length cap is
|
|
209
|
+
* the load-bearing defense for what actually ships to the DB.
|
|
210
|
+
*
|
|
211
|
+
* @throws Error if the regex is invalid, too long, or unsafe
|
|
212
|
+
*/
|
|
213
|
+
validateRegexPattern(fieldName, pattern) {
|
|
214
|
+
if (pattern.length > MAX_REGEX_PATTERN_LENGTH) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`Regex pattern for field "${fieldName}" exceeds the ${MAX_REGEX_PATTERN_LENGTH}-character cap (got ${pattern.length}).`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
new RegExp(pattern);
|
|
221
|
+
} catch (e) {
|
|
222
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
223
|
+
throw new Error(
|
|
224
|
+
`Invalid regex pattern for field "${fieldName}": ${message}`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (!safeRegex(pattern)) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`Regex pattern for field "${fieldName}" is unsafe (catastrophic backtracking detected).`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (REGEX_DDL_FORBIDDEN_CHARS.test(pattern)) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`Regex pattern for field "${fieldName}" contains characters that are not allowed in CHECK constraint expressions (no semicolons, backslashes, newlines, or null bytes).`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// src/domains/dynamic-collections/services/dynamic-collection-schema-service.ts
|
|
241
|
+
var DynamicCollectionSchemaService = class {
|
|
242
|
+
validationService;
|
|
243
|
+
dialect;
|
|
244
|
+
constructor(validationService, dialect) {
|
|
245
|
+
this.validationService = validationService || new DynamicCollectionValidationService();
|
|
246
|
+
this.dialect = dialect || env.DB_DIALECT || "postgresql";
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Quote identifier based on dialect
|
|
250
|
+
*/
|
|
251
|
+
quoteIdentifier(name) {
|
|
252
|
+
if (this.dialect === "mysql") {
|
|
253
|
+
return `\`${name}\``;
|
|
254
|
+
}
|
|
255
|
+
return `"${name}"`;
|
|
256
|
+
}
|
|
257
|
+
/** Convert camelCase to snake_case (e.g., publishedAt → published_at) */
|
|
258
|
+
toSnakeCase(str) {
|
|
259
|
+
return str.replace(/([A-Z])/g, "_$1").toLowerCase();
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Generate SQL migration for creating a new collection table
|
|
263
|
+
*
|
|
264
|
+
* @param tableName - The name of the table to create
|
|
265
|
+
* @param fields - Field definitions for the table
|
|
266
|
+
* @param options - Optional configuration (reserved for future use)
|
|
267
|
+
*/
|
|
268
|
+
generateMigrationSQL(tableName, fields, _options) {
|
|
269
|
+
const constraints = [];
|
|
270
|
+
const checks = [];
|
|
271
|
+
const junctionTables = [];
|
|
272
|
+
const columns = fields.map((f) => {
|
|
273
|
+
if (f.type === "relationship" && f.options?.relationType === "manyToMany") {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const type = this.mapFieldTypeToSQL(
|
|
277
|
+
f.type,
|
|
278
|
+
f.length,
|
|
279
|
+
f.options,
|
|
280
|
+
f.validation
|
|
281
|
+
);
|
|
282
|
+
const nullable = f.required ? "NOT NULL" : "";
|
|
283
|
+
const unique = f.unique || f.type === "relationship" && f.options?.relationType === "oneToOne" ? "UNIQUE" : "";
|
|
284
|
+
const defaultVal = f.default !== void 0 && f.default !== null ? `DEFAULT ${this.formatDefaultValue(f.default, f.type)}` : "";
|
|
285
|
+
if (f.validation) {
|
|
286
|
+
if (f.validation.min !== void 0) {
|
|
287
|
+
checks.push(
|
|
288
|
+
`${this.quoteIdentifier(f.name)} >= ${f.validation.min}`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (f.validation.max !== void 0) {
|
|
292
|
+
checks.push(
|
|
293
|
+
`${this.quoteIdentifier(f.name)} <= ${f.validation.max}`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (f.validation.minLength !== void 0 && (f.type === "text" || f.type === "textarea" || f.type === "email" || f.type === "password" || f.type === "code")) {
|
|
297
|
+
checks.push(
|
|
298
|
+
`LENGTH(${this.quoteIdentifier(f.name)}) >= ${f.validation.minLength}`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (f.validation.regex && this.dialect !== "sqlite") {
|
|
302
|
+
this.validationService.validateRegexPattern(
|
|
303
|
+
f.name,
|
|
304
|
+
f.validation.regex
|
|
305
|
+
);
|
|
306
|
+
const escapedRegex = f.validation.regex.replace(/'/g, "''");
|
|
307
|
+
if (this.dialect === "mysql") {
|
|
308
|
+
checks.push(
|
|
309
|
+
`${this.quoteIdentifier(this.toSnakeCase(f.name))} REGEXP '${escapedRegex}'`
|
|
310
|
+
);
|
|
311
|
+
} else {
|
|
312
|
+
checks.push(
|
|
313
|
+
`${this.quoteIdentifier(this.toSnakeCase(f.name))} ~ '${escapedRegex}'`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (f.type === "relationship" && f.options?.target) {
|
|
319
|
+
const relationType = f.options.relationType || "manyToOne";
|
|
320
|
+
const targetTable = `dc_${f.options.target}`;
|
|
321
|
+
if (relationType === "oneToOne" || relationType === "manyToOne" || relationType === "oneToMany") {
|
|
322
|
+
const onDelete = this.mapOnDeleteAction(
|
|
323
|
+
f.options.onDelete || "set null"
|
|
324
|
+
);
|
|
325
|
+
const onUpdate = this.mapOnUpdateAction(
|
|
326
|
+
f.options.onUpdate || "no action"
|
|
327
|
+
);
|
|
328
|
+
const fkColName = this.toSnakeCase(f.name);
|
|
329
|
+
constraints.push(
|
|
330
|
+
` CONSTRAINT ${this.quoteIdentifier(`fk_${tableName}_${fkColName}`)} FOREIGN KEY (${this.quoteIdentifier(fkColName)}) REFERENCES ${this.quoteIdentifier(targetTable)}(${this.quoteIdentifier("id")}) ON DELETE ${onDelete} ON UPDATE ${onUpdate}`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const colName = this.toSnakeCase(f.name);
|
|
335
|
+
return ` ${this.quoteIdentifier(colName)} ${type} ${nullable} ${unique} ${defaultVal}`.trim();
|
|
336
|
+
}).filter(Boolean).join(",\n");
|
|
337
|
+
const allColumnDefs = [];
|
|
338
|
+
if (this.dialect === "mysql") {
|
|
339
|
+
allColumnDefs.push(` ${this.quoteIdentifier("id")} varchar(36) PRIMARY KEY NOT NULL`);
|
|
340
|
+
} else {
|
|
341
|
+
allColumnDefs.push(` ${this.quoteIdentifier("id")} text PRIMARY KEY NOT NULL`);
|
|
342
|
+
}
|
|
343
|
+
const hasTitleField = fields.some((f) => f.name === "title");
|
|
344
|
+
if (!hasTitleField) {
|
|
345
|
+
if (this.dialect === "mysql") {
|
|
346
|
+
allColumnDefs.push(` ${this.quoteIdentifier("title")} varchar(255) NOT NULL`);
|
|
347
|
+
} else {
|
|
348
|
+
allColumnDefs.push(` ${this.quoteIdentifier("title")} text NOT NULL`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const hasSlugField = fields.some((f) => f.name === "slug");
|
|
352
|
+
if (!hasSlugField) {
|
|
353
|
+
if (this.dialect === "mysql") {
|
|
354
|
+
allColumnDefs.push(` ${this.quoteIdentifier("slug")} varchar(255) NOT NULL`);
|
|
355
|
+
} else {
|
|
356
|
+
allColumnDefs.push(` ${this.quoteIdentifier("slug")} text NOT NULL`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (columns.length > 0) {
|
|
360
|
+
allColumnDefs.push(columns);
|
|
361
|
+
}
|
|
362
|
+
if (_options?.hasStatus) {
|
|
363
|
+
if (this.dialect === "sqlite") {
|
|
364
|
+
allColumnDefs.push(
|
|
365
|
+
` ${this.quoteIdentifier("status")} text DEFAULT 'draft' NOT NULL`
|
|
366
|
+
);
|
|
367
|
+
} else if (this.dialect === "mysql") {
|
|
368
|
+
allColumnDefs.push(
|
|
369
|
+
` ${this.quoteIdentifier("status")} varchar(20) DEFAULT 'draft' NOT NULL`
|
|
370
|
+
);
|
|
371
|
+
} else {
|
|
372
|
+
allColumnDefs.push(
|
|
373
|
+
` ${this.quoteIdentifier("status")} varchar(20) DEFAULT 'draft' NOT NULL`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (checks.length > 0) {
|
|
378
|
+
allColumnDefs.push(` CONSTRAINT ${this.quoteIdentifier(`chk_${tableName}_validation`)} CHECK (${checks.join(" AND ")})`);
|
|
379
|
+
}
|
|
380
|
+
for (const c of constraints) {
|
|
381
|
+
allColumnDefs.push(c);
|
|
382
|
+
}
|
|
383
|
+
if (this.dialect === "sqlite") {
|
|
384
|
+
allColumnDefs.push(` ${this.quoteIdentifier("created_at")} integer DEFAULT (strftime('%s', 'now')) NOT NULL`);
|
|
385
|
+
allColumnDefs.push(` ${this.quoteIdentifier("updated_at")} integer DEFAULT (strftime('%s', 'now')) NOT NULL`);
|
|
386
|
+
} else if (this.dialect === "mysql") {
|
|
387
|
+
allColumnDefs.push(` ${this.quoteIdentifier("created_at")} timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL`);
|
|
388
|
+
allColumnDefs.push(` ${this.quoteIdentifier("updated_at")} timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL`);
|
|
389
|
+
} else {
|
|
390
|
+
allColumnDefs.push(` ${this.quoteIdentifier("created_at")} timestamp DEFAULT now() NOT NULL`);
|
|
391
|
+
allColumnDefs.push(` ${this.quoteIdentifier("updated_at")} timestamp DEFAULT now() NOT NULL`);
|
|
392
|
+
}
|
|
393
|
+
let sql = `-- Create dynamic collection: ${tableName}
|
|
394
|
+
CREATE TABLE IF NOT EXISTS ${this.quoteIdentifier(tableName)} (
|
|
395
|
+
${allColumnDefs.join(",\n")}
|
|
396
|
+
);`;
|
|
397
|
+
fields.forEach((f) => {
|
|
398
|
+
if (f.type === "relationship" && f.options?.relationType === "manyToMany" && f.options?.target) {
|
|
399
|
+
const junctionTableSQL = this.generateJunctionTable(tableName, f);
|
|
400
|
+
junctionTables.push(junctionTableSQL);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
const indexStatements = [];
|
|
404
|
+
fields.forEach((f) => {
|
|
405
|
+
if (f.type === "relationship" && f.options?.relationType !== "manyToMany") {
|
|
406
|
+
const indexName = `idx_${tableName}_${f.name}`;
|
|
407
|
+
if (this.dialect === "mysql") {
|
|
408
|
+
indexStatements.push(
|
|
409
|
+
`CREATE INDEX ${this.quoteIdentifier(indexName)} ON ${this.quoteIdentifier(tableName)}(${this.quoteIdentifier(f.name)});`
|
|
410
|
+
);
|
|
411
|
+
} else {
|
|
412
|
+
indexStatements.push(
|
|
413
|
+
`CREATE INDEX IF NOT EXISTS ${this.quoteIdentifier(indexName)} ON ${this.quoteIdentifier(tableName)}(${this.quoteIdentifier(f.name)});`
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
fields.forEach((f) => {
|
|
419
|
+
if (f.index && f.type !== "relationship") {
|
|
420
|
+
const indexName = `idx_${tableName}_${f.name}`;
|
|
421
|
+
if (this.dialect === "mysql") {
|
|
422
|
+
indexStatements.push(
|
|
423
|
+
`CREATE INDEX ${this.quoteIdentifier(indexName)} ON ${this.quoteIdentifier(tableName)}(${this.quoteIdentifier(f.name)});`
|
|
424
|
+
);
|
|
425
|
+
} else {
|
|
426
|
+
indexStatements.push(
|
|
427
|
+
`CREATE INDEX IF NOT EXISTS ${this.quoteIdentifier(indexName)} ON ${this.quoteIdentifier(tableName)}(${this.quoteIdentifier(f.name)});`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
let createdAtIndex = "";
|
|
433
|
+
if (this.dialect === "sqlite") {
|
|
434
|
+
createdAtIndex = `CREATE INDEX IF NOT EXISTS ${this.quoteIdentifier(`idx_${tableName}_created_at`)} ON ${this.quoteIdentifier(tableName)}(${this.quoteIdentifier("created_at")});`;
|
|
435
|
+
} else if (this.dialect === "mysql") {
|
|
436
|
+
createdAtIndex = `CREATE INDEX ${this.quoteIdentifier(`idx_${tableName}_created_at`)} ON ${this.quoteIdentifier(tableName)}(${this.quoteIdentifier("created_at")} DESC);`;
|
|
437
|
+
} else {
|
|
438
|
+
createdAtIndex = `CREATE INDEX IF NOT EXISTS ${this.quoteIdentifier(`idx_${tableName}_created_at`)} ON ${this.quoteIdentifier(tableName)}(${this.quoteIdentifier("created_at")} DESC);`;
|
|
439
|
+
}
|
|
440
|
+
indexStatements.push(createdAtIndex);
|
|
441
|
+
let slugIndex = "";
|
|
442
|
+
if (this.dialect === "sqlite") {
|
|
443
|
+
slugIndex = `CREATE UNIQUE INDEX IF NOT EXISTS ${this.quoteIdentifier(`idx_${tableName}_slug`)} ON ${this.quoteIdentifier(tableName)}(${this.quoteIdentifier("slug")});`;
|
|
444
|
+
} else if (this.dialect === "mysql") {
|
|
445
|
+
slugIndex = `CREATE UNIQUE INDEX ${this.quoteIdentifier(`idx_${tableName}_slug`)} ON ${this.quoteIdentifier(tableName)}(${this.quoteIdentifier("slug")});`;
|
|
446
|
+
} else {
|
|
447
|
+
slugIndex = `CREATE UNIQUE INDEX IF NOT EXISTS ${this.quoteIdentifier(`idx_${tableName}_slug`)} ON ${this.quoteIdentifier(tableName)}(${this.quoteIdentifier("slug")});`;
|
|
448
|
+
}
|
|
449
|
+
indexStatements.push(slugIndex);
|
|
450
|
+
if (indexStatements.length > 0) {
|
|
451
|
+
sql += "\n--> statement-breakpoint\n";
|
|
452
|
+
sql += indexStatements.join("\n--> statement-breakpoint\n");
|
|
453
|
+
}
|
|
454
|
+
if (junctionTables.length > 0) {
|
|
455
|
+
sql += "\n--> statement-breakpoint\n";
|
|
456
|
+
sql += junctionTables.join("\n--> statement-breakpoint\n");
|
|
457
|
+
}
|
|
458
|
+
return sql;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Generate ALTER TABLE migration for updating a collection
|
|
462
|
+
*
|
|
463
|
+
* Note: SQLite has very limited ALTER TABLE support:
|
|
464
|
+
* - ADD COLUMN is supported
|
|
465
|
+
* - DROP COLUMN is supported (SQLite 3.35.0+)
|
|
466
|
+
* - ALTER COLUMN (change type, nullability) is NOT supported
|
|
467
|
+
*
|
|
468
|
+
* For complex schema changes in SQLite, a table rebuild is required,
|
|
469
|
+
* but for dynamic collections we keep it simple and only support
|
|
470
|
+
* adding/removing columns.
|
|
471
|
+
*/
|
|
472
|
+
generateAlterTableMigration(tableName, oldFields, newFields, options) {
|
|
473
|
+
const statements = [`-- Update dynamic collection: ${tableName}`];
|
|
474
|
+
const wasStatus = options?.wasStatus === true;
|
|
475
|
+
const hasStatus = options?.hasStatus === true;
|
|
476
|
+
if (!wasStatus && hasStatus) {
|
|
477
|
+
const statusType = this.dialect === "sqlite" ? "text" : "varchar(20)";
|
|
478
|
+
statements.push(
|
|
479
|
+
`ALTER TABLE ${this.quoteIdentifier(tableName)} ADD COLUMN ${this.quoteIdentifier("status")} ${statusType} DEFAULT 'draft' NOT NULL;`
|
|
480
|
+
);
|
|
481
|
+
} else if (wasStatus && !hasStatus) {
|
|
482
|
+
statements.push(
|
|
483
|
+
`ALTER TABLE ${this.quoteIdentifier(tableName)} DROP COLUMN ${this.quoteIdentifier("status")};`
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
const oldFieldMap = new Map(oldFields.map((f) => [f.name, f]));
|
|
487
|
+
const newFieldMap = new Map(newFields.map((f) => [f.name, f]));
|
|
488
|
+
const rename = this.detectFieldRename(oldFields, newFields);
|
|
489
|
+
const renamedFromName = rename?.from.name ?? null;
|
|
490
|
+
const renamedToName = rename?.to.name ?? null;
|
|
491
|
+
if (rename) {
|
|
492
|
+
const fromCol = this.toSnakeCase(rename.from.name);
|
|
493
|
+
const toCol = this.toSnakeCase(rename.to.name);
|
|
494
|
+
statements.push(
|
|
495
|
+
`ALTER TABLE ${this.quoteIdentifier(tableName)} RENAME COLUMN ${this.quoteIdentifier(fromCol)} TO ${this.quoteIdentifier(toCol)};`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
for (const field of newFields) {
|
|
499
|
+
if (field.name === renamedToName) continue;
|
|
500
|
+
if (!oldFieldMap.has(field.name)) {
|
|
501
|
+
if (field.type === "relationship" && field.options?.relationType === "manyToMany") {
|
|
502
|
+
const junctionSQL = this.generateJunctionTable(tableName, field);
|
|
503
|
+
statements.push(junctionSQL);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const type = this.mapFieldTypeToSQL(field.type, field.length);
|
|
507
|
+
const nullable = field.required ? "NOT NULL" : "";
|
|
508
|
+
let defaultVal = "";
|
|
509
|
+
if (field.default !== void 0) {
|
|
510
|
+
defaultVal = `DEFAULT ${this.formatDefaultValue(field.default, field.type)}`;
|
|
511
|
+
} else if (field.required) {
|
|
512
|
+
defaultVal = `DEFAULT ${this.getDefaultValueForType(field.type)}`;
|
|
513
|
+
}
|
|
514
|
+
const addColName = this.toSnakeCase(field.name);
|
|
515
|
+
statements.push(
|
|
516
|
+
`ALTER TABLE ${this.quoteIdentifier(tableName)} ADD COLUMN ${this.quoteIdentifier(addColName)} ${type} ${nullable} ${defaultVal};`.trim()
|
|
517
|
+
);
|
|
518
|
+
if (this.dialect !== "sqlite") {
|
|
519
|
+
if (field.type === "relationship" && field.options?.target) {
|
|
520
|
+
const targetTable = `dc_${field.options.target}`;
|
|
521
|
+
const onDelete = field.options.onDelete || "set null";
|
|
522
|
+
const onUpdate = field.options.onUpdate || "no action";
|
|
523
|
+
statements.push(
|
|
524
|
+
`ALTER TABLE ${this.quoteIdentifier(tableName)} ADD CONSTRAINT ${this.quoteIdentifier(`fk_${tableName}_${addColName}`)} FOREIGN KEY (${this.quoteIdentifier(addColName)}) REFERENCES ${this.quoteIdentifier(targetTable)}(${this.quoteIdentifier("id")}) ON DELETE ${this.mapOnDeleteAction(onDelete)} ON UPDATE ${this.mapOnUpdateAction(onUpdate)};`
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
if (field.unique) {
|
|
528
|
+
statements.push(
|
|
529
|
+
`ALTER TABLE ${this.quoteIdentifier(tableName)} ADD CONSTRAINT ${this.quoteIdentifier(`uq_${tableName}_${addColName}`)} UNIQUE (${this.quoteIdentifier(addColName)});`
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
} else {
|
|
533
|
+
if (field.unique) {
|
|
534
|
+
statements.push(
|
|
535
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS ${this.quoteIdentifier(`uq_${tableName}_${addColName}`)} ON ${this.quoteIdentifier(tableName)}(${this.quoteIdentifier(addColName)});`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
for (const field of newFields) {
|
|
542
|
+
const oldField = oldFieldMap.get(field.name);
|
|
543
|
+
if (oldField && oldField.index !== field.index) {
|
|
544
|
+
const idxCol = this.toSnakeCase(field.name);
|
|
545
|
+
const indexName = `idx_${tableName}_${idxCol}`;
|
|
546
|
+
if (field.index) {
|
|
547
|
+
if (this.dialect === "mysql") {
|
|
548
|
+
statements.push(
|
|
549
|
+
`CREATE INDEX ${this.quoteIdentifier(indexName)} ON ${this.quoteIdentifier(tableName)}(${this.quoteIdentifier(idxCol)});`
|
|
550
|
+
);
|
|
551
|
+
} else {
|
|
552
|
+
statements.push(
|
|
553
|
+
`CREATE INDEX IF NOT EXISTS ${this.quoteIdentifier(indexName)} ON ${this.quoteIdentifier(tableName)}(${this.quoteIdentifier(idxCol)});`
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
} else {
|
|
557
|
+
if (this.dialect === "mysql") {
|
|
558
|
+
statements.push(
|
|
559
|
+
`DROP INDEX ${this.quoteIdentifier(indexName)} ON ${this.quoteIdentifier(tableName)};`
|
|
560
|
+
);
|
|
561
|
+
} else {
|
|
562
|
+
statements.push(
|
|
563
|
+
`DROP INDEX IF EXISTS ${this.quoteIdentifier(indexName)} ON ${this.quoteIdentifier(tableName)};`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
for (const field of oldFields) {
|
|
570
|
+
if (field.name === renamedFromName) continue;
|
|
571
|
+
if (!newFieldMap.has(field.name)) {
|
|
572
|
+
const dropCol = this.toSnakeCase(field.name);
|
|
573
|
+
if (this.dialect === "sqlite") {
|
|
574
|
+
statements.push(
|
|
575
|
+
`ALTER TABLE ${this.quoteIdentifier(tableName)} DROP COLUMN ${this.quoteIdentifier(dropCol)};`
|
|
576
|
+
);
|
|
577
|
+
} else {
|
|
578
|
+
statements.push(
|
|
579
|
+
`ALTER TABLE ${this.quoteIdentifier(tableName)} DROP COLUMN IF EXISTS ${this.quoteIdentifier(dropCol)};`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (this.dialect !== "sqlite") {
|
|
585
|
+
for (const field of newFields) {
|
|
586
|
+
const oldField = oldFieldMap.get(field.name);
|
|
587
|
+
if (oldField && this.isFieldModified(oldField, field)) {
|
|
588
|
+
const alterCol = this.toSnakeCase(field.name);
|
|
589
|
+
const type = this.mapFieldTypeToSQL(field.type, field.length);
|
|
590
|
+
statements.push(
|
|
591
|
+
`ALTER TABLE ${this.quoteIdentifier(tableName)} ALTER COLUMN ${this.quoteIdentifier(alterCol)} TYPE ${type};`
|
|
592
|
+
);
|
|
593
|
+
if (field.required !== oldField.required) {
|
|
594
|
+
if (field.required) {
|
|
595
|
+
statements.push(
|
|
596
|
+
`ALTER TABLE ${this.quoteIdentifier(tableName)} ALTER COLUMN ${this.quoteIdentifier(alterCol)} SET NOT NULL;`
|
|
597
|
+
);
|
|
598
|
+
} else {
|
|
599
|
+
statements.push(
|
|
600
|
+
`ALTER TABLE ${this.quoteIdentifier(tableName)} ALTER COLUMN ${this.quoteIdentifier(alterCol)} DROP NOT NULL;`
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return statements.join("\n--> statement-breakpoint\n");
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Check if a field definition has been modified
|
|
611
|
+
*/
|
|
612
|
+
isFieldModified(oldField, newField) {
|
|
613
|
+
return oldField.type !== newField.type || oldField.length !== newField.length || oldField.required !== newField.required || oldField.unique !== newField.unique || oldField.index !== newField.index;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Phase D (Option 2) — structural rename detection.
|
|
617
|
+
*
|
|
618
|
+
* Pairs a removed field with an added field if and only if:
|
|
619
|
+
* 1. There is exactly ONE removed field (in oldFields, not in newFields)
|
|
620
|
+
* 2. AND exactly ONE added field (in newFields, not in oldFields)
|
|
621
|
+
* 3. AND their types are compatible (same `type`, and for relations
|
|
622
|
+
* same target + relationType)
|
|
623
|
+
*
|
|
624
|
+
* This is the SAFE heuristic: zero ambiguity. If the user renames
|
|
625
|
+
* multiple fields in a single save, the heuristic bails out and the
|
|
626
|
+
* caller falls back to ADD+DROP. A console.warn surfaces the data-
|
|
627
|
+
* loss risk so the user knows to rename one field at a time, OR an
|
|
628
|
+
* admin-UI confirmation prompt can be added later (tracked as a
|
|
629
|
+
* Phase D follow-up).
|
|
630
|
+
*
|
|
631
|
+
* Why not the more aggressive multi-pair scoring described in the
|
|
632
|
+
* design doc: ambiguous pairings can silently rename to the wrong
|
|
633
|
+
* column. The cost of that bug exceeds the cost of asking the user
|
|
634
|
+
* to make smaller saves. We can soften this with an admin-UI
|
|
635
|
+
* confirmation later if friction is real.
|
|
636
|
+
*/
|
|
637
|
+
detectFieldRename(oldFields, newFields) {
|
|
638
|
+
const oldNames = new Set(oldFields.map((f) => f.name));
|
|
639
|
+
const newNames = new Set(newFields.map((f) => f.name));
|
|
640
|
+
const oldOnly = oldFields.filter((f) => !newNames.has(f.name));
|
|
641
|
+
const newOnly = newFields.filter((f) => !oldNames.has(f.name));
|
|
642
|
+
if (oldOnly.length === 0 || newOnly.length === 0) {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
if (oldOnly.length > 1 || newOnly.length > 1) {
|
|
646
|
+
console.warn(
|
|
647
|
+
`[Nextly schema] Detected ${oldOnly.length} removed and ${newOnly.length} added field(s) in the same save on this collection. Skipping rename detection (ambiguous) \u2014 emitting DROP/ADD which loses any data in the removed columns. To rename safely, edit and save one field at a time. Removed: [` + oldOnly.map((f) => f.name).join(", ") + `]. Added: [` + newOnly.map((f) => f.name).join(", ") + `].`
|
|
648
|
+
);
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
const from = oldOnly[0];
|
|
652
|
+
const to = newOnly[0];
|
|
653
|
+
if (!this.areFieldTypesCompatible(from, to)) {
|
|
654
|
+
console.warn(
|
|
655
|
+
`[Nextly schema] Field "${from.name}" was removed and "${to.name}" was added in the same save, but their types (${from.type} vs ${to.type}) are not compatible. Treating as DROP "${from.name}" + ADD "${to.name}" \u2014 existing data in "${from.name}" will be lost. If this was intended as a type-changing rename, do it in two steps: first rename without changing type, then change the type.`
|
|
656
|
+
);
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
return { from, to };
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Are two field definitions compatible enough that renaming one to
|
|
663
|
+
* the other preserves data semantics?
|
|
664
|
+
*
|
|
665
|
+
* Strict by design: same type, and for relations same target +
|
|
666
|
+
* relationType. Length differences are allowed for text/varchar
|
|
667
|
+
* since a column rename doesn't touch the size constraint. Required/
|
|
668
|
+
* unique/index differences are allowed (those are independent
|
|
669
|
+
* attribute changes the user can adjust on either side of a rename).
|
|
670
|
+
*/
|
|
671
|
+
areFieldTypesCompatible(a, b) {
|
|
672
|
+
if (a.type !== b.type) return false;
|
|
673
|
+
if (a.type === "relationship" && a.options?.relationType === "manyToMany") {
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
if (a.type === "relationship") {
|
|
677
|
+
return a.options?.target === b.options?.target && a.options?.relationType === b.options?.relationType;
|
|
678
|
+
}
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Generate TypeScript/Drizzle schema code for a collection
|
|
683
|
+
*/
|
|
684
|
+
generateSchemaCode(tableName, collectionName, fields) {
|
|
685
|
+
const dialectConfig = this.getDialectConfig();
|
|
686
|
+
const jsonbFieldTypes = [
|
|
687
|
+
"json",
|
|
688
|
+
"repeater",
|
|
689
|
+
"group",
|
|
690
|
+
"blocks",
|
|
691
|
+
"point",
|
|
692
|
+
"group",
|
|
693
|
+
"blocks",
|
|
694
|
+
"point",
|
|
695
|
+
"chips"
|
|
696
|
+
];
|
|
697
|
+
const hasJsonField = fields.some((f) => jsonbFieldTypes.includes(f.type));
|
|
698
|
+
const hasRelation = fields.some((f) => f.type === "relationship");
|
|
699
|
+
const baseImports = ["text", "index", "uniqueIndex"];
|
|
700
|
+
if (this.dialect !== "sqlite") {
|
|
701
|
+
baseImports.push("varchar", "decimal", "boolean", "timestamp", "integer");
|
|
702
|
+
} else {
|
|
703
|
+
baseImports.push("integer", "real");
|
|
704
|
+
}
|
|
705
|
+
if (hasJsonField) {
|
|
706
|
+
if (this.dialect === "postgresql") {
|
|
707
|
+
baseImports.push("jsonb");
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
let imports = `import { ${dialectConfig.tableFunction}, ${baseImports.join(", ")} } from '${dialectConfig.importPath}';`;
|
|
711
|
+
if (hasRelation) {
|
|
712
|
+
imports += `
|
|
713
|
+
import { relations } from 'drizzle-orm';`;
|
|
714
|
+
}
|
|
715
|
+
const columns = fields.filter(
|
|
716
|
+
(f) => !(f.type === "relationship" && f.options?.relationType === "manyToMany")
|
|
717
|
+
).map((f) => {
|
|
718
|
+
const drizzleType = this.mapFieldTypeToDrizzleDialectAware(f);
|
|
719
|
+
const modifiers = [];
|
|
720
|
+
if (f.required) modifiers.push(".notNull()");
|
|
721
|
+
if (f.unique || f.type === "relationship" && f.options?.relationType === "oneToOne") {
|
|
722
|
+
modifiers.push(".unique()");
|
|
723
|
+
}
|
|
724
|
+
const defaultValue = f.default;
|
|
725
|
+
if (defaultValue !== void 0 && defaultValue !== null) {
|
|
726
|
+
if (f.type === "json") {
|
|
727
|
+
modifiers.push(`.default(${JSON.stringify(defaultValue)})`);
|
|
728
|
+
} else if (typeof defaultValue === "string") {
|
|
729
|
+
modifiers.push(`.default('${defaultValue}')`);
|
|
730
|
+
} else {
|
|
731
|
+
modifiers.push(`.default(${String(defaultValue)})`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (f.type === "relationship" && f.options?.target && f.options?.relationType !== "manyToMany") {
|
|
735
|
+
const targetTable = `dc_${f.options.target}`;
|
|
736
|
+
const onDelete = f.options.onDelete || "set null";
|
|
737
|
+
const onUpdate = f.options.onUpdate || "no action";
|
|
738
|
+
modifiers.push(
|
|
739
|
+
`.references(() => ${targetTable}.id, { onDelete: "${onDelete}", onUpdate: "${onUpdate}" })`
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
return ` ${f.name}: ${drizzleType}${modifiers.join("")},`;
|
|
743
|
+
}).join("\n");
|
|
744
|
+
const relationDefs = this.generateRelationDefinitions(tableName, fields);
|
|
745
|
+
const fieldIndexes = fields.filter(
|
|
746
|
+
(f) => f.index || f.type === "relationship" && f.options?.relationType !== "manyToMany"
|
|
747
|
+
).map(
|
|
748
|
+
(f) => ` ${f.name}Idx: index('idx_${tableName}_${f.name}').on(table.${f.name}),`
|
|
749
|
+
).join("\n");
|
|
750
|
+
const allIndexes = fieldIndexes ? ` createdAtIdx: index('idx_${tableName}_created_at').on(table.createdAt),
|
|
751
|
+
${fieldIndexes}` : ` createdAtIdx: index('idx_${tableName}_created_at').on(table.createdAt),`;
|
|
752
|
+
const timestampColumns = this.generateTimestampColumnsForDialect();
|
|
753
|
+
return `${imports}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Dynamic collection: ${collectionName}
|
|
757
|
+
* Generated by nextly
|
|
758
|
+
*/
|
|
759
|
+
export const ${tableName} = ${dialectConfig.tableFunction}('${tableName}', {
|
|
760
|
+
id: text('id').primaryKey().notNull(),
|
|
761
|
+
title: text('title').notNull(),
|
|
762
|
+
slug: text('slug').notNull(),
|
|
763
|
+
${columns}
|
|
764
|
+
${timestampColumns}
|
|
765
|
+
}, (table) => ({
|
|
766
|
+
slugIdx: uniqueIndex('idx_${tableName}_slug').on(table.slug),
|
|
767
|
+
${allIndexes}
|
|
768
|
+
}));
|
|
769
|
+
${relationDefs}
|
|
770
|
+
export type ${this.toPascalCase(collectionName)} = typeof ${tableName}.$inferSelect;
|
|
771
|
+
export type New${this.toPascalCase(collectionName)} = typeof ${tableName}.$inferInsert;
|
|
772
|
+
`;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Get dialect-specific configuration for schema generation
|
|
776
|
+
*/
|
|
777
|
+
getDialectConfig() {
|
|
778
|
+
switch (this.dialect) {
|
|
779
|
+
case "mysql":
|
|
780
|
+
return {
|
|
781
|
+
tableFunction: "mysqlTable",
|
|
782
|
+
importPath: "drizzle-orm/mysql-core"
|
|
783
|
+
};
|
|
784
|
+
case "sqlite":
|
|
785
|
+
return {
|
|
786
|
+
tableFunction: "sqliteTable",
|
|
787
|
+
importPath: "drizzle-orm/sqlite-core"
|
|
788
|
+
};
|
|
789
|
+
case "postgresql":
|
|
790
|
+
default:
|
|
791
|
+
return {
|
|
792
|
+
tableFunction: "pgTable",
|
|
793
|
+
importPath: "drizzle-orm/pg-core"
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Generate dialect-specific timestamp column definitions
|
|
799
|
+
*/
|
|
800
|
+
generateTimestampColumnsForDialect() {
|
|
801
|
+
if (this.dialect === "sqlite") {
|
|
802
|
+
return ` createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
803
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()).$onUpdate(() => new Date()),`;
|
|
804
|
+
}
|
|
805
|
+
return ` createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
806
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),`;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Map field type to Drizzle ORM column definition (dialect-aware)
|
|
810
|
+
*/
|
|
811
|
+
mapFieldTypeToDrizzleDialectAware(field) {
|
|
812
|
+
if (this.dialect === "sqlite") {
|
|
813
|
+
const sqliteTypeMap = {
|
|
814
|
+
string: (f) => `text('${f.name}')`,
|
|
815
|
+
text: (f) => `text('${f.name}')`,
|
|
816
|
+
number: (f) => f.options?.format === "float" ? `real('${f.name}')` : `integer('${f.name}')`,
|
|
817
|
+
decimal: (f) => `real('${f.name}')`,
|
|
818
|
+
boolean: (f) => `integer('${f.name}', { mode: 'boolean' })`,
|
|
819
|
+
date: (f) => `integer('${f.name}', { mode: 'timestamp' })`,
|
|
820
|
+
email: (f) => `text('${f.name}')`,
|
|
821
|
+
password: (f) => `text('${f.name}')`,
|
|
822
|
+
richtext: (f) => `text('${f.name}')`,
|
|
823
|
+
json: (f) => `text('${f.name}', { mode: 'json' })`,
|
|
824
|
+
chips: (f) => `text('${f.name}', { mode: 'json' })`,
|
|
825
|
+
relation: (f) => `text('${f.name}')`
|
|
826
|
+
};
|
|
827
|
+
const mapper = sqliteTypeMap[field.type];
|
|
828
|
+
return mapper ? mapper(field) : `text('${field.name}')`;
|
|
829
|
+
}
|
|
830
|
+
if (this.dialect === "mysql") {
|
|
831
|
+
const mysqlTypeMap = {
|
|
832
|
+
string: (f) => `varchar('${f.name}', { length: ${f.length || 255} })`,
|
|
833
|
+
text: (f) => f.options?.variant === "short" ? `varchar('${f.name}', { length: ${f.validation?.maxLength || 255} })` : `text('${f.name}')`,
|
|
834
|
+
number: (f) => f.options?.format === "float" ? `decimal('${f.name}', { precision: 10, scale: 2 })` : `int('${f.name}')`,
|
|
835
|
+
decimal: (f) => `decimal('${f.name}', { precision: 10, scale: 2 })`,
|
|
836
|
+
boolean: (f) => `boolean('${f.name}')`,
|
|
837
|
+
date: (f) => `timestamp('${f.name}')`,
|
|
838
|
+
email: (f) => `varchar('${f.name}', { length: ${f.validation?.maxLength || 255} })`,
|
|
839
|
+
password: (f) => `varchar('${f.name}', { length: ${f.validation?.maxLength || 255} })`,
|
|
840
|
+
richtext: (f) => `text('${f.name}')`,
|
|
841
|
+
json: (f) => `json('${f.name}')`,
|
|
842
|
+
chips: (f) => `json('${f.name}')`,
|
|
843
|
+
relation: (f) => `varchar('${f.name}', { length: 36 })`
|
|
844
|
+
};
|
|
845
|
+
const mapper = mysqlTypeMap[field.type];
|
|
846
|
+
return mapper ? mapper(field) : `text('${field.name}')`;
|
|
847
|
+
}
|
|
848
|
+
return this.mapFieldTypeToDrizzle(field);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Generate DROP TABLE migration SQL
|
|
852
|
+
*/
|
|
853
|
+
generateDropTableMigration(collectionName, tableName) {
|
|
854
|
+
const dropStatement = this.dialect === "sqlite" ? `DROP TABLE IF EXISTS ${this.quoteIdentifier(tableName)};` : `DROP TABLE IF EXISTS ${this.quoteIdentifier(tableName)} CASCADE;`;
|
|
855
|
+
const migrationSQL = `-- Drop dynamic collection: ${collectionName}
|
|
856
|
+
${dropStatement}`;
|
|
857
|
+
return {
|
|
858
|
+
migrationSQL,
|
|
859
|
+
migrationFileName: `${Date.now()}_drop_${collectionName}.sql`
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Generate junction table SQL for many-to-many relationships
|
|
864
|
+
*/
|
|
865
|
+
generateJunctionTable(sourceTableName, field) {
|
|
866
|
+
const targetCollectionName = field.options.target;
|
|
867
|
+
const targetTableName = `dc_${targetCollectionName}`;
|
|
868
|
+
const junctionTableName = field.options?.junctionTable || this.generateJunctionTableName(
|
|
869
|
+
sourceTableName,
|
|
870
|
+
targetTableName,
|
|
871
|
+
field.name
|
|
872
|
+
);
|
|
873
|
+
const onDelete = this.mapOnDeleteAction(
|
|
874
|
+
field.options?.onDelete || "cascade"
|
|
875
|
+
);
|
|
876
|
+
const onUpdate = this.mapOnUpdateAction(
|
|
877
|
+
field.options?.onUpdate || "no action"
|
|
878
|
+
);
|
|
879
|
+
const sourceCollectionName = sourceTableName.replace("dc_", "");
|
|
880
|
+
let timestampDefault = "";
|
|
881
|
+
if (this.dialect === "sqlite") {
|
|
882
|
+
timestampDefault = "integer DEFAULT (strftime('%s', 'now')) NOT NULL";
|
|
883
|
+
} else if (this.dialect === "mysql") {
|
|
884
|
+
timestampDefault = "timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL";
|
|
885
|
+
} else {
|
|
886
|
+
timestampDefault = "timestamp DEFAULT now() NOT NULL";
|
|
887
|
+
}
|
|
888
|
+
return `-- Junction table for many-to-many: ${sourceCollectionName}.${field.name} -> ${targetCollectionName}
|
|
889
|
+
CREATE TABLE IF NOT EXISTS ${this.quoteIdentifier(junctionTableName)} (
|
|
890
|
+
${this.quoteIdentifier("id")} ${this.dialect === "mysql" ? "varchar(36)" : "text"} PRIMARY KEY NOT NULL,
|
|
891
|
+
${this.quoteIdentifier(`${sourceCollectionName}_id`)} ${this.dialect === "mysql" ? "varchar(36)" : "text"} NOT NULL,
|
|
892
|
+
${this.quoteIdentifier(`${targetCollectionName}_id`)} ${this.dialect === "mysql" ? "varchar(36)" : "text"} NOT NULL,
|
|
893
|
+
${this.quoteIdentifier("created_at")} ${timestampDefault},
|
|
894
|
+
CONSTRAINT ${this.quoteIdentifier(`fk_${junctionTableName}_${sourceCollectionName}`)} FOREIGN KEY (${this.quoteIdentifier(`${sourceCollectionName}_id`)}) REFERENCES ${this.quoteIdentifier(sourceTableName)}(${this.quoteIdentifier("id")}) ON DELETE ${onDelete} ON UPDATE ${onUpdate},
|
|
895
|
+
CONSTRAINT ${this.quoteIdentifier(`fk_${junctionTableName}_${targetCollectionName}`)} FOREIGN KEY (${this.quoteIdentifier(`${targetCollectionName}_id`)}) REFERENCES ${this.quoteIdentifier(targetTableName)}(${this.quoteIdentifier("id")}) ON DELETE ${onDelete} ON UPDATE ${onUpdate},
|
|
896
|
+
CONSTRAINT ${this.quoteIdentifier(`uq_${junctionTableName}_pair`)} UNIQUE (${this.quoteIdentifier(`${sourceCollectionName}_id`)}, ${this.quoteIdentifier(`${targetCollectionName}_id`)})
|
|
897
|
+
);
|
|
898
|
+
--> statement-breakpoint
|
|
899
|
+
CONSTRAINT ${this.quoteIdentifier(`uq_${junctionTableName}_pair`)} UNIQUE (${this.quoteIdentifier(`${sourceCollectionName}_id`)}, ${this.quoteIdentifier(`${targetCollectionName}_id`)})
|
|
900
|
+
);
|
|
901
|
+
--> statement-breakpoint
|
|
902
|
+
${this.dialect === "mysql" ? "CREATE INDEX" : "CREATE INDEX IF NOT EXISTS"} ${this.quoteIdentifier(`idx_${junctionTableName}_${sourceCollectionName}`)} ON ${this.quoteIdentifier(junctionTableName)}(${this.quoteIdentifier(`${sourceCollectionName}_id`)});
|
|
903
|
+
--> statement-breakpoint
|
|
904
|
+
${this.dialect === "mysql" ? "CREATE INDEX" : "CREATE INDEX IF NOT EXISTS"} ${this.quoteIdentifier(`idx_${junctionTableName}_${targetCollectionName}`)} ON ${this.quoteIdentifier(junctionTableName)}(${this.quoteIdentifier(`${targetCollectionName}_id`)});`;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Generate junction table name following naming convention
|
|
908
|
+
*/
|
|
909
|
+
generateJunctionTableName(sourceTable, targetTable, fieldName) {
|
|
910
|
+
const tables = [sourceTable, targetTable].sort();
|
|
911
|
+
return `${tables[0]}_${tables[1]}_${fieldName}`;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Generate Drizzle ORM relation definitions
|
|
915
|
+
*/
|
|
916
|
+
generateRelationDefinitions(tableName, fields) {
|
|
917
|
+
const relationFields = fields.filter((f) => f.type === "relationship");
|
|
918
|
+
if (relationFields.length === 0) {
|
|
919
|
+
return "";
|
|
920
|
+
}
|
|
921
|
+
const relationDefs = relationFields.map((f) => {
|
|
922
|
+
const targetTable = `dc_${f.options.target}`;
|
|
923
|
+
const relationType = f.options.relationType;
|
|
924
|
+
switch (relationType) {
|
|
925
|
+
case "oneToOne":
|
|
926
|
+
return ` ${f.name}: one(${targetTable}, {
|
|
927
|
+
fields: [${tableName}.${f.name}],
|
|
928
|
+
references: [${targetTable}.id],
|
|
929
|
+
}),`;
|
|
930
|
+
case "manyToOne":
|
|
931
|
+
return ` ${f.name}: one(${targetTable}, {
|
|
932
|
+
fields: [${tableName}.${f.name}],
|
|
933
|
+
references: [${targetTable}.id],
|
|
934
|
+
}),`;
|
|
935
|
+
case "oneToMany":
|
|
936
|
+
return ` ${f.name}: many(${targetTable}),`;
|
|
937
|
+
case "manyToMany": {
|
|
938
|
+
const junctionTableName = f.options?.junctionTable || this.generateJunctionTableName(tableName, targetTable, f.name);
|
|
939
|
+
return ` ${f.name}: many(${targetTable}), // Through ${junctionTableName}`;
|
|
940
|
+
}
|
|
941
|
+
default:
|
|
942
|
+
return "";
|
|
943
|
+
}
|
|
944
|
+
}).filter(Boolean).join("\n");
|
|
945
|
+
return `
|
|
946
|
+
// Drizzle ORM Relations
|
|
947
|
+
export const ${tableName}Relations = relations(${tableName}, ({ one, many }) => ({
|
|
948
|
+
${relationDefs}
|
|
949
|
+
}));
|
|
950
|
+
`;
|
|
951
|
+
}
|
|
952
|
+
// ==================== TYPE MAPPING METHODS ====================
|
|
953
|
+
/**
|
|
954
|
+
* Map field type to SQL column type (dialect-aware)
|
|
955
|
+
*/
|
|
956
|
+
mapFieldTypeToSQL(type, length, options, validation) {
|
|
957
|
+
if (this.dialect === "sqlite") {
|
|
958
|
+
const sqliteTypeMap = {
|
|
959
|
+
text: "text",
|
|
960
|
+
textarea: "text",
|
|
961
|
+
number: options?.format === "float" ? "real" : "integer",
|
|
962
|
+
checkbox: "integer",
|
|
963
|
+
// SQLite uses 0/1 for boolean
|
|
964
|
+
date: "integer",
|
|
965
|
+
// Store as Unix timestamp
|
|
966
|
+
email: "text",
|
|
967
|
+
password: "text",
|
|
968
|
+
code: "text",
|
|
969
|
+
richText: "text",
|
|
970
|
+
json: "text",
|
|
971
|
+
// JSON stored as text in SQLite
|
|
972
|
+
chips: "text",
|
|
973
|
+
// Chips stored as JSON text in SQLite
|
|
974
|
+
relationship: "text"
|
|
975
|
+
// Store foreign key as text (UUID or ID)
|
|
976
|
+
};
|
|
977
|
+
return sqliteTypeMap[type] || "text";
|
|
978
|
+
}
|
|
979
|
+
if (this.dialect === "mysql") {
|
|
980
|
+
const mysqlTypeMap = {
|
|
981
|
+
text: options?.variant === "short" ? `varchar(${validation?.maxLength || 255})` : "text",
|
|
982
|
+
textarea: "text",
|
|
983
|
+
number: options?.format === "float" ? "decimal(10,2)" : "integer",
|
|
984
|
+
checkbox: "boolean",
|
|
985
|
+
date: "timestamp",
|
|
986
|
+
email: `varchar(${validation?.maxLength || 255})`,
|
|
987
|
+
password: `varchar(${validation?.maxLength || 255})`,
|
|
988
|
+
code: "text",
|
|
989
|
+
richText: "text",
|
|
990
|
+
json: "json",
|
|
991
|
+
// MySQL uses 'json' type, not 'jsonb'
|
|
992
|
+
chips: "json",
|
|
993
|
+
// Chips stored as JSON array
|
|
994
|
+
relationship: "varchar(36)"
|
|
995
|
+
// Store foreign key as varchar(36) for UUIDs
|
|
996
|
+
};
|
|
997
|
+
return mysqlTypeMap[type] || "text";
|
|
998
|
+
}
|
|
999
|
+
const typeMap = {
|
|
1000
|
+
text: options?.variant === "short" ? `varchar(${validation?.maxLength || 255})` : "text",
|
|
1001
|
+
textarea: "text",
|
|
1002
|
+
number: options?.format === "float" ? "decimal(10,2)" : "integer",
|
|
1003
|
+
checkbox: "boolean",
|
|
1004
|
+
date: "timestamp",
|
|
1005
|
+
email: `varchar(${validation?.maxLength || 255})`,
|
|
1006
|
+
password: `varchar(${validation?.maxLength || 255})`,
|
|
1007
|
+
code: "text",
|
|
1008
|
+
richText: "text",
|
|
1009
|
+
json: "jsonb",
|
|
1010
|
+
chips: "jsonb",
|
|
1011
|
+
// Chips stored as JSON array
|
|
1012
|
+
relationship: "text"
|
|
1013
|
+
// Store foreign key as text (UUID or ID)
|
|
1014
|
+
};
|
|
1015
|
+
return typeMap[type] || "text";
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Map field type to Drizzle ORM column definition
|
|
1019
|
+
*/
|
|
1020
|
+
mapFieldTypeToDrizzle(field) {
|
|
1021
|
+
const typeMap = {
|
|
1022
|
+
string: (f) => `varchar('${f.name}', { length: ${f.length || 255} })`,
|
|
1023
|
+
text: (f) => f.options?.variant === "short" ? `varchar('${f.name}', { length: ${f.validation?.maxLength || 255} })` : `text('${f.name}')`,
|
|
1024
|
+
number: (f) => f.options?.format === "float" ? `decimal('${f.name}', { precision: 10, scale: 2 })` : `integer('${f.name}')`,
|
|
1025
|
+
decimal: (f) => `decimal('${f.name}', { precision: 10, scale: 2 })`,
|
|
1026
|
+
boolean: (f) => `boolean('${f.name}')`,
|
|
1027
|
+
date: (f) => `timestamp('${f.name}')`,
|
|
1028
|
+
email: (f) => `varchar('${f.name}', { length: ${f.validation?.maxLength || 255} })`,
|
|
1029
|
+
password: (f) => `varchar('${f.name}', { length: ${f.validation?.maxLength || 255} })`,
|
|
1030
|
+
richtext: (f) => `text('${f.name}')`,
|
|
1031
|
+
json: (f) => `jsonb('${f.name}')`,
|
|
1032
|
+
chips: (f) => `jsonb('${f.name}')`,
|
|
1033
|
+
relation: (f) => `text('${f.name}')`
|
|
1034
|
+
// Foreign key
|
|
1035
|
+
};
|
|
1036
|
+
const mapper = typeMap[field.type];
|
|
1037
|
+
return mapper ? mapper(field) : `text('${field.name}')`;
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Get a sensible default value for a field type.
|
|
1041
|
+
* Used when adding NOT NULL columns to existing tables.
|
|
1042
|
+
*/
|
|
1043
|
+
getDefaultValueForType(type) {
|
|
1044
|
+
switch (type) {
|
|
1045
|
+
case "text":
|
|
1046
|
+
case "textarea":
|
|
1047
|
+
case "email":
|
|
1048
|
+
case "password":
|
|
1049
|
+
case "richText":
|
|
1050
|
+
case "code":
|
|
1051
|
+
return "''";
|
|
1052
|
+
case "number":
|
|
1053
|
+
return "0";
|
|
1054
|
+
case "checkbox":
|
|
1055
|
+
return this.dialect === "sqlite" ? "0" : "FALSE";
|
|
1056
|
+
case "date":
|
|
1057
|
+
if (this.dialect === "sqlite") {
|
|
1058
|
+
return String(Math.floor(Date.now() / 1e3));
|
|
1059
|
+
}
|
|
1060
|
+
return "NOW()";
|
|
1061
|
+
case "json":
|
|
1062
|
+
case "repeater":
|
|
1063
|
+
case "group":
|
|
1064
|
+
case "blocks":
|
|
1065
|
+
return "'{}'";
|
|
1066
|
+
case "chips":
|
|
1067
|
+
return "'[]'";
|
|
1068
|
+
case "relationship":
|
|
1069
|
+
case "upload":
|
|
1070
|
+
return "NULL";
|
|
1071
|
+
case "select":
|
|
1072
|
+
case "radio":
|
|
1073
|
+
return "''";
|
|
1074
|
+
case "point":
|
|
1075
|
+
return this.dialect === "postgresql" ? "'(0,0)'" : `'{"x":0,"y":0}'`;
|
|
1076
|
+
default:
|
|
1077
|
+
return "''";
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Format a default value for SQL (dialect-aware)
|
|
1082
|
+
*/
|
|
1083
|
+
formatDefaultValue(value, type) {
|
|
1084
|
+
if (type === "text" || type === "textarea" || type === "email" || type === "password" || type === "richText" || type === "code" || type === "select" || type === "radio") {
|
|
1085
|
+
return `'${String(value)}'`;
|
|
1086
|
+
}
|
|
1087
|
+
if (type === "checkbox") {
|
|
1088
|
+
if (this.dialect === "sqlite") {
|
|
1089
|
+
return value ? "1" : "0";
|
|
1090
|
+
}
|
|
1091
|
+
return value ? "TRUE" : "FALSE";
|
|
1092
|
+
}
|
|
1093
|
+
if (type === "json") {
|
|
1094
|
+
return `'${typeof value === "string" ? value : JSON.stringify(value)}'`;
|
|
1095
|
+
}
|
|
1096
|
+
if (type === "date") {
|
|
1097
|
+
if (this.dialect === "sqlite" && typeof value === "string") {
|
|
1098
|
+
const timestamp = new Date(value).getTime() / 1e3;
|
|
1099
|
+
return String(Math.floor(timestamp));
|
|
1100
|
+
}
|
|
1101
|
+
return `'${String(value)}'`;
|
|
1102
|
+
}
|
|
1103
|
+
if (type === "number") {
|
|
1104
|
+
return String(value);
|
|
1105
|
+
}
|
|
1106
|
+
if (type === "relationship") {
|
|
1107
|
+
return `'${String(value)}'`;
|
|
1108
|
+
}
|
|
1109
|
+
return String(value);
|
|
1110
|
+
}
|
|
1111
|
+
// ==================== UTILITY METHODS ====================
|
|
1112
|
+
/**
|
|
1113
|
+
* Convert snake_case to PascalCase
|
|
1114
|
+
*/
|
|
1115
|
+
toPascalCase(str) {
|
|
1116
|
+
return str.charAt(0).toUpperCase().concat(str.slice(1).replace(/_([a-z])/g, (_, c) => c.toUpperCase()));
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Convert snake_case to camelCase
|
|
1120
|
+
*/
|
|
1121
|
+
toCamelCase(str) {
|
|
1122
|
+
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Map onDelete action to SQL syntax
|
|
1126
|
+
*/
|
|
1127
|
+
mapOnDeleteAction(action) {
|
|
1128
|
+
const actionMap = {
|
|
1129
|
+
cascade: "CASCADE",
|
|
1130
|
+
"set null": "SET NULL",
|
|
1131
|
+
restrict: "RESTRICT",
|
|
1132
|
+
"no action": "NO ACTION"
|
|
1133
|
+
};
|
|
1134
|
+
return actionMap[action.toLowerCase()] || "SET NULL";
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Map onUpdate action to SQL syntax
|
|
1138
|
+
*/
|
|
1139
|
+
mapOnUpdateAction(action) {
|
|
1140
|
+
const actionMap = {
|
|
1141
|
+
cascade: "CASCADE",
|
|
1142
|
+
"set null": "SET NULL",
|
|
1143
|
+
restrict: "RESTRICT",
|
|
1144
|
+
"no action": "NO ACTION"
|
|
1145
|
+
};
|
|
1146
|
+
return actionMap[action.toLowerCase()] || "NO ACTION";
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
export {
|
|
1151
|
+
DynamicCollectionValidationService,
|
|
1152
|
+
DynamicCollectionSchemaService
|
|
1153
|
+
};
|