nextly 0.0.1 → 0.0.2-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +122 -0
  3. package/dist/_dts-chunks/collections-handler.d-DjgO74Wt.d.ts +20540 -0
  4. package/dist/_dts-chunks/config.d-DNwsDnjs.d.ts +2589 -0
  5. package/dist/_dts-chunks/define-component.d-BUgTHmt3.d.ts +1149 -0
  6. package/dist/_dts-chunks/image-processor.d-OO1PmMrv.d.ts +335 -0
  7. package/dist/_dts-chunks/index.d-axCAzZ7m.d.ts +17842 -0
  8. package/dist/_dts-chunks/media.d-DjDOZo4B.d.ts +117 -0
  9. package/dist/_dts-chunks/on-error.d-CHIKWNxd.d.ts +38 -0
  10. package/dist/_dts-chunks/storage.d-BUhQ2we_.d.ts +404 -0
  11. package/dist/actions/index.d.ts +239 -0
  12. package/dist/actions/index.mjs +281 -0
  13. package/dist/api/auth-state.d.ts +5 -0
  14. package/dist/api/auth-state.mjs +131 -0
  15. package/dist/api/collections-schema-detail.d.ts +56 -0
  16. package/dist/api/collections-schema-detail.mjs +244 -0
  17. package/dist/api/collections-schema-export.d.ts +56 -0
  18. package/dist/api/collections-schema-export.mjs +129 -0
  19. package/dist/api/collections-schema.d.ts +59 -0
  20. package/dist/api/collections-schema.mjs +207 -0
  21. package/dist/api/components-detail.d.ts +50 -0
  22. package/dist/api/components-detail.mjs +132 -0
  23. package/dist/api/components.d.ts +69 -0
  24. package/dist/api/components.mjs +144 -0
  25. package/dist/api/email-providers-default.d.ts +40 -0
  26. package/dist/api/email-providers-default.mjs +75 -0
  27. package/dist/api/email-providers-detail.d.ts +81 -0
  28. package/dist/api/email-providers-detail.mjs +109 -0
  29. package/dist/api/email-providers-test.d.ts +43 -0
  30. package/dist/api/email-providers-test.mjs +114 -0
  31. package/dist/api/email-providers.d.ts +69 -0
  32. package/dist/api/email-providers.mjs +110 -0
  33. package/dist/api/email-send-template.d.ts +41 -0
  34. package/dist/api/email-send-template.mjs +58 -0
  35. package/dist/api/email-send.d.ts +42 -0
  36. package/dist/api/email-send.mjs +58 -0
  37. package/dist/api/email-templates-detail.d.ts +74 -0
  38. package/dist/api/email-templates-detail.mjs +112 -0
  39. package/dist/api/email-templates-layout.d.ts +55 -0
  40. package/dist/api/email-templates-layout.mjs +92 -0
  41. package/dist/api/email-templates-preview.d.ts +48 -0
  42. package/dist/api/email-templates-preview.mjs +93 -0
  43. package/dist/api/email-templates.d.ts +61 -0
  44. package/dist/api/email-templates.mjs +118 -0
  45. package/dist/api/health.d.ts +68 -0
  46. package/dist/api/health.mjs +67 -0
  47. package/dist/api/index.d.ts +54 -0
  48. package/dist/api/index.mjs +16 -0
  49. package/dist/api/media-bulk.d.ts +74 -0
  50. package/dist/api/media-bulk.mjs +196 -0
  51. package/dist/api/media-folders.d.ts +112 -0
  52. package/dist/api/media-folders.mjs +187 -0
  53. package/dist/api/media-handlers.d.ts +102 -0
  54. package/dist/api/media-handlers.mjs +437 -0
  55. package/dist/api/media.d.ts +117 -0
  56. package/dist/api/media.mjs +242 -0
  57. package/dist/api/singles-detail.d.ts +87 -0
  58. package/dist/api/singles-detail.mjs +170 -0
  59. package/dist/api/singles-schema-detail.d.ts +54 -0
  60. package/dist/api/singles-schema-detail.mjs +182 -0
  61. package/dist/api/singles.d.ts +34 -0
  62. package/dist/api/singles.mjs +94 -0
  63. package/dist/api/storage-upload-url.d.ts +48 -0
  64. package/dist/api/storage-upload-url.mjs +202 -0
  65. package/dist/api/uploads.d.ts +109 -0
  66. package/dist/api/uploads.mjs +359 -0
  67. package/dist/auth/index.d.ts +425 -0
  68. package/dist/auth/index.mjs +199 -0
  69. package/dist/boot-apply-PQSYLDIN.mjs +7 -0
  70. package/dist/chunk-2OALJTK6.mjs +489 -0
  71. package/dist/chunk-2Q2SX2CS.mjs +365 -0
  72. package/dist/chunk-2TFX4ND3.mjs +13 -0
  73. package/dist/chunk-2TWPDSYD.mjs +87 -0
  74. package/dist/chunk-2W3DVD7S.mjs +647 -0
  75. package/dist/chunk-2ZFKXPQM.mjs +88 -0
  76. package/dist/chunk-3FA7FKAV.mjs +832 -0
  77. package/dist/chunk-3NZ2KMBL.mjs +58 -0
  78. package/dist/chunk-4MJLT6PZ.mjs +0 -0
  79. package/dist/chunk-56WO4WX7.mjs +0 -0
  80. package/dist/chunk-5APFUGAD.mjs +89 -0
  81. package/dist/chunk-5HMZ644B.mjs +108 -0
  82. package/dist/chunk-67GXH6PR.mjs +32 -0
  83. package/dist/chunk-6JNEPWRW.mjs +14368 -0
  84. package/dist/chunk-6NFHQIJD.mjs +45 -0
  85. package/dist/chunk-7P6ASYW6.mjs +9 -0
  86. package/dist/chunk-A3WPLSDT.mjs +1364 -0
  87. package/dist/chunk-AGJ6F2T3.mjs +144 -0
  88. package/dist/chunk-AK6Z23OX.mjs +1464 -0
  89. package/dist/chunk-APKKRD2G.mjs +102 -0
  90. package/dist/chunk-B2GV2BWH.mjs +73 -0
  91. package/dist/chunk-D5HQBNUB.mjs +74 -0
  92. package/dist/chunk-DNNG377Z.mjs +204 -0
  93. package/dist/chunk-DP3G27G5.mjs +135 -0
  94. package/dist/chunk-DV6WVX2Q.mjs +0 -0
  95. package/dist/chunk-DXGGXIUZ.mjs +57 -0
  96. package/dist/chunk-EGXBZCGC.mjs +943 -0
  97. package/dist/chunk-ERCNLX3V.mjs +176 -0
  98. package/dist/chunk-FQULBZ53.mjs +850 -0
  99. package/dist/chunk-G2AA4QLC.mjs +262 -0
  100. package/dist/chunk-GDBJ5JCU.mjs +488 -0
  101. package/dist/chunk-GJNSJU4S.mjs +19 -0
  102. package/dist/chunk-GZ6DCQKC.mjs +69 -0
  103. package/dist/chunk-H26B4FYG.mjs +167 -0
  104. package/dist/chunk-I4JMR3UR.mjs +21 -0
  105. package/dist/chunk-INV7QKLG.mjs +508 -0
  106. package/dist/chunk-IUDOC7N7.mjs +46 -0
  107. package/dist/chunk-IZWPRDC3.mjs +206 -0
  108. package/dist/chunk-KIMNCZGV.mjs +15 -0
  109. package/dist/chunk-L6HW2DA7.mjs +15 -0
  110. package/dist/chunk-LAZXX4HR.mjs +100 -0
  111. package/dist/chunk-LDKCUMHK.mjs +95 -0
  112. package/dist/chunk-LRXMECUA.mjs +0 -0
  113. package/dist/chunk-M52VMPGA.mjs +119 -0
  114. package/dist/chunk-MGUWEEI6.mjs +160 -0
  115. package/dist/chunk-NRUWQ5Z7.mjs +419 -0
  116. package/dist/chunk-NSEFNNU4.mjs +25360 -0
  117. package/dist/chunk-NTHVDFGO.mjs +138 -0
  118. package/dist/chunk-O3QHXMOX.mjs +3166 -0
  119. package/dist/chunk-P7NH2OSC.mjs +2605 -0
  120. package/dist/chunk-PKMABBB5.mjs +184 -0
  121. package/dist/chunk-PWS6XGJK.mjs +76 -0
  122. package/dist/chunk-R6JJQHFC.mjs +20 -0
  123. package/dist/chunk-RJLLGGPG.mjs +0 -0
  124. package/dist/chunk-SBACDPNX.mjs +689 -0
  125. package/dist/chunk-TO5AFLVQ.mjs +124 -0
  126. package/dist/chunk-TS7GHTG2.mjs +5436 -0
  127. package/dist/chunk-UJ2IMJ4W.mjs +133 -0
  128. package/dist/chunk-UOP63Q54.mjs +102 -0
  129. package/dist/chunk-UUOFWCM6.mjs +78 -0
  130. package/dist/chunk-V4EQTOA4.mjs +893 -0
  131. package/dist/chunk-VJ66NCL4.mjs +193 -0
  132. package/dist/chunk-VQJQHVEV.mjs +29 -0
  133. package/dist/chunk-VTJADRO3.mjs +141 -0
  134. package/dist/chunk-VWF3JO32.mjs +0 -0
  135. package/dist/chunk-W4MGXIRR.mjs +27 -0
  136. package/dist/chunk-W5KKPZT5.mjs +1204 -0
  137. package/dist/chunk-WD34YQ6T.mjs +381 -0
  138. package/dist/chunk-WZBYMYVW.mjs +14 -0
  139. package/dist/chunk-X23WKS3Z.mjs +50 -0
  140. package/dist/chunk-X7TXCYYN.mjs +6496 -0
  141. package/dist/chunk-XGI4EMS3.mjs +140 -0
  142. package/dist/chunk-XZKLBMN6.mjs +1153 -0
  143. package/dist/chunk-YB7INWPY.mjs +0 -0
  144. package/dist/chunk-YV4Y7SDL.mjs +83 -0
  145. package/dist/chunk-YZNBLFIW.mjs +1688 -0
  146. package/dist/chunk-YZZCTONM.mjs +263 -0
  147. package/dist/chunk-ZE6A3FYH.mjs +289 -0
  148. package/dist/cli/nextly.mjs +68 -0
  149. package/dist/cli/utils/index.d.ts +449 -0
  150. package/dist/cli/utils/index.mjs +49 -0
  151. package/dist/component-schema-service-5577KVW6.mjs +11 -0
  152. package/dist/config-loader-23YEMC3Z.mjs +23 -0
  153. package/dist/config.d.ts +44 -0
  154. package/dist/config.mjs +109 -0
  155. package/dist/container-ORGFGYSZ.mjs +9 -0
  156. package/dist/database/index.d.ts +12 -0
  157. package/dist/database/index.mjs +40 -0
  158. package/dist/database/seeders/index.d.ts +93 -0
  159. package/dist/database/seeders/index.mjs +47 -0
  160. package/dist/db-sync-demote-LJGKLB3S.mjs +117 -0
  161. package/dist/db-sync-promote-B26VSYQF.mjs +113 -0
  162. package/dist/dev-reload-broadcaster-B73IQ53V.mjs +25 -0
  163. package/dist/dist-M2NOU37V.mjs +19 -0
  164. package/dist/drizzle-kit-lazy-D2M2PXR2.mjs +13 -0
  165. package/dist/dynamic-collection-schema-service-IEXTPIZ7.mjs +8 -0
  166. package/dist/errors/index.d.ts +159 -0
  167. package/dist/errors/index.mjs +10 -0
  168. package/dist/factory-IWMBKUJM.mjs +15 -0
  169. package/dist/first-run-QIVKWJIF.mjs +63 -0
  170. package/dist/fresh-push-NR67DC3R.mjs +8 -0
  171. package/dist/index.d.ts +4175 -0
  172. package/dist/index.mjs +1336 -0
  173. package/dist/local-plugin-PTET4NAT.mjs +7 -0
  174. package/dist/logger-NU46DXNY.mjs +15 -0
  175. package/dist/logger-YE4TC7ZN.mjs +9 -0
  176. package/dist/migration-journal-EP532Y4L.mjs +139 -0
  177. package/dist/migrations/mysql/0000_eager_sentry.sql +174 -0
  178. package/dist/migrations/mysql/0001_soft_giant_girl.sql +27 -0
  179. package/dist/migrations/mysql/0002_media_table.sql +24 -0
  180. package/dist/migrations/mysql/0003_dynamic_singles.sql +37 -0
  181. package/dist/migrations/mysql/0004_dynamic_components.sql +35 -0
  182. package/dist/migrations/mysql/0005_user_management_tables.sql +92 -0
  183. package/dist/migrations/mysql/0006_api_keys.sql +36 -0
  184. package/dist/migrations/mysql/0007_general_settings.sql +20 -0
  185. package/dist/migrations/mysql/0008_site_settings_logo_url.sql +9 -0
  186. package/dist/migrations/mysql/0009_activity_log.sql +30 -0
  187. package/dist/migrations/mysql/0010_site_settings_sidebar.sql +13 -0
  188. package/dist/migrations/mysql/0011_missing_tables_and_columns.sql +54 -0
  189. package/dist/migrations/mysql/0012_image_sizes_and_focal_point.sql +30 -0
  190. package/dist/migrations/mysql/0012_media_folders.sql +43 -0
  191. package/dist/migrations/mysql/0013_user_brute_force_protection.sql +31 -0
  192. package/dist/migrations/mysql/0014_email_template_attachments.sql +12 -0
  193. package/dist/migrations/mysql/0015_media_uploaded_by_nullable.sql +15 -0
  194. package/dist/migrations/mysql/20260429_000000_000_initial_journal.sql +22 -0
  195. package/dist/migrations/mysql/20260501_000000_journal_batch.sql +17 -0
  196. package/dist/migrations/mysql/20260501_000001_audit_log.sql +24 -0
  197. package/dist/migrations/mysql/20260504_000000_nextly_meta.sql +21 -0
  198. package/dist/migrations/mysql/meta/0000_snapshot.json +1005 -0
  199. package/dist/migrations/mysql/meta/0001_snapshot.json +1099 -0
  200. package/dist/migrations/mysql/meta/_journal.json +41 -0
  201. package/dist/migrations/postgresql/0000_misty_king_bedlam.sql +169 -0
  202. package/dist/migrations/postgresql/0001_perpetual_captain_marvel.sql +8 -0
  203. package/dist/migrations/postgresql/0002_sad_spectrum.sql +16 -0
  204. package/dist/migrations/postgresql/0003_hesitant_ultron.sql +17 -0
  205. package/dist/migrations/postgresql/0004_media_table.sql +24 -0
  206. package/dist/migrations/postgresql/0005_media_folders.sql +36 -0
  207. package/dist/migrations/postgresql/0006_dynamic_collections_update.sql +50 -0
  208. package/dist/migrations/postgresql/0007_dynamic_singles.sql +38 -0
  209. package/dist/migrations/postgresql/0008_dynamic_components.sql +37 -0
  210. package/dist/migrations/postgresql/0009_user_management_tables.sql +95 -0
  211. package/dist/migrations/postgresql/0010_api_keys.sql +34 -0
  212. package/dist/migrations/postgresql/0011_general_settings.sql +20 -0
  213. package/dist/migrations/postgresql/0012_site_settings_logo_url.sql +9 -0
  214. package/dist/migrations/postgresql/0013_activity_log.sql +29 -0
  215. package/dist/migrations/postgresql/0014_image_sizes_and_focal_point.sql +33 -0
  216. package/dist/migrations/postgresql/0014_site_settings_sidebar.sql +13 -0
  217. package/dist/migrations/postgresql/0015_user_brute_force_protection.sql +29 -0
  218. package/dist/migrations/postgresql/0016_email_template_attachments.sql +12 -0
  219. package/dist/migrations/postgresql/0017_media_uploaded_by_nullable.sql +15 -0
  220. package/dist/migrations/postgresql/20260429_000000_000_initial_journal.sql +24 -0
  221. package/dist/migrations/postgresql/20260501_000000_journal_batch.sql +17 -0
  222. package/dist/migrations/postgresql/20260501_000001_audit_log.sql +24 -0
  223. package/dist/migrations/postgresql/20260504_000000_nextly_meta.sql +22 -0
  224. package/dist/migrations/postgresql/meta/0000_snapshot.json +1286 -0
  225. package/dist/migrations/postgresql/meta/0001_snapshot.json +1407 -0
  226. package/dist/migrations/postgresql/meta/0002_snapshot.json +1552 -0
  227. package/dist/migrations/postgresql/meta/0003_snapshot.json +1695 -0
  228. package/dist/migrations/postgresql/meta/0010_snapshot.json +2345 -0
  229. package/dist/migrations/postgresql/meta/_journal.json +90 -0
  230. package/dist/migrations/sqlite/0000_api_keys.sql +34 -0
  231. package/dist/migrations/sqlite/0001_general_settings.sql +20 -0
  232. package/dist/migrations/sqlite/0002_site_settings_logo_url.sql +9 -0
  233. package/dist/migrations/sqlite/0003_activity_log.sql +29 -0
  234. package/dist/migrations/sqlite/0004_image_sizes_and_focal_point.sql +29 -0
  235. package/dist/migrations/sqlite/0004_site_settings_sidebar.sql +11 -0
  236. package/dist/migrations/sqlite/0005_user_brute_force_protection.sql +29 -0
  237. package/dist/migrations/sqlite/0006_email_template_attachments.sql +12 -0
  238. package/dist/migrations/sqlite/0007_media_uploaded_by_nullable.sql +111 -0
  239. package/dist/migrations/sqlite/20260429_000000_000_initial_journal.sql +24 -0
  240. package/dist/migrations/sqlite/20260501_000000_journal_batch.sql +19 -0
  241. package/dist/migrations/sqlite/20260501_000001_audit_log.sql +24 -0
  242. package/dist/migrations/sqlite/20260504_000000_nextly_meta.sql +21 -0
  243. package/dist/migrations/sqlite/20260505_000000_user_management_tables.sql +77 -0
  244. package/dist/next.d.ts +57 -0
  245. package/dist/next.mjs +55 -0
  246. package/dist/observability/index.d.ts +87 -0
  247. package/dist/observability/index.mjs +57 -0
  248. package/dist/permissions-3DZZQZMI.mjs +39 -0
  249. package/dist/pipeline-YOML7SWF.mjs +29 -0
  250. package/dist/preview-ZZTR3QGS.mjs +9 -0
  251. package/dist/program-PW6UB2ZC.mjs +5934 -0
  252. package/dist/reconcile-single-tables-7ENVXJGB.mjs +7 -0
  253. package/dist/register-SF6E6FVU.mjs +49 -0
  254. package/dist/reload-config-HWQ4G5MM.mjs +23 -0
  255. package/dist/resolve-single-table-name-JSOMUB3R.mjs +7 -0
  256. package/dist/routeHandler-UNMMJIBM.mjs +77 -0
  257. package/dist/runtime-schema-generator-NRA6A6Z6.mjs +8 -0
  258. package/dist/runtime.d.ts +120 -0
  259. package/dist/runtime.mjs +73 -0
  260. package/dist/schema-hash-FMMG6VPJ.mjs +13 -0
  261. package/dist/schema-registry-EQ36FZDP.mjs +7 -0
  262. package/dist/scripts/load-env.mjs +42 -0
  263. package/dist/storage/index.d.ts +566 -0
  264. package/dist/storage/index.mjs +45 -0
  265. package/dist/super-admin-G5ZK5F4T.mjs +39 -0
  266. package/dist/system-table-service-WGSRVEGT.mjs +17 -0
  267. package/dist/users-7KELGRYJ.mjs +38 -0
  268. 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
+ };