@aphexcms/cms-core 0.1.17 → 0.2.2

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 (120) hide show
  1. package/dist/api/client.d.ts.map +1 -1
  2. package/dist/api/client.js +7 -1
  3. package/dist/api/types.d.ts +2 -0
  4. package/dist/api/types.d.ts.map +1 -1
  5. package/dist/cli/generate-types.js +59 -16
  6. package/dist/cli/index.js +1 -1
  7. package/dist/client/index.d.ts +0 -1
  8. package/dist/client/index.d.ts.map +1 -1
  9. package/dist/client/index.js +0 -1
  10. package/dist/components/AdminApp.svelte +278 -45
  11. package/dist/components/AdminApp.svelte.d.ts +2 -0
  12. package/dist/components/AdminApp.svelte.d.ts.map +1 -1
  13. package/dist/components/admin/DocumentEditor.svelte +60 -13
  14. package/dist/components/admin/DocumentEditor.svelte.d.ts.map +1 -1
  15. package/dist/components/admin/ObjectModal.svelte +15 -4
  16. package/dist/components/admin/ObjectModal.svelte.d.ts +1 -0
  17. package/dist/components/admin/ObjectModal.svelte.d.ts.map +1 -1
  18. package/dist/components/admin/SchemaField.svelte +64 -5
  19. package/dist/components/admin/SchemaField.svelte.d.ts +1 -0
  20. package/dist/components/admin/SchemaField.svelte.d.ts.map +1 -1
  21. package/dist/components/admin/fields/ArrayField.svelte +72 -17
  22. package/dist/components/admin/fields/ArrayField.svelte.d.ts +1 -0
  23. package/dist/components/admin/fields/ArrayField.svelte.d.ts.map +1 -1
  24. package/dist/components/admin/fields/DateField.svelte +145 -0
  25. package/dist/components/admin/fields/DateField.svelte.d.ts +14 -0
  26. package/dist/components/admin/fields/DateField.svelte.d.ts.map +1 -0
  27. package/dist/components/admin/fields/DateTimeField.svelte +225 -0
  28. package/dist/components/admin/fields/DateTimeField.svelte.d.ts +14 -0
  29. package/dist/components/admin/fields/DateTimeField.svelte.d.ts.map +1 -0
  30. package/dist/components/admin/fields/ImageField.svelte +20 -7
  31. package/dist/components/admin/fields/ImageField.svelte.d.ts +1 -0
  32. package/dist/components/admin/fields/ImageField.svelte.d.ts.map +1 -1
  33. package/dist/components/admin/fields/ReferenceField.svelte +1 -1
  34. package/dist/components/admin/fields/SlugField.svelte +1 -3
  35. package/dist/components/admin/fields/SlugField.svelte.d.ts.map +1 -1
  36. package/dist/components/admin/fields/StringField.svelte +156 -12
  37. package/dist/components/admin/fields/StringField.svelte.d.ts +3 -2
  38. package/dist/components/admin/fields/StringField.svelte.d.ts.map +1 -1
  39. package/dist/components/admin/fields/URLField.svelte +41 -0
  40. package/dist/components/admin/fields/URLField.svelte.d.ts +14 -0
  41. package/dist/components/admin/fields/URLField.svelte.d.ts.map +1 -0
  42. package/dist/components/index.d.ts +0 -1
  43. package/dist/components/index.d.ts.map +1 -1
  44. package/dist/components/index.js +0 -1
  45. package/dist/components/layout/sidebar/AppSidebar.svelte.d.ts +1 -8
  46. package/dist/components/layout/sidebar/AppSidebar.svelte.d.ts.map +1 -1
  47. package/dist/db/interfaces/document.d.ts +0 -2
  48. package/dist/db/interfaces/document.d.ts.map +1 -1
  49. package/dist/db/interfaces/index.d.ts +2 -1
  50. package/dist/db/interfaces/index.d.ts.map +1 -1
  51. package/dist/db/interfaces/user.d.ts +2 -0
  52. package/dist/db/interfaces/user.d.ts.map +1 -1
  53. package/dist/db/utils/reference-resolver.js +1 -1
  54. package/dist/engine.d.ts.map +1 -1
  55. package/dist/engine.js +3 -0
  56. package/dist/field-validation/date-utils.d.ts +30 -0
  57. package/dist/field-validation/date-utils.d.ts.map +1 -0
  58. package/dist/field-validation/date-utils.js +147 -0
  59. package/dist/field-validation/rule.d.ts +4 -0
  60. package/dist/field-validation/rule.d.ts.map +1 -1
  61. package/dist/field-validation/rule.js +170 -4
  62. package/dist/field-validation/utils.d.ts +7 -3
  63. package/dist/field-validation/utils.d.ts.map +1 -1
  64. package/dist/field-validation/utils.js +129 -35
  65. package/dist/hooks.d.ts.map +1 -1
  66. package/dist/hooks.js +38 -21
  67. package/dist/lib/field-validation/date-utils.js +147 -0
  68. package/dist/lib/field-validation/rule.js +170 -4
  69. package/dist/lib/field-validation/utils.js +129 -35
  70. package/dist/local-api/collection-api.d.ts +16 -4
  71. package/dist/local-api/collection-api.d.ts.map +1 -1
  72. package/dist/local-api/collection-api.js +51 -17
  73. package/dist/local-api/index.d.ts +1 -1
  74. package/dist/local-api/index.d.ts.map +1 -1
  75. package/dist/routes/assets-cdn.d.ts.map +1 -1
  76. package/dist/routes/assets-cdn.js +14 -7
  77. package/dist/routes/assets.d.ts.map +1 -1
  78. package/dist/routes/assets.js +6 -1
  79. package/dist/routes/documents-by-id.d.ts.map +1 -1
  80. package/dist/routes/documents-by-id.js +18 -7
  81. package/dist/routes/documents-publish.js +2 -2
  82. package/dist/routes/documents-query.d.ts +3 -1
  83. package/dist/routes/documents-query.d.ts.map +1 -1
  84. package/dist/routes/documents-query.js +6 -2
  85. package/dist/routes/documents.d.ts.map +1 -1
  86. package/dist/routes/documents.js +20 -4
  87. package/dist/routes/index.d.ts +1 -0
  88. package/dist/routes/index.d.ts.map +1 -1
  89. package/dist/routes/index.js +2 -0
  90. package/dist/routes/user-preferences.d.ts +4 -0
  91. package/dist/routes/user-preferences.d.ts.map +1 -0
  92. package/dist/routes/user-preferences.js +77 -0
  93. package/dist/schema-utils/utils.d.ts +4 -0
  94. package/dist/schema-utils/utils.d.ts.map +1 -1
  95. package/dist/schema-utils/utils.js +23 -2
  96. package/dist/schema-utils/validator.d.ts +4 -0
  97. package/dist/schema-utils/validator.d.ts.map +1 -1
  98. package/dist/schema-utils/validator.js +120 -0
  99. package/dist/types/filters.d.ts +13 -0
  100. package/dist/types/filters.d.ts.map +1 -1
  101. package/dist/types/organization.d.ts +3 -0
  102. package/dist/types/organization.d.ts.map +1 -1
  103. package/dist/types/schemas.d.ts +67 -7
  104. package/dist/types/schemas.d.ts.map +1 -1
  105. package/dist/utils/default-orderings.d.ts +10 -0
  106. package/dist/utils/default-orderings.d.ts.map +1 -0
  107. package/dist/utils/default-orderings.js +63 -0
  108. package/dist/utils/field-defaults.d.ts +8 -0
  109. package/dist/utils/field-defaults.d.ts.map +1 -0
  110. package/dist/utils/field-defaults.js +20 -0
  111. package/dist/utils/index.d.ts +1 -0
  112. package/dist/utils/index.d.ts.map +1 -1
  113. package/dist/utils/index.js +1 -0
  114. package/dist/utils/initial-value-helpers.d.ts +50 -0
  115. package/dist/utils/initial-value-helpers.d.ts.map +1 -0
  116. package/dist/utils/initial-value-helpers.js +70 -0
  117. package/package.json +6 -4
  118. package/dist/components/admin/DocumentTypesList.svelte +0 -97
  119. package/dist/components/admin/DocumentTypesList.svelte.d.ts +0 -14
  120. package/dist/components/admin/DocumentTypesList.svelte.d.ts.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import { Rule } from './rule.js';
2
+ import { normalizeDateFields } from './date-utils.js';
2
3
  /**
3
4
  * Check if a field is required based on its validation rules
4
5
  */
@@ -20,39 +21,106 @@ export function isFieldRequired(field) {
20
21
  * Validate a field value against its validation rules
21
22
  */
22
23
  export async function validateField(field, value, context = {}) {
23
- if (!field.validation) {
24
- return { isValid: true, errors: [] };
24
+ console.log(`[validateField] Validating field "${field.name}"`, {
25
+ type: field.type,
26
+ value,
27
+ hasValidation: !!field.validation
28
+ });
29
+ const allErrors = [];
30
+ // Add automatic validation for date/datetime/url fields based on type
31
+ if (field.type === 'date') {
32
+ const dateField = field;
33
+ const dateFormat = dateField.options?.dateFormat || 'YYYY-MM-DD';
34
+ console.log(`[validateField] Adding automatic DATE validation for "${field.name}"`, {
35
+ dateFormat
36
+ });
37
+ const autoRule = new Rule().date(dateFormat);
38
+ const markers = await autoRule.validate(value, {
39
+ path: [field.name],
40
+ ...context
41
+ });
42
+ allErrors.push(...markers.map((marker) => ({
43
+ level: marker.level,
44
+ message: marker.message
45
+ })));
25
46
  }
26
- try {
27
- const validationFunctions = Array.isArray(field.validation)
28
- ? field.validation
29
- : [field.validation];
30
- const allErrors = [];
31
- for (const validationFn of validationFunctions) {
32
- const rule = validationFn(new Rule());
33
- if (!(rule instanceof Rule)) {
34
- console.error(`Validation function for field "${field.name}" did not return a Rule object. Make sure you are chaining validation methods and returning the result.`);
35
- continue;
47
+ else if (field.type === 'datetime') {
48
+ const dateTimeField = field;
49
+ const dateFormat = dateTimeField.options?.dateFormat || 'YYYY-MM-DD';
50
+ const timeFormat = dateTimeField.options?.timeFormat || 'HH:mm';
51
+ console.log(`[validateField] Adding automatic DATETIME validation for "${field.name}"`, {
52
+ dateFormat,
53
+ timeFormat
54
+ });
55
+ const autoRule = new Rule().datetime(dateFormat, timeFormat);
56
+ const markers = await autoRule.validate(value, {
57
+ path: [field.name],
58
+ ...context
59
+ });
60
+ allErrors.push(...markers.map((marker) => ({
61
+ level: marker.level,
62
+ message: marker.message
63
+ })));
64
+ }
65
+ else if (field.type === 'url') {
66
+ // Only add automatic URL validation if there's no custom validation
67
+ // This allows custom validation to specify different options (scheme, allowRelative, relativeOnly)
68
+ if (!field.validation) {
69
+ console.log(`[validateField] Adding automatic URL validation for "${field.name}"`);
70
+ // Automatic URL validation - only validate if there's a value
71
+ if (value && value !== '') {
72
+ const autoRule = new Rule().uri();
73
+ const markers = await autoRule.validate(value, {
74
+ path: [field.name],
75
+ ...context
76
+ });
77
+ allErrors.push(...markers.map((marker) => ({
78
+ level: marker.level,
79
+ message: marker.message
80
+ })));
36
81
  }
37
- const markers = await rule.validate(value, {
38
- path: [field.name],
39
- ...context
40
- });
41
- allErrors.push(...markers.map((marker) => ({
42
- level: marker.level,
43
- message: marker.message
44
- })));
45
82
  }
46
- const isValid = allErrors.filter((e) => e.level === 'error').length === 0;
47
- return { isValid, errors: allErrors };
83
+ else {
84
+ console.log(`[validateField] Skipping automatic URL validation for "${field.name}" (has custom validation)`);
85
+ }
86
+ }
87
+ // Run user-defined validation rules if present
88
+ if (!field.validation) {
89
+ console.log(`[validateField] No custom validation rules for "${field.name}"`);
48
90
  }
49
- catch (error) {
50
- console.error('Validation error:', error);
51
- return {
52
- isValid: false,
53
- errors: [{ level: 'error', message: 'Validation failed' }]
54
- };
91
+ else {
92
+ try {
93
+ const validationFunctions = Array.isArray(field.validation)
94
+ ? field.validation
95
+ : [field.validation];
96
+ console.log(`[validateField] Field "${field.name}" has ${validationFunctions.length} custom validation function(s)`);
97
+ for (const validationFn of validationFunctions) {
98
+ const rule = validationFn(new Rule());
99
+ if (!(rule instanceof Rule)) {
100
+ console.error(`Validation function for field "${field.name}" did not return a Rule object. Make sure you are chaining validation methods and returning the result.`);
101
+ continue;
102
+ }
103
+ const markers = await rule.validate(value, {
104
+ path: [field.name],
105
+ ...context
106
+ });
107
+ allErrors.push(...markers.map((marker) => ({
108
+ level: marker.level,
109
+ message: marker.message
110
+ })));
111
+ }
112
+ }
113
+ catch (error) {
114
+ console.error(`[validateField] Validation error for "${field.name}":`, error);
115
+ allErrors.push({ level: 'error', message: 'Validation failed' });
116
+ }
55
117
  }
118
+ const isValid = allErrors.filter((e) => e.level === 'error').length === 0;
119
+ console.log(`[validateField] Field "${field.name}" validation complete`, {
120
+ isValid,
121
+ errors: allErrors
122
+ });
123
+ return { isValid, errors: allErrors };
56
124
  }
57
125
  /**
58
126
  * Get validation CSS classes for input styling
@@ -66,20 +134,41 @@ export function getValidationClasses(hasErrors) {
66
134
  }
67
135
  /**
68
136
  * Validate an entire document's data against a schema
69
- * This function validates all fields in a schema against the provided data
70
- * and returns any validation errors found.
137
+ * This function:
138
+ * 1. Normalizes date fields (converts user format to ISO for storage)
139
+ * 2. Converts ISO dates to user format for validation
140
+ * 3. Validates all fields and returns errors
141
+ * 4. Returns normalized data (with ISO dates) for storage
71
142
  *
72
143
  * @param schema - The schema type containing field definitions
73
144
  * @param data - The document data to validate
74
145
  * @param context - Optional context to pass to field validators
75
- * @returns Validation result with isValid flag and array of field errors
146
+ * @returns Validation result with isValid flag, errors, and normalized data
76
147
  */
77
148
  export async function validateDocumentData(schema, data, context = {}) {
149
+ console.log('[validateDocumentData] Starting validation', {
150
+ schemaName: schema.name,
151
+ data
152
+ });
78
153
  const validationErrors = [];
79
- // Validate each field in the schema
154
+ // Normalize date fields: convert to ISO for storage, user format for validation
155
+ const { normalizedData, dataForValidation } = normalizeDateFields(data, schema);
156
+ console.log('[validateDocumentData] After normalization', {
157
+ normalizedData,
158
+ dataForValidation
159
+ });
160
+ // Validate each field using the user-formatted data
80
161
  for (const field of schema.fields) {
81
- const value = data[field.name];
82
- const result = await validateField(field, value, { ...context, ...data });
162
+ const value = dataForValidation[field.name];
163
+ console.log(`[validateDocumentData] Validating field "${field.name}"`, {
164
+ type: field.type,
165
+ value
166
+ });
167
+ const result = await validateField(field, value, { ...context, ...dataForValidation });
168
+ console.log(`[validateDocumentData] Field "${field.name}" validation result`, {
169
+ isValid: result.isValid,
170
+ errors: result.errors
171
+ });
83
172
  if (!result.isValid) {
84
173
  const errorMessages = result.errors.filter((e) => e.level === 'error').map((e) => e.message);
85
174
  if (errorMessages.length > 0) {
@@ -90,8 +179,13 @@ export async function validateDocumentData(schema, data, context = {}) {
90
179
  }
91
180
  }
92
181
  }
93
- return {
182
+ console.log('[validateDocumentData] Final result', {
94
183
  isValid: validationErrors.length === 0,
95
184
  errors: validationErrors
185
+ });
186
+ return {
187
+ isValid: validationErrors.length === 0,
188
+ errors: validationErrors,
189
+ normalizedData
96
190
  };
97
191
  }
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/lib/hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,KAAK,EAAE,SAAS,EAA8B,MAAM,eAAe,CAAC;AAC3E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAIpD,OAAO,EAAa,SAAS,EAAE,MAAM,UAAU,CAAC;AAChD,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAGlE,MAAM,WAAW,YAAY;IAC5B,MAAM,EAAE,SAAS,CAAC;IAClB,YAAY,EAAE,YAAY,CAAC;IAC3B,cAAc,EAAE,cAAc,CAAC;IAC/B,eAAe,EAAE,eAAe,CAAC;IACjC,YAAY,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IACnC,SAAS,EAAE,SAAS,CAAC;IACrB,QAAQ,EAAE,QAAQ,CAAC;IACnB,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,YAAY,CAAC,EAAE,GAAG,CACjB,MAAM,EACN;QAAE,OAAO,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,OAAO,CAAC,QAAQ,CAAC,GAAG,QAAQ,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAC7E,CAAC;CACF;AA4ED,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CAyHvD"}
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/lib/hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,KAAK,EAAE,SAAS,EAA8B,MAAM,eAAe,CAAC;AAC3E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAIpD,OAAO,EAAa,SAAS,EAAE,MAAM,UAAU,CAAC;AAChD,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAGlE,MAAM,WAAW,YAAY;IAC5B,MAAM,EAAE,SAAS,CAAC;IAClB,YAAY,EAAE,YAAY,CAAC;IAC3B,cAAc,EAAE,cAAc,CAAC;IAC/B,eAAe,EAAE,eAAe,CAAC;IACjC,YAAY,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IACnC,SAAS,EAAE,SAAS,CAAC;IACrB,QAAQ,EAAE,QAAQ,CAAC;IACnB,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,YAAY,CAAC,EAAE,GAAG,CACjB,MAAM,EACN;QAAE,OAAO,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,OAAO,CAAC,QAAQ,CAAC,GAAG,QAAQ,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAC7E,CAAC;CACF;AAgFD,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CAgIvD"}
package/dist/hooks.js CHANGED
@@ -4,13 +4,17 @@ import { AssetService as AssetServiceClass } from './services/asset-service.js';
4
4
  import { createCMS } from './engine.js';
5
5
  import { createLocalAPI } from './local-api/index.js';
6
6
  let cmsInstances = null;
7
- let lastConfigHash = null;
8
- // Helper to generate a simple hash of schema types for change detection
9
- function getConfigHash(config) {
10
- const schemaNames = config.schemaTypes
11
- .map((s) => `${s.name}:${s.fields.length}:${JSON.stringify(s.fields)}`)
12
- .join(',');
13
- return schemaNames;
7
+ let schemaError = null;
8
+ /**
9
+ * Check if schemas are dirty (changed via Vite HMR)
10
+ * Vite plugin sets a global flag when schema files change
11
+ */
12
+ function checkSchemasDirty() {
13
+ const dirty = global.__aphexSchemasDirty === true;
14
+ if (dirty) {
15
+ global.__aphexSchemasDirty = false; // Reset the flag
16
+ }
17
+ return dirty;
14
18
  }
15
19
  // Factory function to create the default local storage adapter
16
20
  function createDefaultStorageAdapter() {
@@ -68,8 +72,6 @@ export function createCMSHook(config) {
68
72
  return async ({ event, resolve }) => {
69
73
  // Note: In dev mode, /storage/ might be accessible via Vite dev server
70
74
  // In production, only /static/ folder is served - /storage/ is private
71
- const currentConfigHash = getConfigHash(config);
72
- const configChanged = lastConfigHash !== null && currentConfigHash !== lastConfigHash;
73
75
  // Initialize CMS instances once at application startup
74
76
  if (!cmsInstances) {
75
77
  console.log('🚀 Initializing CMS...');
@@ -81,7 +83,14 @@ export function createCMSHook(config) {
81
83
  const cmsEngine = createCMS(config, databaseAdapter);
82
84
  // Initialize Local API (unified operations layer)
83
85
  const localAPI = createLocalAPI(config, databaseAdapter);
84
- await cmsEngine.initialize();
86
+ // Initialize schemas with validation
87
+ try {
88
+ await cmsEngine.initialize();
89
+ }
90
+ catch (error) {
91
+ console.error('❌ Failed to initialize CMS:', error);
92
+ schemaError = error instanceof Error ? error : new Error(String(error));
93
+ }
85
94
  // Build plugin route map and install plugins (do this ONCE at startup)
86
95
  const pluginRoutes = new Map();
87
96
  // Resolve and install plugins
@@ -130,18 +139,26 @@ export function createCMSHook(config) {
130
139
  auth: config.auth?.provider,
131
140
  pluginRoutes
132
141
  };
133
- lastConfigHash = currentConfigHash;
134
142
  }
135
- else if (configChanged) {
136
- // HMR: Config changed, re-sync schemas
137
- console.log('🔄 Schema types changed, re-syncing...');
138
- console.log('Old hash:', lastConfigHash?.substring(0, 100) + '...');
139
- console.log('New hash:', currentConfigHash.substring(0, 100) + '...');
140
- console.log('Schema types being updated:', config.schemaTypes.map((s) => s.name));
141
- cmsInstances.cmsEngine.updateConfig(config);
142
- await cmsInstances.cmsEngine.initialize();
143
- lastConfigHash = currentConfigHash;
144
- console.log('✅ Schema sync complete');
143
+ else if (checkSchemasDirty()) {
144
+ // HMR: Schemas changed, re-sync with database
145
+ try {
146
+ console.log('🔄 Syncing changed schemas to database...');
147
+ cmsInstances.cmsEngine.updateConfig(config);
148
+ await cmsInstances.cmsEngine.initialize();
149
+ schemaError = null; // Clear any previous errors
150
+ console.log('✅ Schema sync complete');
151
+ }
152
+ catch (error) {
153
+ console.error('❌ Failed to sync schemas:', error);
154
+ schemaError = error instanceof Error ? error : new Error(String(error));
155
+ // Don't crash the app, just store the error
156
+ // The old schemas in DB will remain until the error is fixed
157
+ }
158
+ }
159
+ // Attach schema error to instances so it can be accessed in load functions
160
+ if (cmsInstances) {
161
+ cmsInstances.schemaError = schemaError;
145
162
  }
146
163
  // Inject shared CMS services into locals (reuse singleton instances)
147
164
  event.locals.aphexCMS = cmsInstances;
@@ -0,0 +1,147 @@
1
+ import dayjs from 'dayjs';
2
+ import customParseFormat from 'dayjs/plugin/customParseFormat.js';
3
+ import utc from 'dayjs/plugin/utc.js';
4
+ dayjs.extend(customParseFormat);
5
+ dayjs.extend(utc);
6
+ /**
7
+ * Convert a date value to user format for validation
8
+ * Handles both ISO format and user format inputs
9
+ */
10
+ export function convertDateToUserFormat(value, userFormat) {
11
+ // Try parsing as ISO format first (what DateField stores)
12
+ // Use strict mode to reject invalid dates
13
+ const parsedISO = dayjs(value, 'YYYY-MM-DD', true);
14
+ if (parsedISO.isValid()) {
15
+ return parsedISO.format(userFormat);
16
+ }
17
+ // Try parsing as user format (what API might send)
18
+ // Use strict mode to reject invalid dates like Feb 31st
19
+ const parsedUser = dayjs(value, userFormat, true);
20
+ if (parsedUser.isValid()) {
21
+ return value; // Already in user format
22
+ }
23
+ // Invalid date - return as-is for validation to catch
24
+ return value;
25
+ }
26
+ /**
27
+ * Convert a date value to ISO format for storage
28
+ * Returns ISO if already valid, or original value if invalid
29
+ */
30
+ export function convertDateToISO(value, userFormat) {
31
+ // Try parsing as user format first (for API consumers)
32
+ // Use strict mode to reject invalid dates like Feb 31st
33
+ const parsedUser = dayjs(value, userFormat, true);
34
+ if (parsedUser.isValid()) {
35
+ return parsedUser.format('YYYY-MM-DD');
36
+ }
37
+ // Try parsing as ISO (for DateField component)
38
+ // Use strict mode
39
+ const parsedISO = dayjs(value, 'YYYY-MM-DD', true);
40
+ if (parsedISO.isValid()) {
41
+ return value; // Already ISO
42
+ }
43
+ // Invalid date - return as-is
44
+ return value;
45
+ }
46
+ /**
47
+ * Convert a datetime value to user format for validation
48
+ * Handles both ISO datetime and user format inputs
49
+ */
50
+ export function convertDateTimeToUserFormat(value, dateFormat, timeFormat = 'HH:mm') {
51
+ const userFormat = `${dateFormat} ${timeFormat}`;
52
+ // Try parsing as ISO datetime first (what DateTimeField stores)
53
+ // Use strict mode to reject invalid dates
54
+ const parsedISO = dayjs(value, 'YYYY-MM-DDTHH:mm:ss[Z]', true);
55
+ if (parsedISO.isValid()) {
56
+ return parsedISO.format(userFormat);
57
+ }
58
+ // Try parsing as user format (what API might send)
59
+ // Use strict mode to reject invalid dates like Feb 31st
60
+ const parsedUser = dayjs(value, userFormat, true);
61
+ if (parsedUser.isValid()) {
62
+ return value; // Already in user format
63
+ }
64
+ // Invalid datetime - return as-is for validation to catch
65
+ return value;
66
+ }
67
+ /**
68
+ * Convert a datetime value to ISO UTC format for storage
69
+ * Returns ISO UTC if already valid, or original value if invalid
70
+ */
71
+ export function convertDateTimeToISO(value, dateFormat, timeFormat = 'HH:mm') {
72
+ const userFormat = `${dateFormat} ${timeFormat}`;
73
+ console.log('[convertDateTimeToISO]', { value, userFormat });
74
+ // Try parsing as user format first (for API consumers)
75
+ // Use strict mode to reject invalid dates like Feb 31st
76
+ const parsedUser = dayjs(value, userFormat, true);
77
+ if (parsedUser.isValid()) {
78
+ const result = parsedUser.utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
79
+ console.log('[convertDateTimeToISO] User format parse successful, converting to UTC:', result);
80
+ return result;
81
+ }
82
+ // Try parsing as ISO datetime (for DateTimeField component)
83
+ // Use strict mode with ISO format
84
+ const parsedISO = dayjs(value, 'YYYY-MM-DDTHH:mm:ss[Z]', true);
85
+ if (parsedISO.isValid()) {
86
+ const result = parsedISO.utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
87
+ console.log('[convertDateTimeToISO] ISO parse successful:', result);
88
+ return result;
89
+ }
90
+ // Invalid datetime - return as-is
91
+ console.log('[convertDateTimeToISO] Invalid - returning as-is');
92
+ return value;
93
+ }
94
+ /**
95
+ * Normalize date fields in data object
96
+ * Converts dates to ISO for storage and creates a parallel object with user-formatted dates for validation
97
+ */
98
+ export function normalizeDateFields(data, schema) {
99
+ const normalizedData = { ...data };
100
+ const dataForValidation = { ...data };
101
+ console.log('[normalizeDateFields] Starting normalization...');
102
+ console.log('[normalizeDateFields] Input data:', data);
103
+ for (const field of schema.fields) {
104
+ if (field.type === 'date' && normalizedData[field.name]) {
105
+ const dateField = field;
106
+ const userFormat = dateField.options?.dateFormat || 'YYYY-MM-DD';
107
+ const dateValue = normalizedData[field.name];
108
+ console.log(`[normalizeDateFields] Processing DATE field "${field.name}"`, {
109
+ originalValue: dateValue,
110
+ userFormat
111
+ });
112
+ if (typeof dateValue === 'string') {
113
+ normalizedData[field.name] = convertDateToISO(dateValue, userFormat);
114
+ dataForValidation[field.name] = convertDateToUserFormat(dateValue, userFormat);
115
+ console.log(`[normalizeDateFields] Converted DATE field "${field.name}"`, {
116
+ normalizedValue: normalizedData[field.name],
117
+ validationValue: dataForValidation[field.name]
118
+ });
119
+ }
120
+ }
121
+ else if (field.type === 'datetime' && normalizedData[field.name]) {
122
+ const dateTimeField = field;
123
+ const dateFormat = dateTimeField.options?.dateFormat || 'YYYY-MM-DD';
124
+ const timeFormat = dateTimeField.options?.timeFormat || 'HH:mm';
125
+ const dateTimeValue = normalizedData[field.name];
126
+ console.log(`[normalizeDateFields] Processing DATETIME field "${field.name}"`, {
127
+ originalValue: dateTimeValue,
128
+ dateFormat,
129
+ timeFormat,
130
+ combinedFormat: `${dateFormat} ${timeFormat}`
131
+ });
132
+ if (typeof dateTimeValue === 'string') {
133
+ normalizedData[field.name] = convertDateTimeToISO(dateTimeValue, dateFormat, timeFormat);
134
+ dataForValidation[field.name] = convertDateTimeToUserFormat(dateTimeValue, dateFormat, timeFormat);
135
+ console.log(`[normalizeDateFields] Converted DATETIME field "${field.name}"`, {
136
+ normalizedValue: normalizedData[field.name],
137
+ validationValue: dataForValidation[field.name]
138
+ });
139
+ }
140
+ }
141
+ }
142
+ console.log('[normalizeDateFields] Final result:', {
143
+ normalizedData,
144
+ dataForValidation
145
+ });
146
+ return { normalizedData, dataForValidation };
147
+ }
@@ -1,3 +1,8 @@
1
+ // Sanity-style validation Rule implementation
2
+ import dayjs from 'dayjs';
3
+ import customParseFormat from 'dayjs/plugin/customParseFormat.js';
4
+ // Enable strict parsing
5
+ dayjs.extend(customParseFormat);
1
6
  export class Rule {
2
7
  _required = false;
3
8
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -39,6 +44,11 @@ export class Rule {
39
44
  newRule._rules.push({ type: 'length', constraint: len });
40
45
  return newRule;
41
46
  }
47
+ unique() {
48
+ const newRule = this.clone();
49
+ newRule._rules.push({ type: 'unique' });
50
+ return newRule;
51
+ }
42
52
  email() {
43
53
  const newRule = this.clone();
44
54
  newRule._rules.push({ type: 'email' });
@@ -79,6 +89,17 @@ export class Rule {
79
89
  newRule._rules.push({ type: 'lessThan', constraint: num });
80
90
  return newRule;
81
91
  }
92
+ date(format) {
93
+ const newRule = this.clone();
94
+ newRule._rules.push({ type: 'date', constraint: format || 'YYYY-MM-DD' });
95
+ return newRule;
96
+ }
97
+ datetime(dateFormat, timeFormat) {
98
+ const newRule = this.clone();
99
+ const fullFormat = `${dateFormat || 'YYYY-MM-DD'} ${timeFormat || 'HH:mm'}`;
100
+ newRule._rules.push({ type: 'datetime', constraint: fullFormat });
101
+ return newRule;
102
+ }
82
103
  custom(fn) {
83
104
  const newRule = this.clone();
84
105
  newRule._rules.push({ type: 'custom', constraint: fn });
@@ -155,6 +176,9 @@ export class Rule {
155
176
  if (typeof value === 'number' && value < rule.constraint) {
156
177
  return `Must be at least ${rule.constraint}`;
157
178
  }
179
+ if (Array.isArray(value) && value.length < rule.constraint) {
180
+ return `Must have at least ${rule.constraint} item${rule.constraint === 1 ? '' : 's'}`;
181
+ }
158
182
  break;
159
183
  case 'max':
160
184
  if (typeof value === 'string' && value.length > rule.constraint) {
@@ -163,6 +187,31 @@ export class Rule {
163
187
  if (typeof value === 'number' && value > rule.constraint) {
164
188
  return `Must be at most ${rule.constraint}`;
165
189
  }
190
+ if (Array.isArray(value) && value.length > rule.constraint) {
191
+ return `Must have at most ${rule.constraint} item${rule.constraint === 1 ? '' : 's'}`;
192
+ }
193
+ break;
194
+ case 'length':
195
+ if (Array.isArray(value) && value.length !== rule.constraint) {
196
+ return `Must have exactly ${rule.constraint} item${rule.constraint === 1 ? '' : 's'}`;
197
+ }
198
+ if (typeof value === 'string' && value.length !== rule.constraint) {
199
+ return `Must be exactly ${rule.constraint} characters`;
200
+ }
201
+ break;
202
+ case 'unique':
203
+ if (Array.isArray(value)) {
204
+ const seen = new Set();
205
+ for (const item of value) {
206
+ // Deep comparison excluding _key property
207
+ const normalized = this.normalizeForComparison(item);
208
+ const serialized = JSON.stringify(normalized);
209
+ if (seen.has(serialized)) {
210
+ return 'All items must be unique';
211
+ }
212
+ seen.add(serialized);
213
+ }
214
+ }
166
215
  break;
167
216
  case 'email':
168
217
  if (typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
@@ -171,11 +220,57 @@ export class Rule {
171
220
  break;
172
221
  case 'uri':
173
222
  if (typeof value === 'string') {
174
- try {
175
- new URL(value);
223
+ const opts = rule.constraint || {};
224
+ const schemes = opts.scheme || [/^https?$/];
225
+ const allowRelative = opts.allowRelative || false;
226
+ const relativeOnly = opts.relativeOnly || false;
227
+ // Check if URL is relative (doesn't have a scheme)
228
+ const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(value);
229
+ const isProtocolRelative = value.startsWith('//');
230
+ if (relativeOnly) {
231
+ // Only allow relative URLs
232
+ if (hasScheme || isProtocolRelative) {
233
+ return 'Must be a relative URL';
234
+ }
235
+ // Validate it's a reasonable path
236
+ if (!value.startsWith('/') &&
237
+ !value.startsWith('.') &&
238
+ !value.startsWith('#') &&
239
+ !value.startsWith('?')) {
240
+ return 'Must be a relative URL starting with /, ., #, or ?';
241
+ }
176
242
  }
177
- catch {
178
- return 'Must be a valid URL';
243
+ else if (!hasScheme && !isProtocolRelative) {
244
+ // It's a relative URL
245
+ if (!allowRelative) {
246
+ return 'Must be an absolute URL';
247
+ }
248
+ // Validate it's a reasonable relative path
249
+ if (!value.startsWith('/') &&
250
+ !value.startsWith('.') &&
251
+ !value.startsWith('#') &&
252
+ !value.startsWith('?')) {
253
+ return 'Must be a valid relative URL';
254
+ }
255
+ }
256
+ else {
257
+ // It's an absolute URL, validate with URL constructor
258
+ try {
259
+ const url = new URL(value);
260
+ // Extract scheme without the trailing colon
261
+ const urlScheme = url.protocol.slice(0, -1);
262
+ // Check if scheme is allowed
263
+ const schemeMatches = schemes.some((s) => s instanceof RegExp ? s.test(urlScheme) : s === urlScheme);
264
+ if (!schemeMatches) {
265
+ const schemeList = schemes
266
+ .map((s) => (s instanceof RegExp ? s.toString() : s))
267
+ .join(', ');
268
+ return `URL scheme must be one of: ${schemeList}`;
269
+ }
270
+ }
271
+ catch {
272
+ return 'Must be a valid URL';
273
+ }
179
274
  }
180
275
  }
181
276
  break;
@@ -199,6 +294,58 @@ export class Rule {
199
294
  return 'Must be an integer';
200
295
  }
201
296
  break;
297
+ case 'date': {
298
+ if (typeof value === 'string') {
299
+ const format = rule.constraint || 'YYYY-MM-DD';
300
+ console.log('[Rule.validate] DATE validation', { value, format });
301
+ // Parse with strict mode
302
+ const parsed = dayjs(value, format, true);
303
+ console.log('[Rule.validate] DATE parsed', {
304
+ isValid: parsed.isValid(),
305
+ parsed: parsed.format()
306
+ });
307
+ if (!parsed.isValid()) {
308
+ console.log('[Rule.validate] DATE validation FAILED - invalid format');
309
+ return `Invalid date format. Expected: ${format}`;
310
+ }
311
+ // Verify the parsed date matches the input (catches invalid dates like 2025-02-31)
312
+ if (parsed.format(format) !== value) {
313
+ console.log('[Rule.validate] DATE validation FAILED - format mismatch', {
314
+ expected: value,
315
+ got: parsed.format(format)
316
+ });
317
+ return `Invalid date. Expected format: ${format}`;
318
+ }
319
+ console.log('[Rule.validate] DATE validation PASSED');
320
+ }
321
+ break;
322
+ }
323
+ case 'datetime': {
324
+ if (typeof value === 'string') {
325
+ const format = rule.constraint || 'YYYY-MM-DD HH:mm';
326
+ console.log('[Rule.validate] DATETIME validation', { value, format });
327
+ // Parse with strict mode
328
+ const parsed = dayjs(value, format, true);
329
+ console.log('[Rule.validate] DATETIME parsed', {
330
+ isValid: parsed.isValid(),
331
+ parsed: parsed.format()
332
+ });
333
+ if (!parsed.isValid()) {
334
+ console.log('[Rule.validate] DATETIME validation FAILED - invalid format');
335
+ return `Invalid datetime format. Expected: ${format}`;
336
+ }
337
+ // Verify the parsed datetime matches the input (catches invalid dates like 2025-02-31 23:59)
338
+ if (parsed.format(format) !== value) {
339
+ console.log('[Rule.validate] DATETIME validation FAILED - format mismatch', {
340
+ expected: value,
341
+ got: parsed.format(format)
342
+ });
343
+ return `Invalid datetime. Expected format: ${format}`;
344
+ }
345
+ console.log('[Rule.validate] DATETIME validation PASSED');
346
+ }
347
+ break;
348
+ }
202
349
  case 'custom': {
203
350
  const customResult = await rule.constraint(value, context);
204
351
  if (customResult === false) {
@@ -218,4 +365,23 @@ export class Rule {
218
365
  isRequired() {
219
366
  return this._required;
220
367
  }
368
+ // Helper method to normalize objects for comparison (exclude _key)
369
+ normalizeForComparison(value) {
370
+ if (value === null || value === undefined) {
371
+ return value;
372
+ }
373
+ if (Array.isArray(value)) {
374
+ return value.map((item) => this.normalizeForComparison(item));
375
+ }
376
+ if (typeof value === 'object') {
377
+ const normalized = {};
378
+ for (const [key, val] of Object.entries(value)) {
379
+ if (key !== '_key') {
380
+ normalized[key] = this.normalizeForComparison(val);
381
+ }
382
+ }
383
+ return normalized;
384
+ }
385
+ return value;
386
+ }
221
387
  }