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,1688 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_COMPONENT_NESTING_DEPTH
|
|
3
|
+
} from "./chunk-FQULBZ53.mjs";
|
|
4
|
+
import {
|
|
5
|
+
PermissionService,
|
|
6
|
+
RolePermissionService,
|
|
7
|
+
SYSTEM_RESOURCES
|
|
8
|
+
} from "./chunk-NSEFNNU4.mjs";
|
|
9
|
+
import {
|
|
10
|
+
BaseRegistryService,
|
|
11
|
+
assertGlobalResourceSlugAvailable
|
|
12
|
+
} from "./chunk-3FA7FKAV.mjs";
|
|
13
|
+
import {
|
|
14
|
+
resolveSingleTableName
|
|
15
|
+
} from "./chunk-I4JMR3UR.mjs";
|
|
16
|
+
import {
|
|
17
|
+
calculateSchemaHash,
|
|
18
|
+
schemaHashesMatch
|
|
19
|
+
} from "./chunk-5HMZ644B.mjs";
|
|
20
|
+
import {
|
|
21
|
+
BaseService
|
|
22
|
+
} from "./chunk-2W3DVD7S.mjs";
|
|
23
|
+
import {
|
|
24
|
+
NextlyError,
|
|
25
|
+
toDbError
|
|
26
|
+
} from "./chunk-NRUWQ5Z7.mjs";
|
|
27
|
+
|
|
28
|
+
// src/domains/auth/services/permission-seed-service.ts
|
|
29
|
+
import { eq } from "drizzle-orm";
|
|
30
|
+
var SYSTEM_PERMISSIONS = [
|
|
31
|
+
{
|
|
32
|
+
name: "Create Users",
|
|
33
|
+
slug: "create-users",
|
|
34
|
+
action: "create",
|
|
35
|
+
resource: "users",
|
|
36
|
+
description: "Permission to create users"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "Read Users",
|
|
40
|
+
slug: "read-users",
|
|
41
|
+
action: "read",
|
|
42
|
+
resource: "users",
|
|
43
|
+
description: "Permission to read users"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "Update Users",
|
|
47
|
+
slug: "update-users",
|
|
48
|
+
action: "update",
|
|
49
|
+
resource: "users",
|
|
50
|
+
description: "Permission to update users"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "Delete Users",
|
|
54
|
+
slug: "delete-users",
|
|
55
|
+
action: "delete",
|
|
56
|
+
resource: "users",
|
|
57
|
+
description: "Permission to delete users"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "Create Roles",
|
|
61
|
+
slug: "create-roles",
|
|
62
|
+
action: "create",
|
|
63
|
+
resource: "roles",
|
|
64
|
+
description: "Permission to create roles"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "Read Roles",
|
|
68
|
+
slug: "read-roles",
|
|
69
|
+
action: "read",
|
|
70
|
+
resource: "roles",
|
|
71
|
+
description: "Permission to read roles"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "Update Roles",
|
|
75
|
+
slug: "update-roles",
|
|
76
|
+
action: "update",
|
|
77
|
+
resource: "roles",
|
|
78
|
+
description: "Permission to update roles"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "Delete Roles",
|
|
82
|
+
slug: "delete-roles",
|
|
83
|
+
action: "delete",
|
|
84
|
+
resource: "roles",
|
|
85
|
+
description: "Permission to delete roles"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "Manage Media",
|
|
89
|
+
slug: "manage-media",
|
|
90
|
+
action: "manage",
|
|
91
|
+
resource: "media",
|
|
92
|
+
description: "Permission to upload and manage media files"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "Create Media",
|
|
96
|
+
slug: "create-media",
|
|
97
|
+
action: "create",
|
|
98
|
+
resource: "media",
|
|
99
|
+
description: "Permission to upload media files"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "Read Media",
|
|
103
|
+
slug: "read-media",
|
|
104
|
+
action: "read",
|
|
105
|
+
resource: "media",
|
|
106
|
+
description: "Permission to view media files"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "Delete Media",
|
|
110
|
+
slug: "delete-media",
|
|
111
|
+
action: "delete",
|
|
112
|
+
resource: "media",
|
|
113
|
+
description: "Permission to delete media files"
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "Manage Settings",
|
|
117
|
+
slug: "manage-settings",
|
|
118
|
+
action: "manage",
|
|
119
|
+
resource: "settings",
|
|
120
|
+
description: "Permission to manage system settings"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "Read Settings",
|
|
124
|
+
slug: "read-settings",
|
|
125
|
+
action: "read",
|
|
126
|
+
resource: "settings",
|
|
127
|
+
description: "Permission to read system settings"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "Manage Email Providers",
|
|
131
|
+
slug: "manage-email-providers",
|
|
132
|
+
action: "manage",
|
|
133
|
+
resource: "email-providers",
|
|
134
|
+
description: "Permission to manage email providers"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: "Create Email Providers",
|
|
138
|
+
slug: "create-email-providers",
|
|
139
|
+
action: "create",
|
|
140
|
+
resource: "email-providers",
|
|
141
|
+
description: "Permission to create email providers"
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "Read Email Providers",
|
|
145
|
+
slug: "read-email-providers",
|
|
146
|
+
action: "read",
|
|
147
|
+
resource: "email-providers",
|
|
148
|
+
description: "Permission to read email providers"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "Delete Email Providers",
|
|
152
|
+
slug: "delete-email-providers",
|
|
153
|
+
action: "delete",
|
|
154
|
+
resource: "email-providers",
|
|
155
|
+
description: "Permission to delete email providers"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "Manage Email Templates",
|
|
159
|
+
slug: "manage-email-templates",
|
|
160
|
+
action: "manage",
|
|
161
|
+
resource: "email-templates",
|
|
162
|
+
description: "Permission to manage email templates"
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "Create Email Templates",
|
|
166
|
+
slug: "create-email-templates",
|
|
167
|
+
action: "create",
|
|
168
|
+
resource: "email-templates",
|
|
169
|
+
description: "Permission to create email templates"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "Read Email Templates",
|
|
173
|
+
slug: "read-email-templates",
|
|
174
|
+
action: "read",
|
|
175
|
+
resource: "email-templates",
|
|
176
|
+
description: "Permission to read email templates"
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "Delete Email Templates",
|
|
180
|
+
slug: "delete-email-templates",
|
|
181
|
+
action: "delete",
|
|
182
|
+
resource: "email-templates",
|
|
183
|
+
description: "Permission to delete email templates"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "Manage API Keys",
|
|
187
|
+
slug: "manage-api-keys",
|
|
188
|
+
action: "update",
|
|
189
|
+
resource: "api-keys",
|
|
190
|
+
description: "Permission to create and manage API keys"
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "Create API Keys",
|
|
194
|
+
slug: "create-api-keys",
|
|
195
|
+
action: "create",
|
|
196
|
+
resource: "api-keys",
|
|
197
|
+
description: "Permission to create API keys"
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "Read API Keys",
|
|
201
|
+
slug: "read-api-keys",
|
|
202
|
+
action: "read",
|
|
203
|
+
resource: "api-keys",
|
|
204
|
+
description: "Permission to read API keys"
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: "Delete API Keys",
|
|
208
|
+
slug: "delete-api-keys",
|
|
209
|
+
action: "delete",
|
|
210
|
+
resource: "api-keys",
|
|
211
|
+
description: "Permission to delete API keys"
|
|
212
|
+
}
|
|
213
|
+
];
|
|
214
|
+
var PermissionSeedService = class extends BaseService {
|
|
215
|
+
_permissionService;
|
|
216
|
+
_rolePermissionService;
|
|
217
|
+
constructor(adapter, logger) {
|
|
218
|
+
super(adapter, logger);
|
|
219
|
+
}
|
|
220
|
+
get permissionService() {
|
|
221
|
+
if (!this._permissionService) {
|
|
222
|
+
this._permissionService = new PermissionService(
|
|
223
|
+
this.adapter,
|
|
224
|
+
this.logger
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return this._permissionService;
|
|
228
|
+
}
|
|
229
|
+
get rolePermissionService() {
|
|
230
|
+
if (!this._rolePermissionService) {
|
|
231
|
+
this._rolePermissionService = new RolePermissionService(
|
|
232
|
+
this.adapter,
|
|
233
|
+
this.logger
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return this._rolePermissionService;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Seed all system resource permissions.
|
|
240
|
+
*
|
|
241
|
+
* Ensures all permissions from the SYSTEM_PERMISSIONS constant exist.
|
|
242
|
+
* System permissions cover: users, roles, permissions, media, settings,
|
|
243
|
+
* email-providers, email-templates.
|
|
244
|
+
*/
|
|
245
|
+
async seedSystemPermissions() {
|
|
246
|
+
const result = this.emptySeedResult();
|
|
247
|
+
for (const perm of SYSTEM_PERMISSIONS) {
|
|
248
|
+
result.total++;
|
|
249
|
+
try {
|
|
250
|
+
const ensureResult = await this.permissionService.ensurePermission(
|
|
251
|
+
perm.action,
|
|
252
|
+
perm.resource,
|
|
253
|
+
perm.name,
|
|
254
|
+
perm.slug,
|
|
255
|
+
perm.description
|
|
256
|
+
);
|
|
257
|
+
if (ensureResult.created) {
|
|
258
|
+
result.created++;
|
|
259
|
+
result.newPermissionIds.push(ensureResult.id);
|
|
260
|
+
} else {
|
|
261
|
+
result.skipped++;
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
result.errors++;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Seed CRUD permissions for a single collection.
|
|
271
|
+
*
|
|
272
|
+
* Creates 4 permissions: create, read, update, delete for the given slug.
|
|
273
|
+
*
|
|
274
|
+
* @param collectionSlug - The collection slug (e.g., "posts", "products")
|
|
275
|
+
*/
|
|
276
|
+
async seedCollectionPermissions(collectionSlug) {
|
|
277
|
+
const result = this.emptySeedResult();
|
|
278
|
+
const label = this.slugToLabel(collectionSlug);
|
|
279
|
+
const actions = ["create", "read", "update", "delete"];
|
|
280
|
+
for (const action of actions) {
|
|
281
|
+
const actionLabel = action.charAt(0).toUpperCase() + action.slice(1);
|
|
282
|
+
const name = `${actionLabel} ${label}`;
|
|
283
|
+
const slug = `${action}-${collectionSlug}`;
|
|
284
|
+
const description = `Permission to ${action} ${label.toLowerCase()}`;
|
|
285
|
+
result.total++;
|
|
286
|
+
try {
|
|
287
|
+
const ensureResult = await this.permissionService.ensurePermission(
|
|
288
|
+
action,
|
|
289
|
+
collectionSlug,
|
|
290
|
+
name,
|
|
291
|
+
slug,
|
|
292
|
+
description
|
|
293
|
+
);
|
|
294
|
+
if (ensureResult.created) {
|
|
295
|
+
result.created++;
|
|
296
|
+
result.newPermissionIds.push(ensureResult.id);
|
|
297
|
+
} else {
|
|
298
|
+
result.skipped++;
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
result.errors++;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Seed read/update permissions for a single (global document).
|
|
308
|
+
*
|
|
309
|
+
* Singles have no create/delete lifecycle — they are auto-created on first
|
|
310
|
+
* access and cannot be deleted. Only read and update permissions are generated.
|
|
311
|
+
*
|
|
312
|
+
* @param singleSlug - The single slug (e.g., "site-settings", "header")
|
|
313
|
+
*/
|
|
314
|
+
async seedSinglePermissions(singleSlug) {
|
|
315
|
+
const result = this.emptySeedResult();
|
|
316
|
+
const label = this.slugToLabel(singleSlug);
|
|
317
|
+
const actions = ["read", "update"];
|
|
318
|
+
for (const action of actions) {
|
|
319
|
+
const actionLabel = action.charAt(0).toUpperCase() + action.slice(1);
|
|
320
|
+
const name = `${actionLabel} ${label}`;
|
|
321
|
+
const slug = `${action}-${singleSlug}`;
|
|
322
|
+
const description = `Permission to ${action} ${label.toLowerCase()}`;
|
|
323
|
+
result.total++;
|
|
324
|
+
try {
|
|
325
|
+
const ensureResult = await this.permissionService.ensurePermission(
|
|
326
|
+
action,
|
|
327
|
+
singleSlug,
|
|
328
|
+
name,
|
|
329
|
+
slug,
|
|
330
|
+
description
|
|
331
|
+
);
|
|
332
|
+
if (ensureResult.created) {
|
|
333
|
+
result.created++;
|
|
334
|
+
result.newPermissionIds.push(ensureResult.id);
|
|
335
|
+
} else {
|
|
336
|
+
result.skipped++;
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
result.errors++;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Seed permissions for ALL dynamic collections.
|
|
346
|
+
*
|
|
347
|
+
* Reads all collection slugs from the `dynamic_collections` table
|
|
348
|
+
* (including plugin-registered collections) and seeds 4 CRUD permissions
|
|
349
|
+
* for each.
|
|
350
|
+
*/
|
|
351
|
+
async seedAllCollectionPermissions() {
|
|
352
|
+
const result = this.emptySeedResult();
|
|
353
|
+
try {
|
|
354
|
+
const slugs = await this.getAllCollectionSlugs();
|
|
355
|
+
for (const slug of slugs) {
|
|
356
|
+
if (SYSTEM_RESOURCES.includes(slug)) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const collectionResult = await this.seedCollectionPermissions(slug);
|
|
360
|
+
this.mergeSeedResult(result, collectionResult);
|
|
361
|
+
}
|
|
362
|
+
} catch {
|
|
363
|
+
this.logger.warn(
|
|
364
|
+
"Could not read dynamic_collections table \u2014 skipping collection permission seeding."
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Seed permissions for ALL registered singles.
|
|
371
|
+
*
|
|
372
|
+
* Reads all single slugs from the `dynamic_singles` table and seeds
|
|
373
|
+
* read/update permissions for each.
|
|
374
|
+
*/
|
|
375
|
+
async seedAllSinglePermissions() {
|
|
376
|
+
const result = this.emptySeedResult();
|
|
377
|
+
try {
|
|
378
|
+
const slugs = await this.getAllSingleSlugs();
|
|
379
|
+
for (const slug of slugs) {
|
|
380
|
+
const singleResult = await this.seedSinglePermissions(slug);
|
|
381
|
+
this.mergeSeedResult(result, singleResult);
|
|
382
|
+
}
|
|
383
|
+
} catch {
|
|
384
|
+
this.logger.warn(
|
|
385
|
+
"Could not read dynamic_singles table \u2014 skipping single permission seeding."
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Assign newly created permissions to the super_admin role.
|
|
392
|
+
*
|
|
393
|
+
* Ensures the super_admin role retains full access when new permissions
|
|
394
|
+
* are generated. Only assigns permissions that aren't already assigned.
|
|
395
|
+
*
|
|
396
|
+
* @param permissionIds - IDs of newly created permissions to assign
|
|
397
|
+
*/
|
|
398
|
+
async assignNewPermissionsToSuperAdmin(permissionIds) {
|
|
399
|
+
if (permissionIds.length === 0) return;
|
|
400
|
+
try {
|
|
401
|
+
const { roles } = this.tables;
|
|
402
|
+
const superAdminRole = await this.db.select({ id: roles.id }).from(roles).where(eq(roles.slug, "super-admin")).limit(1).then(
|
|
403
|
+
(rows) => rows[0] ?? null
|
|
404
|
+
);
|
|
405
|
+
if (!superAdminRole) {
|
|
406
|
+
this.logger.debug(
|
|
407
|
+
"super-admin role not found yet \u2014 permissions will be assigned during onboarding."
|
|
408
|
+
);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const roleId = String(superAdminRole.id);
|
|
412
|
+
for (const permissionId of permissionIds) {
|
|
413
|
+
const existing = await this.db.query.rolePermissions.findFirst({
|
|
414
|
+
// Required by Drizzle ORM: relational query `where` callback is not
|
|
415
|
+
// narrowly typed without importing internal Drizzle helper types.
|
|
416
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
417
|
+
where: (rp, { and: andFn, eq: eqFn }) => andFn(eqFn(rp.roleId, roleId), eqFn(rp.permissionId, permissionId)),
|
|
418
|
+
columns: { id: true }
|
|
419
|
+
});
|
|
420
|
+
if (!existing) {
|
|
421
|
+
try {
|
|
422
|
+
const perm = await this.permissionService.getPermissionById(permissionId);
|
|
423
|
+
await this.rolePermissionService.addPermissionToRole(roleId, {
|
|
424
|
+
action: perm.action,
|
|
425
|
+
resource: perm.resource,
|
|
426
|
+
name: perm.name,
|
|
427
|
+
slug: perm.slug
|
|
428
|
+
});
|
|
429
|
+
} catch {
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
this.logger.info?.(
|
|
434
|
+
`Assigned ${permissionIds.length} new permission(s) to super-admin role.`
|
|
435
|
+
);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
this.logger.warn(
|
|
438
|
+
`Failed to assign permissions to super-admin: ${error instanceof Error ? error.message : String(error)}`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Delete all permissions for a specific collection or single.
|
|
444
|
+
*
|
|
445
|
+
* Removes all permissions where the resource matches the given slug.
|
|
446
|
+
* First removes the permissions from all roles, then deletes the permissions.
|
|
447
|
+
* This is typically called when a collection or single is deleted.
|
|
448
|
+
*
|
|
449
|
+
* @param resourceSlug - The collection or single slug (e.g., "posts", "site-settings")
|
|
450
|
+
* @returns Result with count of deleted permissions
|
|
451
|
+
*/
|
|
452
|
+
async deletePermissionsForResource(resourceSlug) {
|
|
453
|
+
const result = this.emptySeedResult();
|
|
454
|
+
try {
|
|
455
|
+
const allPerms = await this.permissionService.listPermissions({
|
|
456
|
+
page: 1,
|
|
457
|
+
limit: 1e4
|
|
458
|
+
});
|
|
459
|
+
const { rolePermissions, permissions } = this.tables;
|
|
460
|
+
for (const perm of allPerms.data) {
|
|
461
|
+
if (perm.resource === resourceSlug) {
|
|
462
|
+
result.total++;
|
|
463
|
+
try {
|
|
464
|
+
await this.db.delete(rolePermissions).where(eq(rolePermissions.permissionId, perm.id));
|
|
465
|
+
await this.db.delete(permissions).where(eq(permissions.id, perm.id));
|
|
466
|
+
result.created++;
|
|
467
|
+
this.logger.info?.(
|
|
468
|
+
`Deleted permission "${perm.slug}" for resource "${resourceSlug}"`
|
|
469
|
+
);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
result.skipped++;
|
|
472
|
+
this.logger.warn?.(
|
|
473
|
+
`Error deleting permission "${perm.slug}": ${error instanceof Error ? error.message : String(error)}`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (result.created > 0) {
|
|
479
|
+
this.logger.info?.(
|
|
480
|
+
`Deleted ${result.created} permission(s) for resource "${resourceSlug}"`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
} catch (error) {
|
|
484
|
+
this.logger.warn(
|
|
485
|
+
`Failed to delete permissions for resource "${resourceSlug}": ${error instanceof Error ? error.message : String(error)}`
|
|
486
|
+
);
|
|
487
|
+
result.errors++;
|
|
488
|
+
}
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Remove permissions for dynamic resources that no longer exist.
|
|
493
|
+
*
|
|
494
|
+
* This is NOT auto-run — it must be called explicitly to prevent
|
|
495
|
+
* accidental permission loss. Removes permissions whose resource is not
|
|
496
|
+
* a system resource and not found in dynamic_collections,
|
|
497
|
+
* dynamic_singles, or dynamic_components.
|
|
498
|
+
* First removes permissions from all roles, then deletes the permissions.
|
|
499
|
+
*/
|
|
500
|
+
async cleanupOrphanedPermissions() {
|
|
501
|
+
const result = this.emptySeedResult();
|
|
502
|
+
try {
|
|
503
|
+
const collectionSlugs = await this.getAllCollectionSlugs();
|
|
504
|
+
const singleSlugs = await this.getAllSingleSlugs();
|
|
505
|
+
const componentSlugs = await this.getAllComponentSlugs();
|
|
506
|
+
const knownResources = /* @__PURE__ */ new Set([
|
|
507
|
+
...SYSTEM_RESOURCES,
|
|
508
|
+
...collectionSlugs,
|
|
509
|
+
...singleSlugs,
|
|
510
|
+
...componentSlugs
|
|
511
|
+
]);
|
|
512
|
+
const allPerms = await this.permissionService.listPermissions({
|
|
513
|
+
page: 1,
|
|
514
|
+
limit: 1e4
|
|
515
|
+
});
|
|
516
|
+
const { rolePermissions, permissions } = this.tables;
|
|
517
|
+
for (const perm of allPerms.data) {
|
|
518
|
+
if (!knownResources.has(perm.resource)) {
|
|
519
|
+
result.total++;
|
|
520
|
+
try {
|
|
521
|
+
await this.db.delete(rolePermissions).where(eq(rolePermissions.permissionId, perm.id));
|
|
522
|
+
await this.db.delete(permissions).where(eq(permissions.id, perm.id));
|
|
523
|
+
result.created++;
|
|
524
|
+
this.logger.info?.(
|
|
525
|
+
`Cleaned up orphaned permission "${perm.slug}" (resource: ${perm.resource})`
|
|
526
|
+
);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
result.skipped++;
|
|
529
|
+
this.logger.warn?.(
|
|
530
|
+
`Error cleaning up permission "${perm.slug}": ${error instanceof Error ? error.message : String(error)}`
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (result.created > 0) {
|
|
536
|
+
this.logger.info?.(
|
|
537
|
+
`Cleaned up ${result.created} orphaned permission(s)`
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
} catch (error) {
|
|
541
|
+
this.logger.warn(
|
|
542
|
+
`Failed to cleanup orphaned permissions: ${error instanceof Error ? error.message : String(error)}`
|
|
543
|
+
);
|
|
544
|
+
result.errors++;
|
|
545
|
+
}
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
548
|
+
async getAllCollectionSlugs() {
|
|
549
|
+
if (!this.tables?.dynamicCollections) return [];
|
|
550
|
+
const rows = await this.db.select({ slug: this.tables.dynamicCollections.slug }).from(this.tables.dynamicCollections);
|
|
551
|
+
return rows.map((row) => String(row.slug));
|
|
552
|
+
}
|
|
553
|
+
async getAllSingleSlugs() {
|
|
554
|
+
if (!this.tables?.dynamicSingles) return [];
|
|
555
|
+
const rows = await this.db.select({ slug: this.tables.dynamicSingles.slug }).from(this.tables.dynamicSingles);
|
|
556
|
+
return rows.map((row) => String(row.slug));
|
|
557
|
+
}
|
|
558
|
+
async getAllComponentSlugs() {
|
|
559
|
+
if (!this.tables?.dynamicComponents) return [];
|
|
560
|
+
const rows = await this.db.select({ slug: this.tables.dynamicComponents.slug }).from(this.tables.dynamicComponents);
|
|
561
|
+
return rows.map((row) => String(row.slug));
|
|
562
|
+
}
|
|
563
|
+
slugToLabel(slug) {
|
|
564
|
+
return slug.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
565
|
+
}
|
|
566
|
+
emptySeedResult() {
|
|
567
|
+
return {
|
|
568
|
+
created: 0,
|
|
569
|
+
skipped: 0,
|
|
570
|
+
errors: 0,
|
|
571
|
+
total: 0,
|
|
572
|
+
newPermissionIds: []
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
mergeSeedResult(parent, child) {
|
|
576
|
+
parent.created += child.created;
|
|
577
|
+
parent.skipped += child.skipped;
|
|
578
|
+
parent.errors += child.errors;
|
|
579
|
+
parent.total += child.total;
|
|
580
|
+
parent.newPermissionIds.push(...child.newPermissionIds);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
// src/domains/components/services/component-registry-service.ts
|
|
585
|
+
var ComponentRegistryService = class extends BaseRegistryService {
|
|
586
|
+
registryTableName = "dynamic_components";
|
|
587
|
+
resourceType = "Component";
|
|
588
|
+
tableNamePrefix = "comp_";
|
|
589
|
+
constructor(adapter, logger) {
|
|
590
|
+
super(adapter, logger);
|
|
591
|
+
}
|
|
592
|
+
getSearchColumns() {
|
|
593
|
+
return ["slug", "label"];
|
|
594
|
+
}
|
|
595
|
+
async getComponentBySlug(slug) {
|
|
596
|
+
return this.getRecordBySlug(slug);
|
|
597
|
+
}
|
|
598
|
+
async getComponent(slug) {
|
|
599
|
+
return this.getRecordOrThrow(slug);
|
|
600
|
+
}
|
|
601
|
+
async getAllComponents(options) {
|
|
602
|
+
return this.getAllRecords(options);
|
|
603
|
+
}
|
|
604
|
+
async listComponents(options) {
|
|
605
|
+
return this.listRecords(options);
|
|
606
|
+
}
|
|
607
|
+
async isLocked(slug) {
|
|
608
|
+
return this.checkIsLocked(slug);
|
|
609
|
+
}
|
|
610
|
+
async updateMigrationStatus(slug, status, migrationId) {
|
|
611
|
+
return this.updateRecordMigrationStatus(slug, status, migrationId);
|
|
612
|
+
}
|
|
613
|
+
async updateMigrationStatusWithVerification(slug, tableName) {
|
|
614
|
+
return this.updateMigrationStatusWithTableVerification(slug, tableName);
|
|
615
|
+
}
|
|
616
|
+
async getPendingMigrations() {
|
|
617
|
+
return this.getRecordsWithPendingMigrations();
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Register a new Component in the registry.
|
|
621
|
+
*
|
|
622
|
+
* @throws NextlyError(DUPLICATE) if a Component with the same slug already exists.
|
|
623
|
+
* @throws NextlyError(DATABASE_ERROR) on insert failure.
|
|
624
|
+
*/
|
|
625
|
+
async registerComponent(data) {
|
|
626
|
+
this.logger.debug("Registering Component", { slug: data.slug });
|
|
627
|
+
const existing = await this.getComponentBySlug(data.slug);
|
|
628
|
+
if (existing) {
|
|
629
|
+
throw NextlyError.duplicate({
|
|
630
|
+
logContext: { reason: "component-slug-conflict", slug: data.slug }
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
const now = this.formatDateForDb();
|
|
634
|
+
const tableName = this.ensureTableNamePrefix(data.tableName);
|
|
635
|
+
const record = {
|
|
636
|
+
id: this.generateId(),
|
|
637
|
+
slug: data.slug,
|
|
638
|
+
label: data.label,
|
|
639
|
+
table_name: tableName,
|
|
640
|
+
description: data.description,
|
|
641
|
+
fields: JSON.stringify(data.fields),
|
|
642
|
+
admin: data.admin ? JSON.stringify(data.admin) : null,
|
|
643
|
+
source: data.source,
|
|
644
|
+
locked: data.locked ?? data.source === "code" ? 1 : 0,
|
|
645
|
+
config_path: data.configPath,
|
|
646
|
+
schema_hash: data.schemaHash,
|
|
647
|
+
schema_version: data.schemaVersion ?? 1,
|
|
648
|
+
migration_status: data.migrationStatus ?? "pending",
|
|
649
|
+
last_migration_id: data.lastMigrationId,
|
|
650
|
+
created_by: data.createdBy,
|
|
651
|
+
created_at: now,
|
|
652
|
+
updated_at: now
|
|
653
|
+
};
|
|
654
|
+
try {
|
|
655
|
+
const result = await this.adapter.insert(
|
|
656
|
+
this.registryTableName,
|
|
657
|
+
record,
|
|
658
|
+
{ returning: "*" }
|
|
659
|
+
);
|
|
660
|
+
this.logger.info("Component registered", {
|
|
661
|
+
slug: data.slug,
|
|
662
|
+
source: data.source
|
|
663
|
+
});
|
|
664
|
+
return this.deserializeRecord(result);
|
|
665
|
+
} catch (error) {
|
|
666
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
async registerComponentInTransaction(tx, data) {
|
|
670
|
+
const existing = await tx.selectOne(
|
|
671
|
+
this.registryTableName,
|
|
672
|
+
{
|
|
673
|
+
where: this.whereEq("slug", data.slug)
|
|
674
|
+
}
|
|
675
|
+
);
|
|
676
|
+
if (existing) {
|
|
677
|
+
throw NextlyError.duplicate({
|
|
678
|
+
logContext: { reason: "component-slug-conflict", slug: data.slug }
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
const now = this.formatDateForDb();
|
|
682
|
+
const tableName = this.ensureTableNamePrefix(data.tableName);
|
|
683
|
+
const record = {
|
|
684
|
+
id: this.generateId(),
|
|
685
|
+
slug: data.slug,
|
|
686
|
+
label: data.label,
|
|
687
|
+
table_name: tableName,
|
|
688
|
+
description: data.description,
|
|
689
|
+
fields: JSON.stringify(data.fields),
|
|
690
|
+
admin: data.admin ? JSON.stringify(data.admin) : null,
|
|
691
|
+
source: data.source,
|
|
692
|
+
locked: data.locked ?? data.source === "code" ? 1 : 0,
|
|
693
|
+
config_path: data.configPath,
|
|
694
|
+
schema_hash: data.schemaHash,
|
|
695
|
+
schema_version: data.schemaVersion ?? 1,
|
|
696
|
+
migration_status: data.migrationStatus ?? "pending",
|
|
697
|
+
last_migration_id: data.lastMigrationId,
|
|
698
|
+
created_by: data.createdBy,
|
|
699
|
+
created_at: now,
|
|
700
|
+
updated_at: now
|
|
701
|
+
};
|
|
702
|
+
const result = await tx.insert(
|
|
703
|
+
this.registryTableName,
|
|
704
|
+
record,
|
|
705
|
+
{ returning: "*" }
|
|
706
|
+
);
|
|
707
|
+
return this.deserializeRecord(result);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Update a Component's metadata.
|
|
711
|
+
*
|
|
712
|
+
* @throws NextlyError(NOT_FOUND) when no Component matches the slug.
|
|
713
|
+
* @throws NextlyError(FORBIDDEN) when the Component is locked and the source isn't "code".
|
|
714
|
+
*/
|
|
715
|
+
async updateComponent(slug, data, options) {
|
|
716
|
+
this.logger.debug("Updating Component", { slug });
|
|
717
|
+
const existing = await this.getComponent(slug);
|
|
718
|
+
if (existing.locked && options?.source !== "code") {
|
|
719
|
+
throw NextlyError.forbidden({
|
|
720
|
+
logContext: {
|
|
721
|
+
reason: "component-locked",
|
|
722
|
+
slug,
|
|
723
|
+
source: options?.source ?? "UI"
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
const updateData = {
|
|
728
|
+
updated_at: this.formatDateForDb()
|
|
729
|
+
};
|
|
730
|
+
if (data.label !== void 0) {
|
|
731
|
+
updateData.label = data.label;
|
|
732
|
+
}
|
|
733
|
+
if (data.description !== void 0) {
|
|
734
|
+
updateData.description = data.description;
|
|
735
|
+
}
|
|
736
|
+
if (data.fields) {
|
|
737
|
+
updateData.fields = JSON.stringify(data.fields);
|
|
738
|
+
updateData.schema_version = existing.schemaVersion + 1;
|
|
739
|
+
updateData.migration_status = data.migrationStatus || "pending";
|
|
740
|
+
}
|
|
741
|
+
if (data.admin !== void 0) {
|
|
742
|
+
updateData.admin = data.admin ? JSON.stringify(data.admin) : null;
|
|
743
|
+
}
|
|
744
|
+
if (data.schemaHash) {
|
|
745
|
+
updateData.schema_hash = data.schemaHash;
|
|
746
|
+
}
|
|
747
|
+
if (data.locked !== void 0) {
|
|
748
|
+
updateData.locked = data.locked ? 1 : 0;
|
|
749
|
+
}
|
|
750
|
+
if (data.configPath !== void 0) {
|
|
751
|
+
updateData.config_path = data.configPath;
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
const results = await this.adapter.update(
|
|
755
|
+
this.registryTableName,
|
|
756
|
+
updateData,
|
|
757
|
+
this.whereEq("slug", slug),
|
|
758
|
+
{ returning: "*" }
|
|
759
|
+
);
|
|
760
|
+
if (results.length === 0) {
|
|
761
|
+
throw NextlyError.notFound({ logContext: { slug } });
|
|
762
|
+
}
|
|
763
|
+
this.logger.info("Component updated", { slug });
|
|
764
|
+
return this.deserializeRecord(results[0]);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
if (NextlyError.is(error)) {
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Delete a Component from the registry.
|
|
774
|
+
*/
|
|
775
|
+
async deleteComponent(slug) {
|
|
776
|
+
this.logger.debug("Deleting Component", { slug });
|
|
777
|
+
const existing = await this.getComponent(slug);
|
|
778
|
+
if (existing.locked) {
|
|
779
|
+
throw NextlyError.forbidden({
|
|
780
|
+
logContext: { reason: "component-locked-for-delete", slug }
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
const references = await this.findComponentReferences(slug);
|
|
784
|
+
if (references.length > 0) {
|
|
785
|
+
throw NextlyError.conflict({
|
|
786
|
+
reason: "state",
|
|
787
|
+
logContext: { reason: "component-has-references", slug, references }
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
try {
|
|
791
|
+
await this.dropComponentTable(existing.tableName);
|
|
792
|
+
await this.adapter.delete(
|
|
793
|
+
this.registryTableName,
|
|
794
|
+
this.whereEq("slug", slug)
|
|
795
|
+
);
|
|
796
|
+
this.logger.info("Component deleted", { slug });
|
|
797
|
+
} catch (error) {
|
|
798
|
+
if (NextlyError.is(error)) {
|
|
799
|
+
throw error;
|
|
800
|
+
}
|
|
801
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
// Uses IF EXISTS so the operation is safe even if the table was never created.
|
|
805
|
+
// PostgreSQL uses CASCADE to drop any dependent objects.
|
|
806
|
+
async dropComponentTable(tableName) {
|
|
807
|
+
const q = this.dialect === "mysql" ? "`" : '"';
|
|
808
|
+
const quotedName = `${q}${tableName}${q}`;
|
|
809
|
+
const sql = this.dialect === "postgresql" ? `DROP TABLE IF EXISTS ${quotedName} CASCADE` : `DROP TABLE IF EXISTS ${quotedName}`;
|
|
810
|
+
this.logger.debug("Dropping component table", { tableName });
|
|
811
|
+
await this.adapter.executeQuery(sql);
|
|
812
|
+
this.logger.info("Component table dropped", { tableName });
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Sync code-first Components with the registry.
|
|
816
|
+
*/
|
|
817
|
+
async syncCodeFirstComponents(configs) {
|
|
818
|
+
this.logger.info("Syncing code-first Components", {
|
|
819
|
+
count: configs.length
|
|
820
|
+
});
|
|
821
|
+
const result = {
|
|
822
|
+
created: [],
|
|
823
|
+
updated: [],
|
|
824
|
+
unchanged: [],
|
|
825
|
+
errors: []
|
|
826
|
+
};
|
|
827
|
+
for (const config of configs) {
|
|
828
|
+
try {
|
|
829
|
+
const existing = await this.getComponentBySlug(config.slug);
|
|
830
|
+
const schemaHash = calculateSchemaHash(config.fields);
|
|
831
|
+
if (!existing) {
|
|
832
|
+
await this.registerComponent({
|
|
833
|
+
slug: config.slug,
|
|
834
|
+
label: config.label,
|
|
835
|
+
tableName: config.tableName ?? this.generateTableName(config.slug),
|
|
836
|
+
description: config.description,
|
|
837
|
+
fields: config.fields,
|
|
838
|
+
admin: config.admin,
|
|
839
|
+
source: "code",
|
|
840
|
+
locked: true,
|
|
841
|
+
configPath: config.configPath,
|
|
842
|
+
schemaHash
|
|
843
|
+
});
|
|
844
|
+
result.created.push(config.slug);
|
|
845
|
+
} else if (!schemaHashesMatch(schemaHash, existing.schemaHash)) {
|
|
846
|
+
await this.updateComponent(
|
|
847
|
+
config.slug,
|
|
848
|
+
{
|
|
849
|
+
label: config.label,
|
|
850
|
+
description: config.description,
|
|
851
|
+
fields: config.fields,
|
|
852
|
+
admin: config.admin,
|
|
853
|
+
configPath: config.configPath,
|
|
854
|
+
schemaHash,
|
|
855
|
+
locked: true
|
|
856
|
+
},
|
|
857
|
+
{ source: "code" }
|
|
858
|
+
);
|
|
859
|
+
result.updated.push(config.slug);
|
|
860
|
+
} else if (this.adminConfigChanged(config.admin, existing.admin)) {
|
|
861
|
+
await this.updateComponent(
|
|
862
|
+
config.slug,
|
|
863
|
+
{
|
|
864
|
+
admin: config.admin,
|
|
865
|
+
locked: true
|
|
866
|
+
},
|
|
867
|
+
{ source: "code" }
|
|
868
|
+
);
|
|
869
|
+
result.updated.push(config.slug);
|
|
870
|
+
} else {
|
|
871
|
+
result.unchanged.push(config.slug);
|
|
872
|
+
}
|
|
873
|
+
} catch (error) {
|
|
874
|
+
result.errors.push({
|
|
875
|
+
slug: config.slug,
|
|
876
|
+
error: error instanceof Error ? error.message : String(error)
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
this.logger.info("Code-first Component sync completed", {
|
|
881
|
+
created: result.created.length,
|
|
882
|
+
updated: result.updated.length,
|
|
883
|
+
unchanged: result.unchanged.length,
|
|
884
|
+
errors: result.errors.length
|
|
885
|
+
});
|
|
886
|
+
return result;
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Find all references to a Component across Collections, Singles, and other Components.
|
|
890
|
+
*/
|
|
891
|
+
async findComponentReferences(componentSlug) {
|
|
892
|
+
this.logger.debug("Checking for component references", {
|
|
893
|
+
slug: componentSlug
|
|
894
|
+
});
|
|
895
|
+
const references = [];
|
|
896
|
+
try {
|
|
897
|
+
const collections = await this.adapter.select(
|
|
898
|
+
"dynamic_collections",
|
|
899
|
+
{ columns: ["slug", "fields"] }
|
|
900
|
+
);
|
|
901
|
+
for (const collection of collections) {
|
|
902
|
+
const slug = collection.slug;
|
|
903
|
+
const fields = this.parseJsonField(collection.fields);
|
|
904
|
+
if (fields) {
|
|
905
|
+
const found = this.scanFieldsForComponentRef(
|
|
906
|
+
fields,
|
|
907
|
+
componentSlug,
|
|
908
|
+
slug,
|
|
909
|
+
"collection"
|
|
910
|
+
);
|
|
911
|
+
references.push(...found);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
} catch (error) {
|
|
915
|
+
this.logger.debug("Could not scan dynamic_collections for references", {
|
|
916
|
+
error: error instanceof Error ? error.message : String(error)
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
const singles = await this.adapter.select(
|
|
921
|
+
"dynamic_singles",
|
|
922
|
+
{ columns: ["slug", "fields"] }
|
|
923
|
+
);
|
|
924
|
+
for (const single of singles) {
|
|
925
|
+
const slug = single.slug;
|
|
926
|
+
const fields = this.parseJsonField(single.fields);
|
|
927
|
+
if (fields) {
|
|
928
|
+
const found = this.scanFieldsForComponentRef(
|
|
929
|
+
fields,
|
|
930
|
+
componentSlug,
|
|
931
|
+
slug,
|
|
932
|
+
"single"
|
|
933
|
+
);
|
|
934
|
+
references.push(...found);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
} catch (error) {
|
|
938
|
+
this.logger.debug("Could not scan dynamic_singles for references", {
|
|
939
|
+
error: error instanceof Error ? error.message : String(error)
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
try {
|
|
943
|
+
const components = await this.adapter.select(
|
|
944
|
+
this.registryTableName,
|
|
945
|
+
{ columns: ["slug", "fields"] }
|
|
946
|
+
);
|
|
947
|
+
for (const comp of components) {
|
|
948
|
+
const slug = comp.slug;
|
|
949
|
+
if (slug === componentSlug) {
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
const fields = this.parseJsonField(comp.fields);
|
|
953
|
+
if (fields) {
|
|
954
|
+
const found = this.scanFieldsForComponentRef(
|
|
955
|
+
fields,
|
|
956
|
+
componentSlug,
|
|
957
|
+
slug,
|
|
958
|
+
"component"
|
|
959
|
+
);
|
|
960
|
+
references.push(...found);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
} catch (error) {
|
|
964
|
+
this.logger.debug("Could not scan dynamic_components for references", {
|
|
965
|
+
error: error instanceof Error ? error.message : String(error)
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
if (references.length > 0) {
|
|
969
|
+
this.logger.debug("Found component references", {
|
|
970
|
+
slug: componentSlug,
|
|
971
|
+
count: references.length,
|
|
972
|
+
references: references.map(
|
|
973
|
+
(r) => `${r.entityType}:${r.entitySlug}.${r.fieldPath}`
|
|
974
|
+
)
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
return references;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Enrich field configurations with inline component schemas.
|
|
981
|
+
*/
|
|
982
|
+
async enrichFieldsWithComponentSchemas(fields, currentDepth = 0) {
|
|
983
|
+
const slugs = this.collectComponentSlugs(fields);
|
|
984
|
+
if (slugs.size === 0) {
|
|
985
|
+
return fields;
|
|
986
|
+
}
|
|
987
|
+
const componentMap = await this.fetchComponentsBySlugsBatch([...slugs]);
|
|
988
|
+
return this.enrichFieldsRecursive(fields, componentMap, currentDepth);
|
|
989
|
+
}
|
|
990
|
+
collectComponentSlugs(fields, slugs = /* @__PURE__ */ new Set()) {
|
|
991
|
+
for (const field of fields) {
|
|
992
|
+
const fieldType = field.type;
|
|
993
|
+
if (fieldType === "component") {
|
|
994
|
+
const componentSlug = field.component;
|
|
995
|
+
if (componentSlug) {
|
|
996
|
+
slugs.add(componentSlug);
|
|
997
|
+
}
|
|
998
|
+
const componentsArray = field.components;
|
|
999
|
+
if (Array.isArray(componentsArray)) {
|
|
1000
|
+
for (const slug of componentsArray) {
|
|
1001
|
+
slugs.add(slug);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
const nestedFields = field.fields;
|
|
1006
|
+
if (Array.isArray(nestedFields)) {
|
|
1007
|
+
this.collectComponentSlugs(nestedFields, slugs);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return slugs;
|
|
1011
|
+
}
|
|
1012
|
+
async fetchComponentsBySlugsBatch(slugs) {
|
|
1013
|
+
const componentMap = /* @__PURE__ */ new Map();
|
|
1014
|
+
if (slugs.length === 0) {
|
|
1015
|
+
return componentMap;
|
|
1016
|
+
}
|
|
1017
|
+
try {
|
|
1018
|
+
const results = await this.adapter.select(
|
|
1019
|
+
this.registryTableName,
|
|
1020
|
+
{
|
|
1021
|
+
where: {
|
|
1022
|
+
and: [
|
|
1023
|
+
{
|
|
1024
|
+
column: "slug",
|
|
1025
|
+
op: "IN",
|
|
1026
|
+
value: slugs
|
|
1027
|
+
}
|
|
1028
|
+
]
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
);
|
|
1032
|
+
for (const result of results) {
|
|
1033
|
+
const deserialized = this.deserializeRecord(result);
|
|
1034
|
+
componentMap.set(deserialized.slug, deserialized);
|
|
1035
|
+
}
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
this.logger.error(
|
|
1038
|
+
"[ComponentRegistry.fetchComponentsBySlugsBatch] Database error",
|
|
1039
|
+
{
|
|
1040
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1041
|
+
}
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
return componentMap;
|
|
1045
|
+
}
|
|
1046
|
+
async enrichFieldsRecursive(fields, componentMap, currentDepth) {
|
|
1047
|
+
const enrichedFields = [];
|
|
1048
|
+
for (const field of fields) {
|
|
1049
|
+
const fieldType = field.type;
|
|
1050
|
+
const enrichedField = { ...field };
|
|
1051
|
+
if (fieldType === "component") {
|
|
1052
|
+
const componentSlug = field.component;
|
|
1053
|
+
if (componentSlug) {
|
|
1054
|
+
const component = componentMap.get(componentSlug);
|
|
1055
|
+
if (component) {
|
|
1056
|
+
let componentFields = component.fields;
|
|
1057
|
+
if (currentDepth < MAX_COMPONENT_NESTING_DEPTH && Array.isArray(componentFields)) {
|
|
1058
|
+
const nestedSlugs = this.collectComponentSlugs(componentFields);
|
|
1059
|
+
if (nestedSlugs.size > 0) {
|
|
1060
|
+
const missingSlugsFetch = [...nestedSlugs].filter(
|
|
1061
|
+
(s) => !componentMap.has(s)
|
|
1062
|
+
);
|
|
1063
|
+
if (missingSlugsFetch.length > 0) {
|
|
1064
|
+
const nestedMap = await this.fetchComponentsBySlugsBatch(missingSlugsFetch);
|
|
1065
|
+
for (const [slug, record] of nestedMap) {
|
|
1066
|
+
componentMap.set(slug, record);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
componentFields = await this.enrichFieldsRecursive(
|
|
1070
|
+
componentFields,
|
|
1071
|
+
componentMap,
|
|
1072
|
+
currentDepth + 1
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
enrichedField.componentFields = componentFields;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
const componentsArray = field.components;
|
|
1080
|
+
if (Array.isArray(componentsArray) && componentsArray.length > 0) {
|
|
1081
|
+
const componentSchemas = {};
|
|
1082
|
+
for (const slug of componentsArray) {
|
|
1083
|
+
const component = componentMap.get(slug);
|
|
1084
|
+
if (component) {
|
|
1085
|
+
let componentFields = component.fields;
|
|
1086
|
+
if (currentDepth < MAX_COMPONENT_NESTING_DEPTH && Array.isArray(componentFields)) {
|
|
1087
|
+
const nestedSlugs = this.collectComponentSlugs(componentFields);
|
|
1088
|
+
if (nestedSlugs.size > 0) {
|
|
1089
|
+
const missingSlugsFetch = [...nestedSlugs].filter(
|
|
1090
|
+
(s) => !componentMap.has(s)
|
|
1091
|
+
);
|
|
1092
|
+
if (missingSlugsFetch.length > 0) {
|
|
1093
|
+
const nestedMap = await this.fetchComponentsBySlugsBatch(missingSlugsFetch);
|
|
1094
|
+
for (const [s, record] of nestedMap) {
|
|
1095
|
+
componentMap.set(s, record);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
componentFields = await this.enrichFieldsRecursive(
|
|
1099
|
+
componentFields,
|
|
1100
|
+
componentMap,
|
|
1101
|
+
currentDepth + 1
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
componentSchemas[slug] = {
|
|
1106
|
+
label: component.label,
|
|
1107
|
+
fields: componentFields,
|
|
1108
|
+
admin: component.admin
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (Object.keys(componentSchemas).length > 0) {
|
|
1113
|
+
enrichedField.componentSchemas = componentSchemas;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const nestedFields = field.fields;
|
|
1118
|
+
if (Array.isArray(nestedFields)) {
|
|
1119
|
+
enrichedField.fields = await this.enrichFieldsRecursive(
|
|
1120
|
+
nestedFields,
|
|
1121
|
+
componentMap,
|
|
1122
|
+
currentDepth
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
enrichedFields.push(enrichedField);
|
|
1126
|
+
}
|
|
1127
|
+
return enrichedFields;
|
|
1128
|
+
}
|
|
1129
|
+
parseJsonField(value) {
|
|
1130
|
+
if (!value) {
|
|
1131
|
+
return null;
|
|
1132
|
+
}
|
|
1133
|
+
try {
|
|
1134
|
+
if (typeof value === "string") {
|
|
1135
|
+
return JSON.parse(value);
|
|
1136
|
+
}
|
|
1137
|
+
if (Array.isArray(value)) {
|
|
1138
|
+
return value;
|
|
1139
|
+
}
|
|
1140
|
+
return null;
|
|
1141
|
+
} catch {
|
|
1142
|
+
return null;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
scanFieldsForComponentRef(fields, targetSlug, entitySlug, entityType, parentPath = "") {
|
|
1146
|
+
const references = [];
|
|
1147
|
+
for (const field of fields) {
|
|
1148
|
+
const fieldName = field.name;
|
|
1149
|
+
if (!fieldName) {
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
const fieldPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
|
|
1153
|
+
const fieldType = field.type;
|
|
1154
|
+
if (fieldType === "component") {
|
|
1155
|
+
if (field.component === targetSlug) {
|
|
1156
|
+
references.push({ entityType, entitySlug, fieldName, fieldPath });
|
|
1157
|
+
}
|
|
1158
|
+
const componentsArray = field.components;
|
|
1159
|
+
if (Array.isArray(componentsArray) && componentsArray.includes(targetSlug)) {
|
|
1160
|
+
references.push({ entityType, entitySlug, fieldName, fieldPath });
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
if ((fieldType === "repeater" || fieldType === "group") && Array.isArray(field.fields)) {
|
|
1164
|
+
const nested = this.scanFieldsForComponentRef(
|
|
1165
|
+
field.fields,
|
|
1166
|
+
targetSlug,
|
|
1167
|
+
entitySlug,
|
|
1168
|
+
entityType,
|
|
1169
|
+
fieldPath
|
|
1170
|
+
);
|
|
1171
|
+
references.push(...nested);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return references;
|
|
1175
|
+
}
|
|
1176
|
+
deserializeRecord(record) {
|
|
1177
|
+
const r = record;
|
|
1178
|
+
const fields = r.fields;
|
|
1179
|
+
const admin = r.admin;
|
|
1180
|
+
const tableName = r.table_name || r.tableName;
|
|
1181
|
+
const configPath = r.config_path || r.configPath;
|
|
1182
|
+
const schemaHash = r.schema_hash || r.schemaHash;
|
|
1183
|
+
const schemaVersion = r.schema_version || r.schemaVersion;
|
|
1184
|
+
const migrationStatus = r.migration_status || r.migrationStatus;
|
|
1185
|
+
const lastMigrationId = r.last_migration_id || r.lastMigrationId;
|
|
1186
|
+
const createdBy = r.created_by || r.createdBy;
|
|
1187
|
+
const createdAt = r.created_at || r.createdAt;
|
|
1188
|
+
const updatedAt = r.updated_at || r.updatedAt;
|
|
1189
|
+
return {
|
|
1190
|
+
id: r.id,
|
|
1191
|
+
slug: r.slug,
|
|
1192
|
+
label: r.label,
|
|
1193
|
+
tableName,
|
|
1194
|
+
description: r.description,
|
|
1195
|
+
fields: typeof fields === "string" ? JSON.parse(fields) : fields,
|
|
1196
|
+
admin: admin ? typeof admin === "string" ? JSON.parse(admin) : admin : void 0,
|
|
1197
|
+
source: r.source,
|
|
1198
|
+
locked: Boolean(r.locked),
|
|
1199
|
+
configPath,
|
|
1200
|
+
schemaHash,
|
|
1201
|
+
schemaVersion,
|
|
1202
|
+
migrationStatus,
|
|
1203
|
+
lastMigrationId,
|
|
1204
|
+
createdBy,
|
|
1205
|
+
createdAt: this.normalizeDbTimestamp(createdAt),
|
|
1206
|
+
updatedAt: this.normalizeDbTimestamp(updatedAt)
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
|
|
1211
|
+
// src/domains/singles/services/single-registry-service.ts
|
|
1212
|
+
var SingleRegistryService = class extends BaseRegistryService {
|
|
1213
|
+
registryTableName = "dynamic_singles";
|
|
1214
|
+
resourceType = "Single";
|
|
1215
|
+
tableNamePrefix = "single_";
|
|
1216
|
+
/** Optional PermissionSeedService for auto-permission management. */
|
|
1217
|
+
permissionSeedService;
|
|
1218
|
+
constructor(adapter, logger) {
|
|
1219
|
+
super(adapter, logger);
|
|
1220
|
+
}
|
|
1221
|
+
getSearchColumns() {
|
|
1222
|
+
return ["slug", "label"];
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Set the PermissionSeedService for auto-seeding permissions on single changes.
|
|
1226
|
+
* Called from DI registration after both services are constructed.
|
|
1227
|
+
*/
|
|
1228
|
+
setPermissionSeedService(service) {
|
|
1229
|
+
this.permissionSeedService = service;
|
|
1230
|
+
}
|
|
1231
|
+
// ============================================================
|
|
1232
|
+
// Public API — Delegates to BaseRegistryService
|
|
1233
|
+
// ============================================================
|
|
1234
|
+
async getSingleBySlug(slug) {
|
|
1235
|
+
return this.getRecordBySlug(slug);
|
|
1236
|
+
}
|
|
1237
|
+
async getSingle(slug) {
|
|
1238
|
+
return this.getRecordOrThrow(slug);
|
|
1239
|
+
}
|
|
1240
|
+
async getAllSingles(options) {
|
|
1241
|
+
return this.getAllRecords(options);
|
|
1242
|
+
}
|
|
1243
|
+
async listSingles(options) {
|
|
1244
|
+
return this.listRecords(options);
|
|
1245
|
+
}
|
|
1246
|
+
async isLocked(slug) {
|
|
1247
|
+
return this.checkIsLocked(slug);
|
|
1248
|
+
}
|
|
1249
|
+
async updateMigrationStatus(slug, status, migrationId) {
|
|
1250
|
+
return this.updateRecordMigrationStatus(slug, status, migrationId);
|
|
1251
|
+
}
|
|
1252
|
+
async updateMigrationStatusWithVerification(slug, tableName) {
|
|
1253
|
+
return this.updateMigrationStatusWithTableVerification(slug, tableName);
|
|
1254
|
+
}
|
|
1255
|
+
async getPendingMigrations() {
|
|
1256
|
+
return this.getRecordsWithPendingMigrations();
|
|
1257
|
+
}
|
|
1258
|
+
// ============================================================
|
|
1259
|
+
// Single-Specific: Registration
|
|
1260
|
+
// ============================================================
|
|
1261
|
+
/**
|
|
1262
|
+
* Register a new Single in the registry.
|
|
1263
|
+
*
|
|
1264
|
+
* @throws NextlyError(DUPLICATE) if a Single with the same slug already exists.
|
|
1265
|
+
* @throws NextlyError(DATABASE_ERROR) on insert failure.
|
|
1266
|
+
*/
|
|
1267
|
+
async registerSingle(data) {
|
|
1268
|
+
this.logger.debug("Registering Single", { slug: data.slug });
|
|
1269
|
+
await assertGlobalResourceSlugAvailable(this.adapter, data.slug);
|
|
1270
|
+
const existing = await this.getSingleBySlug(data.slug);
|
|
1271
|
+
if (existing) {
|
|
1272
|
+
throw NextlyError.duplicate({
|
|
1273
|
+
logContext: { reason: "single-slug-conflict", slug: data.slug }
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
const fieldsJson = JSON.stringify(data.fields);
|
|
1277
|
+
const schemaHash = data.schemaHash ?? this.computeSimpleHash(fieldsJson);
|
|
1278
|
+
const record = this.buildInsertRecord(data, fieldsJson, schemaHash);
|
|
1279
|
+
try {
|
|
1280
|
+
const result = await this.adapter.insert(
|
|
1281
|
+
this.registryTableName,
|
|
1282
|
+
record,
|
|
1283
|
+
{ returning: "*" }
|
|
1284
|
+
);
|
|
1285
|
+
this.logger.info("Single registered", {
|
|
1286
|
+
slug: data.slug,
|
|
1287
|
+
source: data.source
|
|
1288
|
+
});
|
|
1289
|
+
await this.seedPermissionsForSingle(data.slug);
|
|
1290
|
+
return this.deserializeRecord(result);
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Register a Single within a transaction.
|
|
1297
|
+
*/
|
|
1298
|
+
async registerSingleInTransaction(tx, data) {
|
|
1299
|
+
await assertGlobalResourceSlugAvailable(this.adapter, data.slug);
|
|
1300
|
+
const existing = await tx.selectOne(
|
|
1301
|
+
this.registryTableName,
|
|
1302
|
+
{
|
|
1303
|
+
where: this.whereEq("slug", data.slug)
|
|
1304
|
+
}
|
|
1305
|
+
);
|
|
1306
|
+
if (existing) {
|
|
1307
|
+
throw NextlyError.duplicate({
|
|
1308
|
+
logContext: { reason: "single-slug-conflict", slug: data.slug }
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
const fieldsJson = JSON.stringify(data.fields);
|
|
1312
|
+
const record = this.buildInsertRecord(
|
|
1313
|
+
data,
|
|
1314
|
+
fieldsJson,
|
|
1315
|
+
data.schemaHash ?? this.computeSimpleHash(fieldsJson)
|
|
1316
|
+
);
|
|
1317
|
+
const result = await tx.insert(
|
|
1318
|
+
this.registryTableName,
|
|
1319
|
+
record,
|
|
1320
|
+
{ returning: "*" }
|
|
1321
|
+
);
|
|
1322
|
+
return this.deserializeRecord(result);
|
|
1323
|
+
}
|
|
1324
|
+
// ============================================================
|
|
1325
|
+
// Single-Specific: Update
|
|
1326
|
+
// ============================================================
|
|
1327
|
+
/**
|
|
1328
|
+
* Update a Single's metadata.
|
|
1329
|
+
*
|
|
1330
|
+
* @throws NextlyError(NOT_FOUND) when no Single matches the slug.
|
|
1331
|
+
* @throws NextlyError(FORBIDDEN) when the Single is locked and the source isn't "code".
|
|
1332
|
+
*/
|
|
1333
|
+
async updateSingle(slug, data, options) {
|
|
1334
|
+
this.logger.debug("Updating Single", { slug });
|
|
1335
|
+
const existing = await this.getSingle(slug);
|
|
1336
|
+
const targetSlug = data.slug ?? slug;
|
|
1337
|
+
await assertGlobalResourceSlugAvailable(this.adapter, targetSlug, {
|
|
1338
|
+
currentResourceType: "single",
|
|
1339
|
+
currentResourceId: existing.id
|
|
1340
|
+
});
|
|
1341
|
+
if (existing.locked && options?.source !== "code") {
|
|
1342
|
+
throw NextlyError.forbidden({
|
|
1343
|
+
logContext: {
|
|
1344
|
+
reason: "single-locked",
|
|
1345
|
+
slug,
|
|
1346
|
+
source: options?.source ?? "UI"
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
const updateData = {
|
|
1351
|
+
updated_at: /* @__PURE__ */ new Date()
|
|
1352
|
+
};
|
|
1353
|
+
if (data.label !== void 0) {
|
|
1354
|
+
updateData.label = data.label;
|
|
1355
|
+
}
|
|
1356
|
+
if (data.description !== void 0) {
|
|
1357
|
+
updateData.description = data.description;
|
|
1358
|
+
}
|
|
1359
|
+
if (data.fields) {
|
|
1360
|
+
updateData.fields = JSON.stringify(data.fields);
|
|
1361
|
+
updateData.schema_version = existing.schemaVersion + 1;
|
|
1362
|
+
updateData.migration_status = data.migrationStatus || "pending";
|
|
1363
|
+
}
|
|
1364
|
+
if (data.admin !== void 0) {
|
|
1365
|
+
updateData.admin = data.admin ? JSON.stringify(data.admin) : null;
|
|
1366
|
+
}
|
|
1367
|
+
if (data.accessRules !== void 0) {
|
|
1368
|
+
updateData.access_rules = data.accessRules ? JSON.stringify(data.accessRules) : null;
|
|
1369
|
+
}
|
|
1370
|
+
if (data.schemaHash) {
|
|
1371
|
+
updateData.schema_hash = data.schemaHash;
|
|
1372
|
+
}
|
|
1373
|
+
if (data.locked !== void 0) {
|
|
1374
|
+
updateData.locked = data.locked ? 1 : 0;
|
|
1375
|
+
}
|
|
1376
|
+
if (data.configPath !== void 0) {
|
|
1377
|
+
updateData.config_path = data.configPath;
|
|
1378
|
+
}
|
|
1379
|
+
if (data.status !== void 0) {
|
|
1380
|
+
updateData.status = data.status === true ? 1 : 0;
|
|
1381
|
+
}
|
|
1382
|
+
try {
|
|
1383
|
+
const results = await this.adapter.update(
|
|
1384
|
+
this.registryTableName,
|
|
1385
|
+
updateData,
|
|
1386
|
+
this.whereEq("slug", slug),
|
|
1387
|
+
{ returning: "*" }
|
|
1388
|
+
);
|
|
1389
|
+
if (results.length === 0) {
|
|
1390
|
+
throw NextlyError.notFound({ logContext: { slug } });
|
|
1391
|
+
}
|
|
1392
|
+
this.logger.info("Single updated", { slug });
|
|
1393
|
+
return this.deserializeRecord(results[0]);
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
if (NextlyError.is(error)) {
|
|
1396
|
+
throw error;
|
|
1397
|
+
}
|
|
1398
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
// ============================================================
|
|
1402
|
+
// Single-Specific: Delete
|
|
1403
|
+
// ============================================================
|
|
1404
|
+
/**
|
|
1405
|
+
* Delete a Single from the registry.
|
|
1406
|
+
*
|
|
1407
|
+
* Singles represent persistent site-wide config, so deletion requires
|
|
1408
|
+
* `force: true`. Use only for admin/CLI operations to clean up orphans.
|
|
1409
|
+
*/
|
|
1410
|
+
async deleteSingle(slug, options) {
|
|
1411
|
+
this.logger.debug("Deleting Single", { slug, force: options?.force });
|
|
1412
|
+
const existing = await this.getSingle(slug);
|
|
1413
|
+
if (!options?.force) {
|
|
1414
|
+
throw NextlyError.forbidden({
|
|
1415
|
+
logContext: {
|
|
1416
|
+
reason: "single-requires-force-delete",
|
|
1417
|
+
slug,
|
|
1418
|
+
hint: "Singles represent persistent site-wide configuration. Pass { force: true } for admin/CLI cleanup."
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
if (existing.locked) {
|
|
1423
|
+
this.logger.warn("Force deleting locked Single", {
|
|
1424
|
+
slug,
|
|
1425
|
+
source: existing.source
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
try {
|
|
1429
|
+
const count = await this.adapter.delete(
|
|
1430
|
+
this.registryTableName,
|
|
1431
|
+
this.whereEq("slug", slug)
|
|
1432
|
+
);
|
|
1433
|
+
if (count === 0) {
|
|
1434
|
+
throw NextlyError.notFound({ logContext: { slug } });
|
|
1435
|
+
}
|
|
1436
|
+
this.logger.info("Single deleted", { slug, force: true });
|
|
1437
|
+
if (this.permissionSeedService) {
|
|
1438
|
+
try {
|
|
1439
|
+
const permissionResult = await this.permissionSeedService.deletePermissionsForResource(slug);
|
|
1440
|
+
if (permissionResult.created > 0) {
|
|
1441
|
+
this.logger.info(
|
|
1442
|
+
`Deleted ${permissionResult.created} permission(s) for single "${slug}"`
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
if (permissionResult.skipped > 0) {
|
|
1446
|
+
this.logger.warn(
|
|
1447
|
+
`${permissionResult.skipped} permission(s) for "${slug}" could not be deleted (may be assigned to roles)`
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
this.logger.warn(
|
|
1452
|
+
`Failed to cleanup permissions for single "${slug}": ${error instanceof Error ? error.message : String(error)}`
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
if (NextlyError.is(error)) {
|
|
1458
|
+
throw error;
|
|
1459
|
+
}
|
|
1460
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
// ============================================================
|
|
1464
|
+
// Single-Specific: Code-First Sync
|
|
1465
|
+
// ============================================================
|
|
1466
|
+
/**
|
|
1467
|
+
* Sync code-first Singles with the registry.
|
|
1468
|
+
*
|
|
1469
|
+
* Compares schema hashes to detect changes and creates/updates
|
|
1470
|
+
* Singles as needed. Typically called during application startup.
|
|
1471
|
+
*/
|
|
1472
|
+
async syncCodeFirstSingles(configs) {
|
|
1473
|
+
this.logger.info("Syncing code-first Singles", {
|
|
1474
|
+
count: configs.length
|
|
1475
|
+
});
|
|
1476
|
+
const result = {
|
|
1477
|
+
created: [],
|
|
1478
|
+
updated: [],
|
|
1479
|
+
unchanged: [],
|
|
1480
|
+
errors: []
|
|
1481
|
+
};
|
|
1482
|
+
for (const config of configs) {
|
|
1483
|
+
try {
|
|
1484
|
+
const existing = await this.getSingleBySlug(config.slug);
|
|
1485
|
+
const schemaHash = calculateSchemaHash(config.fields);
|
|
1486
|
+
if (!existing) {
|
|
1487
|
+
await this.registerSingle({
|
|
1488
|
+
slug: config.slug,
|
|
1489
|
+
label: config.label,
|
|
1490
|
+
// Route through the canonical resolver so registry and DDL paths
|
|
1491
|
+
// never disagree on the single's physical table name, even when
|
|
1492
|
+
// an explicit dbName/tableName is provided without the prefix.
|
|
1493
|
+
tableName: resolveSingleTableName({
|
|
1494
|
+
slug: config.slug,
|
|
1495
|
+
dbName: config.tableName
|
|
1496
|
+
}),
|
|
1497
|
+
description: config.description,
|
|
1498
|
+
fields: config.fields,
|
|
1499
|
+
admin: config.admin,
|
|
1500
|
+
source: "code",
|
|
1501
|
+
locked: true,
|
|
1502
|
+
// Forward Draft/Published flag so code-first Singles that opt
|
|
1503
|
+
// in actually write the column on first sync.
|
|
1504
|
+
status: config.status === true,
|
|
1505
|
+
configPath: config.configPath,
|
|
1506
|
+
schemaHash
|
|
1507
|
+
});
|
|
1508
|
+
result.created.push(config.slug);
|
|
1509
|
+
await this.seedPermissionsForSingle(config.slug);
|
|
1510
|
+
} else if (!schemaHashesMatch(schemaHash, existing.schemaHash) || config.status === true !== (existing.status === true)) {
|
|
1511
|
+
await this.updateSingle(
|
|
1512
|
+
config.slug,
|
|
1513
|
+
{
|
|
1514
|
+
label: config.label,
|
|
1515
|
+
description: config.description,
|
|
1516
|
+
fields: config.fields,
|
|
1517
|
+
admin: config.admin,
|
|
1518
|
+
configPath: config.configPath,
|
|
1519
|
+
schemaHash,
|
|
1520
|
+
locked: true,
|
|
1521
|
+
status: config.status === true
|
|
1522
|
+
},
|
|
1523
|
+
{ source: "code" }
|
|
1524
|
+
);
|
|
1525
|
+
result.updated.push(config.slug);
|
|
1526
|
+
await this.seedPermissionsForSingle(config.slug);
|
|
1527
|
+
} else {
|
|
1528
|
+
await this.seedPermissionsForSingle(config.slug);
|
|
1529
|
+
result.unchanged.push(config.slug);
|
|
1530
|
+
}
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
const handled = await this.handleSyncError(config, error);
|
|
1533
|
+
if (handled.status === "unchanged") {
|
|
1534
|
+
result.unchanged.push(config.slug);
|
|
1535
|
+
} else if (handled.status === "created") {
|
|
1536
|
+
result.created.push(config.slug);
|
|
1537
|
+
} else {
|
|
1538
|
+
result.errors.push({ slug: config.slug, error: handled.error });
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
this.logger.info("Code-first Single sync completed", {
|
|
1543
|
+
created: result.created.length,
|
|
1544
|
+
updated: result.updated.length,
|
|
1545
|
+
unchanged: result.unchanged.length,
|
|
1546
|
+
errors: result.errors.length
|
|
1547
|
+
});
|
|
1548
|
+
return result;
|
|
1549
|
+
}
|
|
1550
|
+
// ============================================================
|
|
1551
|
+
// Abstract Implementation
|
|
1552
|
+
// ============================================================
|
|
1553
|
+
/**
|
|
1554
|
+
* Deserialize a raw DB row into a typed {@link DynamicSingleRecord}.
|
|
1555
|
+
*
|
|
1556
|
+
* Handles snake_case-to-camelCase normalization and JSON column parsing
|
|
1557
|
+
* so callers always receive the canonical record shape regardless of
|
|
1558
|
+
* which adapter returned it.
|
|
1559
|
+
*/
|
|
1560
|
+
deserializeRecord(record) {
|
|
1561
|
+
const r = record;
|
|
1562
|
+
const fields = r.fields;
|
|
1563
|
+
const admin = r.admin;
|
|
1564
|
+
const accessRules = r.access_rules ?? r.accessRules;
|
|
1565
|
+
const tableName = r.table_name ?? r.tableName;
|
|
1566
|
+
const configPath = r.config_path ?? r.configPath;
|
|
1567
|
+
const schemaHash = r.schema_hash ?? r.schemaHash;
|
|
1568
|
+
const schemaVersion = r.schema_version ?? r.schemaVersion;
|
|
1569
|
+
const migrationStatus = r.migration_status ?? r.migrationStatus;
|
|
1570
|
+
const lastMigrationId = r.last_migration_id ?? r.lastMigrationId;
|
|
1571
|
+
const createdBy = r.created_by ?? r.createdBy;
|
|
1572
|
+
const createdAt = r.created_at ?? r.createdAt;
|
|
1573
|
+
const updatedAt = r.updated_at ?? r.updatedAt;
|
|
1574
|
+
return {
|
|
1575
|
+
id: r.id,
|
|
1576
|
+
slug: r.slug,
|
|
1577
|
+
label: r.label,
|
|
1578
|
+
tableName,
|
|
1579
|
+
description: r.description,
|
|
1580
|
+
fields: typeof fields === "string" ? JSON.parse(fields) : fields,
|
|
1581
|
+
admin: admin ? typeof admin === "string" ? JSON.parse(admin) : admin : void 0,
|
|
1582
|
+
accessRules: accessRules ? typeof accessRules === "string" ? JSON.parse(accessRules) : accessRules : void 0,
|
|
1583
|
+
source: r.source,
|
|
1584
|
+
locked: Boolean(r.locked),
|
|
1585
|
+
// Why: read the new status meta-column, defaulting to false for legacy
|
|
1586
|
+
// rows written before the column existed.
|
|
1587
|
+
status: Boolean(r.status),
|
|
1588
|
+
configPath,
|
|
1589
|
+
schemaHash,
|
|
1590
|
+
schemaVersion,
|
|
1591
|
+
migrationStatus,
|
|
1592
|
+
lastMigrationId,
|
|
1593
|
+
createdBy,
|
|
1594
|
+
createdAt: this.normalizeDbTimestamp(createdAt),
|
|
1595
|
+
updatedAt: this.normalizeDbTimestamp(updatedAt)
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
// ============================================================
|
|
1599
|
+
// Private Helpers
|
|
1600
|
+
// ============================================================
|
|
1601
|
+
/**
|
|
1602
|
+
* Seed read/update permissions for a single and assign to super_admin.
|
|
1603
|
+
* Non-blocking — errors are logged but do not fail the parent operation.
|
|
1604
|
+
*/
|
|
1605
|
+
async seedPermissionsForSingle(slug) {
|
|
1606
|
+
if (!this.permissionSeedService) return;
|
|
1607
|
+
try {
|
|
1608
|
+
const result = await this.permissionSeedService.seedSinglePermissions(slug);
|
|
1609
|
+
if (result.newPermissionIds.length > 0) {
|
|
1610
|
+
await this.permissionSeedService.assignNewPermissionsToSuperAdmin(
|
|
1611
|
+
result.newPermissionIds
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
if (result.created > 0) {
|
|
1615
|
+
this.logger.info(
|
|
1616
|
+
`Permissions seeded for single "${slug}": ${result.created} created, ${result.skipped} already existed`
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
this.logger.warn(
|
|
1621
|
+
`Failed to seed permissions for single "${slug}": ${error instanceof Error ? error.message : String(error)}`
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Build the common insert record shape for registerSingle and
|
|
1627
|
+
* registerSingleInTransaction. Extracted so both flows stay in sync.
|
|
1628
|
+
*/
|
|
1629
|
+
buildInsertRecord(data, fieldsJson, schemaHash) {
|
|
1630
|
+
const now = /* @__PURE__ */ new Date();
|
|
1631
|
+
const tableName = resolveSingleTableName({
|
|
1632
|
+
slug: data.slug,
|
|
1633
|
+
dbName: data.tableName
|
|
1634
|
+
});
|
|
1635
|
+
return {
|
|
1636
|
+
id: this.generateId(),
|
|
1637
|
+
slug: data.slug,
|
|
1638
|
+
label: data.label,
|
|
1639
|
+
table_name: tableName,
|
|
1640
|
+
description: data.description,
|
|
1641
|
+
fields: fieldsJson,
|
|
1642
|
+
admin: data.admin ? JSON.stringify(data.admin) : null,
|
|
1643
|
+
access_rules: data.accessRules ? JSON.stringify(data.accessRules) : null,
|
|
1644
|
+
source: data.source,
|
|
1645
|
+
locked: data.locked ?? data.source === "code" ? 1 : 0,
|
|
1646
|
+
// Persist Draft/Published flag so the Singles edit form shows the
|
|
1647
|
+
// Save Draft / Publish split when the schema opts in.
|
|
1648
|
+
status: data.status === true ? 1 : 0,
|
|
1649
|
+
config_path: data.configPath,
|
|
1650
|
+
schema_hash: schemaHash,
|
|
1651
|
+
schema_version: data.schemaVersion ?? 1,
|
|
1652
|
+
migration_status: data.migrationStatus ?? "pending",
|
|
1653
|
+
last_migration_id: data.lastMigrationId,
|
|
1654
|
+
created_by: data.createdBy,
|
|
1655
|
+
created_at: now,
|
|
1656
|
+
updated_at: now
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Handle an error thrown during code-first sync. Disambiguates
|
|
1661
|
+
* duplicate-key errors (which are recoverable) from hard failures.
|
|
1662
|
+
*/
|
|
1663
|
+
async handleSyncError(config, error) {
|
|
1664
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1665
|
+
const isDuplicate = message.toLowerCase().includes("already exists") || message.toLowerCase().includes("duplicate") || message.toLowerCase().includes("unique constraint");
|
|
1666
|
+
if (!isDuplicate) {
|
|
1667
|
+
return { status: "error", error: message };
|
|
1668
|
+
}
|
|
1669
|
+
const refetched = await this.getSingleBySlug(config.slug).catch(() => null);
|
|
1670
|
+
if (refetched) {
|
|
1671
|
+
this.logger.warn(
|
|
1672
|
+
`Code-first sync: "${config.slug}" already in DB \u2014 treating as unchanged`,
|
|
1673
|
+
{ slug: config.slug }
|
|
1674
|
+
);
|
|
1675
|
+
return { status: "unchanged" };
|
|
1676
|
+
}
|
|
1677
|
+
return {
|
|
1678
|
+
status: "error",
|
|
1679
|
+
error: `Single "${config.slug}" has a table_name conflict in the registry and the expected row could not be refetched: ${message}`
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
|
|
1684
|
+
export {
|
|
1685
|
+
PermissionSeedService,
|
|
1686
|
+
ComponentRegistryService,
|
|
1687
|
+
SingleRegistryService
|
|
1688
|
+
};
|