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.
- 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,832 @@
|
|
|
1
|
+
import {
|
|
2
|
+
calculateSchemaHash,
|
|
3
|
+
schemaHashesMatch
|
|
4
|
+
} from "./chunk-5HMZ644B.mjs";
|
|
5
|
+
import {
|
|
6
|
+
BaseService
|
|
7
|
+
} from "./chunk-2W3DVD7S.mjs";
|
|
8
|
+
import {
|
|
9
|
+
NextlyError,
|
|
10
|
+
toDbError
|
|
11
|
+
} from "./chunk-NRUWQ5Z7.mjs";
|
|
12
|
+
|
|
13
|
+
// src/services/lib/resource-slug-guard.ts
|
|
14
|
+
async function findSlugOwner(adapter, slug) {
|
|
15
|
+
const collection = await adapter.selectOne(
|
|
16
|
+
"dynamic_collections",
|
|
17
|
+
{
|
|
18
|
+
where: { and: [{ column: "slug", op: "=", value: slug }] },
|
|
19
|
+
columns: ["id"]
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
if (collection?.id) {
|
|
23
|
+
return {
|
|
24
|
+
resourceType: "collection",
|
|
25
|
+
id: collection.id
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const single = await adapter.selectOne("dynamic_singles", {
|
|
29
|
+
where: { and: [{ column: "slug", op: "=", value: slug }] },
|
|
30
|
+
columns: ["id"]
|
|
31
|
+
});
|
|
32
|
+
if (single?.id) {
|
|
33
|
+
return {
|
|
34
|
+
resourceType: "single",
|
|
35
|
+
id: single.id
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
async function assertGlobalResourceSlugAvailable(adapter, slug, options) {
|
|
41
|
+
const owner = await findSlugOwner(adapter, slug);
|
|
42
|
+
if (!owner) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const isSameResource = owner.resourceType === options?.currentResourceType && owner.id === options?.currentResourceId;
|
|
46
|
+
if (isSameResource) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
throw NextlyError.duplicate({
|
|
50
|
+
logContext: {
|
|
51
|
+
slug,
|
|
52
|
+
conflictResourceType: owner.resourceType,
|
|
53
|
+
conflictResourceId: owner.id
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/shared/base-registry-service.ts
|
|
59
|
+
import crypto from "crypto";
|
|
60
|
+
var BaseRegistryService = class extends BaseService {
|
|
61
|
+
constructor(adapter, logger) {
|
|
62
|
+
super(adapter, logger);
|
|
63
|
+
}
|
|
64
|
+
// ============================================================
|
|
65
|
+
// Shared Query Methods
|
|
66
|
+
// ============================================================
|
|
67
|
+
/**
|
|
68
|
+
* Get a record by slug, returning null if not found.
|
|
69
|
+
*/
|
|
70
|
+
async getRecordBySlug(slug) {
|
|
71
|
+
try {
|
|
72
|
+
const result = await this.adapter.selectOne(
|
|
73
|
+
this.registryTableName,
|
|
74
|
+
{
|
|
75
|
+
where: this.whereEq("slug", slug)
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
return result ? this.deserializeRecord(result) : null;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (NextlyError.is(error)) throw error;
|
|
81
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get a record by slug, throwing NOT_FOUND if missing.
|
|
86
|
+
*
|
|
87
|
+
* §13.8: public message is generic; identifying details (slug, resource
|
|
88
|
+
* type) flow through `logContext`, not the wire.
|
|
89
|
+
*/
|
|
90
|
+
async getRecordOrThrow(slug) {
|
|
91
|
+
const record = await this.getRecordBySlug(slug);
|
|
92
|
+
if (!record) {
|
|
93
|
+
throw NextlyError.notFound({
|
|
94
|
+
logContext: { entity: this.resourceType, slug }
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return record;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get all records, optionally filtered by source, migration status, and locked.
|
|
101
|
+
*/
|
|
102
|
+
async getAllRecords(options) {
|
|
103
|
+
try {
|
|
104
|
+
const conditions = this.buildFilterConditions(options);
|
|
105
|
+
const results = await this.adapter.select(
|
|
106
|
+
this.registryTableName,
|
|
107
|
+
{
|
|
108
|
+
where: conditions.length > 0 ? { and: conditions } : void 0,
|
|
109
|
+
orderBy: [{ column: "created_at", direction: "asc" }],
|
|
110
|
+
limit: options?.limit,
|
|
111
|
+
offset: options?.offset
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
return results.map((record) => this.deserializeRecord(record));
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (NextlyError.is(error)) throw error;
|
|
117
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* List records with pagination, search, and total count.
|
|
122
|
+
*/
|
|
123
|
+
async listRecords(options) {
|
|
124
|
+
try {
|
|
125
|
+
const conditions = this.buildFilterConditions(options);
|
|
126
|
+
if (options?.search) {
|
|
127
|
+
const searchPattern = `%${options.search}%`;
|
|
128
|
+
const searchColumns = this.getSearchColumns();
|
|
129
|
+
conditions.push({
|
|
130
|
+
or: searchColumns.map((column) => ({
|
|
131
|
+
column,
|
|
132
|
+
op: "ILIKE",
|
|
133
|
+
value: searchPattern
|
|
134
|
+
}))
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const whereClause = conditions.length > 0 ? { and: conditions } : void 0;
|
|
138
|
+
const allResults = await this.adapter.select(
|
|
139
|
+
this.registryTableName,
|
|
140
|
+
{
|
|
141
|
+
where: whereClause,
|
|
142
|
+
columns: ["id"]
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
const total = allResults.length;
|
|
146
|
+
const results = await this.adapter.select(
|
|
147
|
+
this.registryTableName,
|
|
148
|
+
{
|
|
149
|
+
where: whereClause,
|
|
150
|
+
orderBy: [{ column: "created_at", direction: "asc" }],
|
|
151
|
+
limit: options?.limit,
|
|
152
|
+
offset: options?.offset
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
return {
|
|
156
|
+
data: results.map((record) => this.deserializeRecord(record)),
|
|
157
|
+
total
|
|
158
|
+
};
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (NextlyError.is(error)) throw error;
|
|
161
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// ============================================================
|
|
165
|
+
// Shared Locking & Migration
|
|
166
|
+
// ============================================================
|
|
167
|
+
/**
|
|
168
|
+
* Check if a record is locked (code-first resources are locked).
|
|
169
|
+
*/
|
|
170
|
+
async checkIsLocked(slug) {
|
|
171
|
+
const record = await this.getRecordBySlug(slug);
|
|
172
|
+
return record?.locked ?? false;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Update migration status for a record.
|
|
176
|
+
*/
|
|
177
|
+
async updateRecordMigrationStatus(slug, status, migrationId) {
|
|
178
|
+
this.logger.debug("Updating migration status", { slug, status });
|
|
179
|
+
try {
|
|
180
|
+
const updateData = {
|
|
181
|
+
migration_status: status,
|
|
182
|
+
updated_at: this.formatDateForDb()
|
|
183
|
+
};
|
|
184
|
+
if (migrationId) {
|
|
185
|
+
updateData.last_migration_id = migrationId;
|
|
186
|
+
}
|
|
187
|
+
const results = await this.adapter.update(
|
|
188
|
+
this.registryTableName,
|
|
189
|
+
updateData,
|
|
190
|
+
this.whereEq("slug", slug),
|
|
191
|
+
{ returning: "*" }
|
|
192
|
+
);
|
|
193
|
+
if (results.length === 0) {
|
|
194
|
+
throw NextlyError.notFound({
|
|
195
|
+
logContext: { entity: this.resourceType, slug }
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
this.logger.info("Migration status updated", { slug, status });
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (NextlyError.is(error)) throw error;
|
|
201
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Safely update migration status to 'applied' with table existence verification.
|
|
206
|
+
*
|
|
207
|
+
* CRITICAL: Use this instead of updateRecordMigrationStatus when setting status
|
|
208
|
+
* to 'applied' to prevent the race condition where status is marked as 'applied'
|
|
209
|
+
* but the table doesn't actually exist.
|
|
210
|
+
*/
|
|
211
|
+
async updateMigrationStatusWithTableVerification(slug, tableName) {
|
|
212
|
+
this.logger.debug("Updating migration status with verification", {
|
|
213
|
+
slug,
|
|
214
|
+
tableName
|
|
215
|
+
});
|
|
216
|
+
try {
|
|
217
|
+
const tableExists = await this.adapter.tableExists(tableName);
|
|
218
|
+
if (tableExists) {
|
|
219
|
+
await this.updateRecordMigrationStatus(
|
|
220
|
+
slug,
|
|
221
|
+
"applied"
|
|
222
|
+
);
|
|
223
|
+
this.logger.info("Table verified, migration status set to 'applied'", {
|
|
224
|
+
slug,
|
|
225
|
+
tableName
|
|
226
|
+
});
|
|
227
|
+
return { verified: true, status: "applied" };
|
|
228
|
+
} else {
|
|
229
|
+
await this.updateRecordMigrationStatus(
|
|
230
|
+
slug,
|
|
231
|
+
"failed"
|
|
232
|
+
);
|
|
233
|
+
this.logger.error(
|
|
234
|
+
"Table verification failed - migration status set to 'failed'",
|
|
235
|
+
{ slug, tableName }
|
|
236
|
+
);
|
|
237
|
+
return { verified: false, status: "failed" };
|
|
238
|
+
}
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (NextlyError.is(error)) throw error;
|
|
241
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Get all records with pending migrations (status 'pending' or 'generated').
|
|
246
|
+
*/
|
|
247
|
+
async getRecordsWithPendingMigrations() {
|
|
248
|
+
try {
|
|
249
|
+
const results = await this.adapter.select(
|
|
250
|
+
this.registryTableName,
|
|
251
|
+
{
|
|
252
|
+
where: {
|
|
253
|
+
and: [
|
|
254
|
+
{
|
|
255
|
+
column: "migration_status",
|
|
256
|
+
op: "IN",
|
|
257
|
+
value: ["pending", "generated"]
|
|
258
|
+
}
|
|
259
|
+
]
|
|
260
|
+
},
|
|
261
|
+
orderBy: [{ column: "created_at", direction: "asc" }]
|
|
262
|
+
}
|
|
263
|
+
);
|
|
264
|
+
return results.map((record) => this.deserializeRecord(record));
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (NextlyError.is(error)) throw error;
|
|
267
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// ============================================================
|
|
271
|
+
// Shared Utilities
|
|
272
|
+
// ============================================================
|
|
273
|
+
/**
|
|
274
|
+
* Generate a unique ID using crypto.randomUUID().
|
|
275
|
+
*/
|
|
276
|
+
generateId() {
|
|
277
|
+
return crypto.randomUUID();
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Compute a simple hash from a string (for auto-generating schema_hash).
|
|
281
|
+
* Uses a fast DJB2-style hash — not cryptographic, just for change detection.
|
|
282
|
+
*/
|
|
283
|
+
computeSimpleHash(input) {
|
|
284
|
+
let hash = 5381;
|
|
285
|
+
for (let i = 0; i < input.length; i++) {
|
|
286
|
+
hash = (hash << 5) + hash + input.charCodeAt(i) | 0;
|
|
287
|
+
}
|
|
288
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Generate a table name from a slug.
|
|
292
|
+
* Converts slug to snake_case, removes invalid characters, and adds the domain prefix.
|
|
293
|
+
*/
|
|
294
|
+
generateTableName(slug) {
|
|
295
|
+
const normalized = slug.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
296
|
+
return `${this.tableNamePrefix}${normalized}`;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Ensure table name has the domain-specific prefix.
|
|
300
|
+
*/
|
|
301
|
+
ensureTableNamePrefix(tableName) {
|
|
302
|
+
if (tableName.startsWith(this.tableNamePrefix)) {
|
|
303
|
+
return tableName;
|
|
304
|
+
}
|
|
305
|
+
return `${this.tableNamePrefix}${tableName}`;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Check if admin config has changed between code and database.
|
|
309
|
+
* Uses JSON comparison to detect changes in admin properties.
|
|
310
|
+
*/
|
|
311
|
+
adminConfigChanged(codeAdmin, existingAdmin) {
|
|
312
|
+
if (!codeAdmin && !existingAdmin) {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
if (!codeAdmin || !existingAdmin) {
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
return JSON.stringify(codeAdmin) !== JSON.stringify(existingAdmin);
|
|
319
|
+
}
|
|
320
|
+
// ============================================================
|
|
321
|
+
// Private Helpers
|
|
322
|
+
// ============================================================
|
|
323
|
+
/**
|
|
324
|
+
* Build WHERE conditions for source, migrationStatus, and locked filters.
|
|
325
|
+
* Returns a mutable array so callers can add additional conditions (e.g., search).
|
|
326
|
+
*/
|
|
327
|
+
buildFilterConditions(options) {
|
|
328
|
+
const conditions = [];
|
|
329
|
+
if (options?.source) {
|
|
330
|
+
conditions.push({
|
|
331
|
+
column: "source",
|
|
332
|
+
op: "=",
|
|
333
|
+
value: options.source
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
if (options?.migrationStatus) {
|
|
337
|
+
conditions.push({
|
|
338
|
+
column: "migration_status",
|
|
339
|
+
op: "=",
|
|
340
|
+
value: options.migrationStatus
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (options?.locked !== void 0) {
|
|
344
|
+
conditions.push({
|
|
345
|
+
column: "locked",
|
|
346
|
+
op: "=",
|
|
347
|
+
value: options.locked
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return conditions;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// src/domains/collections/services/collection-registry-service.ts
|
|
355
|
+
function describeError(error) {
|
|
356
|
+
if (NextlyError.is(error)) {
|
|
357
|
+
const parts = [];
|
|
358
|
+
parts.push(`[${error.code}] ${error.message}`);
|
|
359
|
+
if (error.cause instanceof Error && error.cause.message) {
|
|
360
|
+
parts.push(`cause: ${error.cause.message}`);
|
|
361
|
+
} else if (typeof error.cause === "string") {
|
|
362
|
+
parts.push(`cause: ${error.cause}`);
|
|
363
|
+
}
|
|
364
|
+
const ctx = error.logContext;
|
|
365
|
+
if (ctx && typeof ctx === "object" && Object.keys(ctx).length > 0) {
|
|
366
|
+
try {
|
|
367
|
+
parts.push(`context: ${JSON.stringify(ctx)}`);
|
|
368
|
+
} catch {
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return parts.join(" | ");
|
|
372
|
+
}
|
|
373
|
+
if (error instanceof Error) {
|
|
374
|
+
return error.message;
|
|
375
|
+
}
|
|
376
|
+
return String(error);
|
|
377
|
+
}
|
|
378
|
+
var CollectionRegistryService = class extends BaseRegistryService {
|
|
379
|
+
registryTableName = "dynamic_collections";
|
|
380
|
+
resourceType = "Collection";
|
|
381
|
+
tableNamePrefix = "dc_";
|
|
382
|
+
permissionSeedService;
|
|
383
|
+
constructor(adapter, logger) {
|
|
384
|
+
super(adapter, logger);
|
|
385
|
+
}
|
|
386
|
+
getSearchColumns() {
|
|
387
|
+
return ["slug"];
|
|
388
|
+
}
|
|
389
|
+
/** Set the PermissionSeedService for auto-seeding permissions on collection sync. */
|
|
390
|
+
setPermissionSeedService(service) {
|
|
391
|
+
this.permissionSeedService = service;
|
|
392
|
+
}
|
|
393
|
+
async getCollectionBySlug(slug) {
|
|
394
|
+
return this.getRecordBySlug(slug);
|
|
395
|
+
}
|
|
396
|
+
async getCollection(slug) {
|
|
397
|
+
return this.getRecordOrThrow(slug);
|
|
398
|
+
}
|
|
399
|
+
async getAllCollections(options) {
|
|
400
|
+
return this.getAllRecords(options);
|
|
401
|
+
}
|
|
402
|
+
async listCollections(options) {
|
|
403
|
+
return this.listRecords(options);
|
|
404
|
+
}
|
|
405
|
+
async isLocked(slug) {
|
|
406
|
+
return this.checkIsLocked(slug);
|
|
407
|
+
}
|
|
408
|
+
async updateMigrationStatus(slug, status, migrationId) {
|
|
409
|
+
return this.updateRecordMigrationStatus(slug, status, migrationId);
|
|
410
|
+
}
|
|
411
|
+
async updateMigrationStatusWithVerification(slug, tableName) {
|
|
412
|
+
return this.updateMigrationStatusWithTableVerification(slug, tableName);
|
|
413
|
+
}
|
|
414
|
+
async getPendingMigrations() {
|
|
415
|
+
return this.getRecordsWithPendingMigrations();
|
|
416
|
+
}
|
|
417
|
+
async registerCollection(data) {
|
|
418
|
+
this.logger.debug("Registering collection", { slug: data.slug });
|
|
419
|
+
await assertGlobalResourceSlugAvailable(this.adapter, data.slug);
|
|
420
|
+
const existing = await this.getCollectionBySlug(data.slug);
|
|
421
|
+
if (existing) {
|
|
422
|
+
throw NextlyError.duplicate({
|
|
423
|
+
logContext: { reason: "collection-slug-conflict", slug: data.slug }
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
const now = this.formatDateForDb();
|
|
427
|
+
const tableName = this.ensureTableNamePrefix(data.tableName);
|
|
428
|
+
const fieldsJson = JSON.stringify(data.fields);
|
|
429
|
+
const schemaHash = data.schemaHash ?? this.computeSimpleHash(fieldsJson);
|
|
430
|
+
const record = {
|
|
431
|
+
id: this.generateId(),
|
|
432
|
+
slug: data.slug,
|
|
433
|
+
labels: JSON.stringify(data.labels),
|
|
434
|
+
table_name: tableName,
|
|
435
|
+
description: data.description,
|
|
436
|
+
fields: fieldsJson,
|
|
437
|
+
timestamps: data.timestamps ?? true ? 1 : 0,
|
|
438
|
+
admin: data.admin ? JSON.stringify(data.admin) : null,
|
|
439
|
+
source: data.source,
|
|
440
|
+
locked: data.locked ?? data.source === "code" ? 1 : 0,
|
|
441
|
+
// Persist Draft/Published flag. Stored as 0/1 to match how
|
|
442
|
+
// `timestamps` and `locked` are written in this code path; the
|
|
443
|
+
// SQLite/postgres/mysql Drizzle column types accept either form.
|
|
444
|
+
status: data.status === true ? 1 : 0,
|
|
445
|
+
config_path: data.configPath,
|
|
446
|
+
schema_hash: schemaHash,
|
|
447
|
+
schema_version: data.schemaVersion ?? 1,
|
|
448
|
+
migration_status: data.migrationStatus ?? "pending",
|
|
449
|
+
last_migration_id: data.lastMigrationId,
|
|
450
|
+
created_by: data.createdBy,
|
|
451
|
+
hooks: data.hooks ? JSON.stringify(data.hooks) : null,
|
|
452
|
+
created_at: now,
|
|
453
|
+
updated_at: now
|
|
454
|
+
};
|
|
455
|
+
try {
|
|
456
|
+
const result = await this.adapter.insert(
|
|
457
|
+
this.registryTableName,
|
|
458
|
+
record,
|
|
459
|
+
{ returning: "*" }
|
|
460
|
+
);
|
|
461
|
+
this.logger.info("Collection registered", {
|
|
462
|
+
slug: data.slug,
|
|
463
|
+
source: data.source
|
|
464
|
+
});
|
|
465
|
+
const deserializedRecord = this.deserializeRecord(result);
|
|
466
|
+
if (this.permissionSeedService && data.slug) {
|
|
467
|
+
try {
|
|
468
|
+
const permResult = await this.permissionSeedService.seedCollectionPermissions(
|
|
469
|
+
data.slug
|
|
470
|
+
);
|
|
471
|
+
if (permResult.newPermissionIds?.length > 0) {
|
|
472
|
+
await this.permissionSeedService.assignNewPermissionsToSuperAdmin(
|
|
473
|
+
permResult.newPermissionIds
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
this.logger.info("Collection permissions seeded", {
|
|
477
|
+
slug: data.slug,
|
|
478
|
+
created: permResult.created,
|
|
479
|
+
total: permResult.total
|
|
480
|
+
});
|
|
481
|
+
} catch (permError) {
|
|
482
|
+
this.logger.error("Failed to seed collection permissions", {
|
|
483
|
+
slug: data.slug,
|
|
484
|
+
error: String(permError)
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return deserializedRecord;
|
|
489
|
+
} catch (error) {
|
|
490
|
+
if (NextlyError.is(error)) throw error;
|
|
491
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async updateCollection(slug, data, options) {
|
|
495
|
+
this.logger.debug("Updating collection", { slug });
|
|
496
|
+
const existing = await this.getCollection(slug);
|
|
497
|
+
const targetSlug = data.slug ?? slug;
|
|
498
|
+
await assertGlobalResourceSlugAvailable(this.adapter, targetSlug, {
|
|
499
|
+
currentResourceType: "collection",
|
|
500
|
+
currentResourceId: existing.id
|
|
501
|
+
});
|
|
502
|
+
if (existing.locked && options?.source !== "code") {
|
|
503
|
+
throw NextlyError.forbidden({
|
|
504
|
+
logContext: {
|
|
505
|
+
reason: "collection-locked",
|
|
506
|
+
slug,
|
|
507
|
+
source: options?.source
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
const updateData = {
|
|
512
|
+
updated_at: this.formatDateForDb()
|
|
513
|
+
};
|
|
514
|
+
if (data.labels) {
|
|
515
|
+
updateData.labels = JSON.stringify(data.labels);
|
|
516
|
+
}
|
|
517
|
+
if (data.description !== void 0) {
|
|
518
|
+
updateData.description = data.description;
|
|
519
|
+
}
|
|
520
|
+
if (data.fields) {
|
|
521
|
+
updateData.fields = JSON.stringify(data.fields);
|
|
522
|
+
updateData.schema_version = existing.schemaVersion + 1;
|
|
523
|
+
updateData.migration_status = "pending";
|
|
524
|
+
}
|
|
525
|
+
if (data.timestamps !== void 0) {
|
|
526
|
+
updateData.timestamps = data.timestamps;
|
|
527
|
+
}
|
|
528
|
+
if (data.admin !== void 0) {
|
|
529
|
+
updateData.admin = data.admin ? JSON.stringify(data.admin) : null;
|
|
530
|
+
}
|
|
531
|
+
if (data.schemaHash) {
|
|
532
|
+
updateData.schema_hash = data.schemaHash;
|
|
533
|
+
}
|
|
534
|
+
if (data.locked !== void 0) {
|
|
535
|
+
updateData.locked = data.locked;
|
|
536
|
+
}
|
|
537
|
+
if (data.configPath !== void 0) {
|
|
538
|
+
updateData.config_path = data.configPath;
|
|
539
|
+
}
|
|
540
|
+
if (data.hooks !== void 0) {
|
|
541
|
+
updateData.hooks = data.hooks ? JSON.stringify(data.hooks) : null;
|
|
542
|
+
}
|
|
543
|
+
if (data.status !== void 0) {
|
|
544
|
+
updateData.status = data.status === true ? 1 : 0;
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
const results = await this.adapter.update(
|
|
548
|
+
this.registryTableName,
|
|
549
|
+
updateData,
|
|
550
|
+
this.whereEq("slug", slug),
|
|
551
|
+
{ returning: "*" }
|
|
552
|
+
);
|
|
553
|
+
if (results.length === 0) {
|
|
554
|
+
throw NextlyError.notFound({ logContext: { slug } });
|
|
555
|
+
}
|
|
556
|
+
this.logger.info("Collection updated", { slug });
|
|
557
|
+
return this.deserializeRecord(results[0]);
|
|
558
|
+
} catch (error) {
|
|
559
|
+
if (NextlyError.is(error)) {
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
562
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
async deleteCollection(slug) {
|
|
566
|
+
this.logger.debug("Deleting collection", { slug });
|
|
567
|
+
const existing = await this.getCollection(slug);
|
|
568
|
+
if (existing.locked) {
|
|
569
|
+
throw NextlyError.forbidden({
|
|
570
|
+
logContext: { reason: "collection-locked-delete", slug }
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
const count = await this.adapter.delete(
|
|
575
|
+
this.registryTableName,
|
|
576
|
+
this.whereEq("slug", slug)
|
|
577
|
+
);
|
|
578
|
+
if (count === 0) {
|
|
579
|
+
throw NextlyError.notFound({ logContext: { slug } });
|
|
580
|
+
}
|
|
581
|
+
this.logger.info("Collection deleted", { slug });
|
|
582
|
+
} catch (error) {
|
|
583
|
+
if (NextlyError.is(error)) {
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
throw NextlyError.fromDatabaseError(toDbError(this.dialect, error));
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async syncCodeFirstCollections(configs) {
|
|
590
|
+
this.logger.info("Syncing code-first collections", {
|
|
591
|
+
count: configs.length
|
|
592
|
+
});
|
|
593
|
+
const result = {
|
|
594
|
+
created: [],
|
|
595
|
+
updated: [],
|
|
596
|
+
unchanged: [],
|
|
597
|
+
errors: []
|
|
598
|
+
};
|
|
599
|
+
for (const config of configs) {
|
|
600
|
+
try {
|
|
601
|
+
const existing = await this.getCollectionBySlug(config.slug);
|
|
602
|
+
const schemaHash = calculateSchemaHash(config.fields);
|
|
603
|
+
if (!existing) {
|
|
604
|
+
await this.registerCollection({
|
|
605
|
+
slug: config.slug,
|
|
606
|
+
labels: config.labels,
|
|
607
|
+
tableName: config.tableName ?? this.generateTableName(config.slug),
|
|
608
|
+
description: config.description,
|
|
609
|
+
fields: config.fields,
|
|
610
|
+
timestamps: config.timestamps ?? true,
|
|
611
|
+
admin: config.admin,
|
|
612
|
+
source: "code",
|
|
613
|
+
locked: true,
|
|
614
|
+
// Forward Draft/Published flag so code-first collections that
|
|
615
|
+
// opt in actually write the column on first sync.
|
|
616
|
+
status: config.status === true,
|
|
617
|
+
configPath: config.configPath,
|
|
618
|
+
schemaHash
|
|
619
|
+
});
|
|
620
|
+
result.created.push(config.slug);
|
|
621
|
+
await this.seedPermissionsForCollection(config.slug);
|
|
622
|
+
} else if (!schemaHashesMatch(schemaHash, existing.schemaHash) || config.status === true !== (existing.status === true)) {
|
|
623
|
+
await this.updateCollection(
|
|
624
|
+
config.slug,
|
|
625
|
+
{
|
|
626
|
+
labels: config.labels,
|
|
627
|
+
description: config.description,
|
|
628
|
+
fields: config.fields,
|
|
629
|
+
timestamps: config.timestamps,
|
|
630
|
+
admin: config.admin,
|
|
631
|
+
configPath: config.configPath,
|
|
632
|
+
schemaHash,
|
|
633
|
+
locked: true,
|
|
634
|
+
status: config.status === true
|
|
635
|
+
},
|
|
636
|
+
{ source: "code" }
|
|
637
|
+
);
|
|
638
|
+
result.updated.push(config.slug);
|
|
639
|
+
await this.seedPermissionsForCollection(config.slug);
|
|
640
|
+
} else if (this.adminConfigChanged(config.admin, existing.admin) || this.labelsChanged(config.labels, existing.labels)) {
|
|
641
|
+
await this.updateCollection(
|
|
642
|
+
config.slug,
|
|
643
|
+
{
|
|
644
|
+
labels: config.labels,
|
|
645
|
+
admin: config.admin,
|
|
646
|
+
locked: true
|
|
647
|
+
},
|
|
648
|
+
{ source: "code" }
|
|
649
|
+
);
|
|
650
|
+
result.updated.push(config.slug);
|
|
651
|
+
} else {
|
|
652
|
+
await this.seedPermissionsForCollection(config.slug);
|
|
653
|
+
result.unchanged.push(config.slug);
|
|
654
|
+
}
|
|
655
|
+
} catch (error) {
|
|
656
|
+
const message = describeError(error);
|
|
657
|
+
const isDuplicate = NextlyError.is(error) && error.code === "DUPLICATE" || message.toLowerCase().includes("already exists") || message.toLowerCase().includes("duplicate") || message.toLowerCase().includes("unique constraint");
|
|
658
|
+
if (isDuplicate) {
|
|
659
|
+
const refetched = await this.getCollectionBySlug(config.slug).catch(
|
|
660
|
+
() => null
|
|
661
|
+
);
|
|
662
|
+
if (refetched) {
|
|
663
|
+
this.logger.warn(
|
|
664
|
+
`Code-first sync: "${config.slug}" already in DB \u2014 treating as unchanged`,
|
|
665
|
+
{ slug: config.slug }
|
|
666
|
+
);
|
|
667
|
+
result.unchanged.push(config.slug);
|
|
668
|
+
} else {
|
|
669
|
+
const disambiguatedTableName = `dc_${config.slug.replace(/-/g, "_")}_cf`;
|
|
670
|
+
try {
|
|
671
|
+
const retrySchemaHash = calculateSchemaHash(config.fields);
|
|
672
|
+
await this.registerCollection({
|
|
673
|
+
slug: config.slug,
|
|
674
|
+
labels: config.labels,
|
|
675
|
+
tableName: disambiguatedTableName,
|
|
676
|
+
description: config.description,
|
|
677
|
+
fields: config.fields,
|
|
678
|
+
timestamps: config.timestamps ?? true,
|
|
679
|
+
admin: config.admin,
|
|
680
|
+
source: "code",
|
|
681
|
+
locked: true,
|
|
682
|
+
configPath: config.configPath,
|
|
683
|
+
schemaHash: retrySchemaHash
|
|
684
|
+
});
|
|
685
|
+
this.logger.warn(
|
|
686
|
+
`Code-first sync: "${config.slug}" had table_name conflict \u2014 registered with table "${disambiguatedTableName}"`,
|
|
687
|
+
{ slug: config.slug, tableName: disambiguatedTableName }
|
|
688
|
+
);
|
|
689
|
+
result.created.push(config.slug);
|
|
690
|
+
} catch (retryError) {
|
|
691
|
+
const retryMessage = describeError(retryError);
|
|
692
|
+
result.errors.push({
|
|
693
|
+
slug: config.slug,
|
|
694
|
+
error: `table_name conflict (tried "${disambiguatedTableName}"): ${retryMessage}`
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
result.errors.push({ slug: config.slug, error: message });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
this.logger.info("Code-first sync completed", {
|
|
704
|
+
created: result.created.length,
|
|
705
|
+
updated: result.updated.length,
|
|
706
|
+
unchanged: result.unchanged.length,
|
|
707
|
+
errors: result.errors.length
|
|
708
|
+
});
|
|
709
|
+
return result;
|
|
710
|
+
}
|
|
711
|
+
async registerCollectionInTransaction(tx, data) {
|
|
712
|
+
await assertGlobalResourceSlugAvailable(this.adapter, data.slug);
|
|
713
|
+
const existing = await tx.selectOne(
|
|
714
|
+
this.registryTableName,
|
|
715
|
+
{
|
|
716
|
+
where: this.whereEq("slug", data.slug)
|
|
717
|
+
}
|
|
718
|
+
);
|
|
719
|
+
if (existing) {
|
|
720
|
+
throw NextlyError.duplicate({
|
|
721
|
+
logContext: {
|
|
722
|
+
reason: "collection-slug-conflict-tx",
|
|
723
|
+
slug: data.slug
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
const now = this.formatDateForDb();
|
|
728
|
+
const record = {
|
|
729
|
+
id: this.generateId(),
|
|
730
|
+
slug: data.slug,
|
|
731
|
+
labels: JSON.stringify(data.labels),
|
|
732
|
+
table_name: data.tableName,
|
|
733
|
+
description: data.description,
|
|
734
|
+
fields: JSON.stringify(data.fields),
|
|
735
|
+
timestamps: data.timestamps ?? true ? 1 : 0,
|
|
736
|
+
admin: data.admin ? JSON.stringify(data.admin) : null,
|
|
737
|
+
source: data.source,
|
|
738
|
+
locked: data.locked ?? data.source === "code" ? 1 : 0,
|
|
739
|
+
// Same as registerCollection — persist Draft/Published as 0/1.
|
|
740
|
+
status: data.status === true ? 1 : 0,
|
|
741
|
+
config_path: data.configPath,
|
|
742
|
+
schema_hash: data.schemaHash,
|
|
743
|
+
schema_version: data.schemaVersion ?? 1,
|
|
744
|
+
migration_status: data.migrationStatus ?? "pending",
|
|
745
|
+
last_migration_id: data.lastMigrationId,
|
|
746
|
+
created_by: data.createdBy,
|
|
747
|
+
hooks: data.hooks ? JSON.stringify(data.hooks) : null,
|
|
748
|
+
created_at: now,
|
|
749
|
+
updated_at: now
|
|
750
|
+
};
|
|
751
|
+
const result = await tx.insert(
|
|
752
|
+
this.registryTableName,
|
|
753
|
+
record,
|
|
754
|
+
{ returning: "*" }
|
|
755
|
+
);
|
|
756
|
+
return this.deserializeRecord(result);
|
|
757
|
+
}
|
|
758
|
+
async seedPermissionsForCollection(slug) {
|
|
759
|
+
if (!this.permissionSeedService) return;
|
|
760
|
+
try {
|
|
761
|
+
const result = await this.permissionSeedService.seedCollectionPermissions(slug);
|
|
762
|
+
if (result.newPermissionIds.length > 0) {
|
|
763
|
+
await this.permissionSeedService.assignNewPermissionsToSuperAdmin(
|
|
764
|
+
result.newPermissionIds
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
if (result.created > 0) {
|
|
768
|
+
this.logger.info(
|
|
769
|
+
`Permissions seeded for collection "${slug}": ${result.created} created, ${result.skipped} already existed`
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
} catch (error) {
|
|
773
|
+
this.logger.warn(
|
|
774
|
+
`Failed to seed permissions for collection "${slug}": ${error instanceof Error ? error.message : String(error)}`
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
labelsChanged(codeLabels, existingLabels) {
|
|
779
|
+
if (!codeLabels && !existingLabels) return false;
|
|
780
|
+
if (!codeLabels || !existingLabels) return true;
|
|
781
|
+
const existing = typeof existingLabels === "string" ? JSON.parse(existingLabels) : existingLabels;
|
|
782
|
+
return codeLabels.singular !== existing.singular || codeLabels.plural !== existing.plural;
|
|
783
|
+
}
|
|
784
|
+
deserializeRecord(record) {
|
|
785
|
+
const r = record;
|
|
786
|
+
const labels = r.labels || r.labels;
|
|
787
|
+
const fields = r.fields || r.fields;
|
|
788
|
+
const admin = r.admin || r.admin;
|
|
789
|
+
const hooks = r.hooks || r.hooks;
|
|
790
|
+
const tableName = r.table_name || r.tableName;
|
|
791
|
+
const configPath = r.config_path || r.configPath;
|
|
792
|
+
const schemaHash = r.schema_hash || r.schemaHash;
|
|
793
|
+
const schemaVersion = r.schema_version || r.schemaVersion;
|
|
794
|
+
const migrationStatus = r.migration_status || r.migrationStatus;
|
|
795
|
+
const lastMigrationId = r.last_migration_id || r.lastMigrationId;
|
|
796
|
+
const createdBy = r.created_by || r.createdBy;
|
|
797
|
+
const createdAt = r.created_at || r.createdAt;
|
|
798
|
+
const updatedAt = r.updated_at || r.updatedAt;
|
|
799
|
+
return {
|
|
800
|
+
id: r.id,
|
|
801
|
+
slug: r.slug,
|
|
802
|
+
tableName,
|
|
803
|
+
description: r.description,
|
|
804
|
+
labels: typeof labels === "string" ? JSON.parse(labels) : labels,
|
|
805
|
+
fields: typeof fields === "string" ? JSON.parse(fields) : fields,
|
|
806
|
+
timestamps: r.timestamps,
|
|
807
|
+
admin: admin ? typeof admin === "string" ? JSON.parse(admin) : admin : void 0,
|
|
808
|
+
hooks: hooks ? typeof hooks === "string" ? JSON.parse(hooks) : hooks : void 0,
|
|
809
|
+
source: r.source,
|
|
810
|
+
locked: r.locked,
|
|
811
|
+
// Why: read the new status meta-column, defaulting to false for rows
|
|
812
|
+
// written before this column existed (legacy data without status set).
|
|
813
|
+
// SQLite returns 0/1 even with mode:"boolean" in some driver/dialect
|
|
814
|
+
// combinations, so accept both shapes.
|
|
815
|
+
status: r.status === 1 || r.status === true,
|
|
816
|
+
configPath,
|
|
817
|
+
schemaHash,
|
|
818
|
+
schemaVersion,
|
|
819
|
+
migrationStatus,
|
|
820
|
+
lastMigrationId,
|
|
821
|
+
createdBy,
|
|
822
|
+
updatedAt: this.normalizeDbTimestamp(updatedAt),
|
|
823
|
+
createdAt: this.normalizeDbTimestamp(createdAt)
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
export {
|
|
829
|
+
assertGlobalResourceSlugAvailable,
|
|
830
|
+
BaseRegistryService,
|
|
831
|
+
CollectionRegistryService
|
|
832
|
+
};
|