@aruvili/core 0.1.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/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +106 -0
- package/dist/cli.js.map +1 -0
- package/dist/core.test.d.ts +2 -0
- package/dist/core.test.d.ts.map +1 -0
- package/dist/core.test.js +192 -0
- package/dist/core.test.js.map +1 -0
- package/dist/ddl.d.ts +20 -0
- package/dist/ddl.d.ts.map +1 -0
- package/dist/ddl.js +218 -0
- package/dist/ddl.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/types/index.d.ts +61 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +19 -0
- package/dist/types/index.js.map +1 -0
- package/dist/validation.d.ts +10 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +144 -0
- package/dist/validation.js.map +1 -0
- package/package.json +21 -0
- package/src/cli.ts +105 -0
- package/src/core.test.ts +236 -0
- package/src/ddl.ts +246 -0
- package/src/index.ts +3 -0
- package/src/types/index.ts +92 -0
- package/src/validation.ts +170 -0
- package/tsconfig.json +10 -0
package/src/ddl.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { DocTypeDefinition, DocField, DatabaseColumnMeta } from './types/index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whitelist-validates an identifier to prevent SQL injection via dynamic column/table names.
|
|
5
|
+
* Only allows lowercase alphanumeric characters and underscores.
|
|
6
|
+
*/
|
|
7
|
+
function sanitizeIdentifier(name: string): string {
|
|
8
|
+
const sanitized = name.replace(/[^a-z0-9_]/g, '');
|
|
9
|
+
if (sanitized !== name.toLowerCase().replace(/[^a-z0-9_]/g, '')) {
|
|
10
|
+
throw new Error(`Unsafe SQL identifier detected: '${name}'`);
|
|
11
|
+
}
|
|
12
|
+
return sanitized;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Maps the DocType FieldType to a PostgreSQL database column type.
|
|
17
|
+
*/
|
|
18
|
+
export function mapFieldToPostgresType(field: DocField): string {
|
|
19
|
+
switch (field.fieldtype) {
|
|
20
|
+
case 'Select':
|
|
21
|
+
case 'Link':
|
|
22
|
+
return `VARCHAR(${field.max_length || 255})`;
|
|
23
|
+
case 'Text':
|
|
24
|
+
return `VARCHAR(${field.max_length || 255})`;
|
|
25
|
+
case 'Small Text':
|
|
26
|
+
return 'TEXT';
|
|
27
|
+
case 'Long Text':
|
|
28
|
+
return 'TEXT';
|
|
29
|
+
case 'Int':
|
|
30
|
+
return 'INTEGER';
|
|
31
|
+
case 'Float':
|
|
32
|
+
return 'DOUBLE PRECISION';
|
|
33
|
+
case 'Check':
|
|
34
|
+
return 'BOOLEAN';
|
|
35
|
+
case 'Date':
|
|
36
|
+
return 'DATE';
|
|
37
|
+
case 'Datetime':
|
|
38
|
+
return 'TIMESTAMP WITH TIME ZONE';
|
|
39
|
+
case 'Currency':
|
|
40
|
+
return 'NUMERIC(18, 4)';
|
|
41
|
+
case 'Table':
|
|
42
|
+
case 'Table MultiSelect':
|
|
43
|
+
// Child tables do not create direct columns in parent tables.
|
|
44
|
+
return '';
|
|
45
|
+
default:
|
|
46
|
+
throw new Error(`Unsupported field type: ${(field as any).fieldtype}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Standardize table name for a DocType.
|
|
52
|
+
* Enforces whitelist-safe identifier generation to prevent SQL injection.
|
|
53
|
+
*/
|
|
54
|
+
export function getTableName(doctypeName: string): string {
|
|
55
|
+
const sanitized = doctypeName.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
56
|
+
if (!sanitized || sanitized.length === 0) {
|
|
57
|
+
throw new Error(`Cannot generate table name from empty DocType name.`);
|
|
58
|
+
}
|
|
59
|
+
return `dt_${sanitized}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Builds a safe default value expression for SQL column definitions.
|
|
64
|
+
* Prevents SQL injection through default value interpolation.
|
|
65
|
+
*/
|
|
66
|
+
function buildDefaultClause(value: any): string {
|
|
67
|
+
if (value === undefined || value === null) return '';
|
|
68
|
+
|
|
69
|
+
if (typeof value === 'string') {
|
|
70
|
+
// Escape single quotes to prevent SQL injection
|
|
71
|
+
const escaped = value.replace(/'/g, "''").replace(/\\/g, '\\\\');
|
|
72
|
+
// Reject values containing SQL comment sequences or semicolons
|
|
73
|
+
if (escaped.includes('--') || escaped.includes(';') || escaped.includes('/*')) {
|
|
74
|
+
throw new Error(`Unsafe default value detected: '${value}'`);
|
|
75
|
+
}
|
|
76
|
+
return ` DEFAULT '${escaped}'`;
|
|
77
|
+
}
|
|
78
|
+
if (typeof value === 'boolean') {
|
|
79
|
+
return ` DEFAULT ${value ? 'TRUE' : 'FALSE'}`;
|
|
80
|
+
}
|
|
81
|
+
if (typeof value === 'number') {
|
|
82
|
+
if (!isFinite(value)) {
|
|
83
|
+
throw new Error(`Default value must be a finite number, got: ${value}`);
|
|
84
|
+
}
|
|
85
|
+
return ` DEFAULT ${value}`;
|
|
86
|
+
}
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generate DDL statement to create a table for a DocType.
|
|
92
|
+
*/
|
|
93
|
+
export function generateCreateTableDDL(definition: DocTypeDefinition): string[] {
|
|
94
|
+
const tableName = getTableName(definition.name);
|
|
95
|
+
const statements: string[] = [];
|
|
96
|
+
const columns: string[] = [];
|
|
97
|
+
|
|
98
|
+
// Every table has a primary key name and a globally unique UUIDv4 identifier
|
|
99
|
+
columns.push('name VARCHAR(255) PRIMARY KEY');
|
|
100
|
+
columns.push('uuid UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL');
|
|
101
|
+
|
|
102
|
+
// Standard audit fields
|
|
103
|
+
columns.push('created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()');
|
|
104
|
+
columns.push('updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()');
|
|
105
|
+
columns.push('created_by VARCHAR(255)');
|
|
106
|
+
columns.push('modified_by VARCHAR(255)');
|
|
107
|
+
columns.push('docstatus INTEGER DEFAULT 0'); // 0: Draft, 1: Submitted, 2: Cancelled
|
|
108
|
+
columns.push('version INTEGER DEFAULT 1'); // Optimistic concurrency control
|
|
109
|
+
|
|
110
|
+
if (definition.is_submittable) {
|
|
111
|
+
columns.push('amended_from VARCHAR(255)');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (definition.workflow) {
|
|
115
|
+
const wfCol = sanitizeIdentifier(definition.workflow.fieldname);
|
|
116
|
+
if (!definition.fields.some(f => f.fieldname === wfCol)) {
|
|
117
|
+
columns.push(`${wfCol} VARCHAR(255) DEFAULT '${definition.workflow.initial_state}'`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Relational structural columns for Child Tables
|
|
122
|
+
if (definition.istable) {
|
|
123
|
+
columns.push('parent VARCHAR(255) NOT NULL');
|
|
124
|
+
columns.push('parenttype VARCHAR(255) NOT NULL');
|
|
125
|
+
columns.push('parentfield VARCHAR(255) NOT NULL');
|
|
126
|
+
columns.push('idx INTEGER NOT NULL');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Custom metadata fields
|
|
130
|
+
for (const field of definition.fields) {
|
|
131
|
+
const pgType = mapFieldToPostgresType(field);
|
|
132
|
+
if (!pgType) continue; // Skip non-column fields like Table
|
|
133
|
+
|
|
134
|
+
const safeFieldname = sanitizeIdentifier(field.fieldname);
|
|
135
|
+
let colDef = `${safeFieldname} ${pgType}`;
|
|
136
|
+
|
|
137
|
+
if (field.required) {
|
|
138
|
+
colDef += ' NOT NULL';
|
|
139
|
+
}
|
|
140
|
+
if (field.unique) {
|
|
141
|
+
colDef += ' UNIQUE';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
colDef += buildDefaultClause(field.default);
|
|
145
|
+
columns.push(colDef);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
statements.push(`CREATE TABLE ${tableName} (\n ${columns.join(',\n ')}\n);`);
|
|
149
|
+
|
|
150
|
+
// Add indexing statements
|
|
151
|
+
if (definition.istable) {
|
|
152
|
+
statements.push(`CREATE INDEX IF NOT EXISTS idx_${tableName}_parent ON ${tableName} (parent, parenttype);`);
|
|
153
|
+
statements.push(`CREATE INDEX IF NOT EXISTS idx_${tableName}_idx ON ${tableName} (idx);`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Always index docstatus for workflow queries
|
|
157
|
+
statements.push(`CREATE INDEX IF NOT EXISTS idx_${tableName}_docstatus ON ${tableName} (docstatus);`);
|
|
158
|
+
// Always index created_at for sorting
|
|
159
|
+
statements.push(`CREATE INDEX IF NOT EXISTS idx_${tableName}_created_at ON ${tableName} (created_at);`);
|
|
160
|
+
|
|
161
|
+
if (definition.is_submittable) {
|
|
162
|
+
statements.push(`CREATE INDEX IF NOT EXISTS idx_${tableName}_amended_from ON ${tableName} (amended_from);`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (definition.workflow) {
|
|
166
|
+
const wfCol = sanitizeIdentifier(definition.workflow.fieldname);
|
|
167
|
+
statements.push(`CREATE INDEX IF NOT EXISTS idx_${tableName}_${wfCol} ON ${tableName} (${wfCol});`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const field of definition.fields) {
|
|
171
|
+
if (field.searchable || field.fieldtype === 'Link') {
|
|
172
|
+
const pgType = mapFieldToPostgresType(field);
|
|
173
|
+
if (pgType) {
|
|
174
|
+
const safeFieldname = sanitizeIdentifier(field.fieldname);
|
|
175
|
+
statements.push(`CREATE INDEX IF NOT EXISTS idx_${tableName}_${safeFieldname} ON ${tableName} (${safeFieldname});`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return statements;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Compare DB catalog columns with schema definition, generating ADD COLUMN alterations.
|
|
185
|
+
* Implements strict additive-only migration. NEVER drops or renames columns.
|
|
186
|
+
*/
|
|
187
|
+
export function generateAlterTableDDL(
|
|
188
|
+
definition: DocTypeDefinition,
|
|
189
|
+
existingColumns: DatabaseColumnMeta[]
|
|
190
|
+
): string[] {
|
|
191
|
+
const tableName = getTableName(definition.name);
|
|
192
|
+
const statements: string[] = [];
|
|
193
|
+
|
|
194
|
+
const existingNames = new Set(existingColumns.map(c => c.columnName.toLowerCase()));
|
|
195
|
+
|
|
196
|
+
// Ensure every table has a unique UUID column
|
|
197
|
+
if (!existingNames.has('uuid')) {
|
|
198
|
+
statements.push(`ALTER TABLE ${tableName} ADD COLUMN uuid UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Ensure submittable tables have amended_from column
|
|
202
|
+
if (definition.is_submittable && !existingNames.has('amended_from')) {
|
|
203
|
+
statements.push(`ALTER TABLE ${tableName} ADD COLUMN amended_from VARCHAR(255)`);
|
|
204
|
+
statements.push(`CREATE INDEX IF NOT EXISTS idx_${tableName}_amended_from ON ${tableName} (amended_from)`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Ensure workflow columns exist
|
|
208
|
+
if (definition.workflow) {
|
|
209
|
+
const wfCol = sanitizeIdentifier(definition.workflow.fieldname);
|
|
210
|
+
if (!existingNames.has(wfCol)) {
|
|
211
|
+
statements.push(`ALTER TABLE ${tableName} ADD COLUMN ${wfCol} VARCHAR(255) DEFAULT '${definition.workflow.initial_state}'`);
|
|
212
|
+
statements.push(`CREATE INDEX IF NOT EXISTS idx_${tableName}_${wfCol} ON ${tableName} (${wfCol})`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const field of definition.fields) {
|
|
217
|
+
const pgType = mapFieldToPostgresType(field);
|
|
218
|
+
if (!pgType) continue; // Skip Table fields
|
|
219
|
+
|
|
220
|
+
const safeFieldname = sanitizeIdentifier(field.fieldname);
|
|
221
|
+
|
|
222
|
+
if (!existingNames.has(safeFieldname)) {
|
|
223
|
+
let colDef = `ALTER TABLE ${tableName} ADD COLUMN ${safeFieldname} ${pgType}`;
|
|
224
|
+
|
|
225
|
+
const defaultClause = buildDefaultClause(field.default);
|
|
226
|
+
|
|
227
|
+
if (field.required) {
|
|
228
|
+
// Must provide default when adding NOT NULL to populated table
|
|
229
|
+
if (defaultClause) {
|
|
230
|
+
colDef += defaultClause;
|
|
231
|
+
}
|
|
232
|
+
colDef += ' NOT NULL';
|
|
233
|
+
} else {
|
|
234
|
+
colDef += defaultClause;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
statements.push(colDef);
|
|
238
|
+
|
|
239
|
+
if (field.searchable || field.fieldtype === 'Link') {
|
|
240
|
+
statements.push(`CREATE INDEX IF NOT EXISTS idx_${tableName}_${safeFieldname} ON ${tableName} (${safeFieldname});`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return statements;
|
|
246
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export type FieldType =
|
|
2
|
+
| 'Select'
|
|
3
|
+
| 'Text'
|
|
4
|
+
| 'Small Text'
|
|
5
|
+
| 'Long Text'
|
|
6
|
+
| 'Int'
|
|
7
|
+
| 'Float'
|
|
8
|
+
| 'Check' // Boolean
|
|
9
|
+
| 'Date'
|
|
10
|
+
| 'Datetime'
|
|
11
|
+
| 'Link' // Reference to another DocType
|
|
12
|
+
| 'Currency'
|
|
13
|
+
| 'Table' // Child Table (references a DocType with istable: true)
|
|
14
|
+
| 'Table MultiSelect';// Many-to-many relationship helper
|
|
15
|
+
|
|
16
|
+
export interface DocField {
|
|
17
|
+
fieldname: string; // Database column name (snake_case, unique within DocType)
|
|
18
|
+
label: string; // Human-readable form label
|
|
19
|
+
fieldtype: FieldType;
|
|
20
|
+
required?: boolean;
|
|
21
|
+
unique?: boolean;
|
|
22
|
+
options?: string; // For 'Select': comma-separated. For 'Link'/'Table': target DocType name
|
|
23
|
+
default?: any;
|
|
24
|
+
searchable?: boolean; // Index this field
|
|
25
|
+
read_only?: boolean; // Cannot be modified after creation
|
|
26
|
+
hidden?: boolean; // Not displayed in UI but stored in DB
|
|
27
|
+
max_length?: number; // Override default VARCHAR length
|
|
28
|
+
min_value?: number; // Min constraint for numeric types
|
|
29
|
+
max_value?: number; // Max constraint for numeric types
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PermissionRule {
|
|
33
|
+
role: string;
|
|
34
|
+
create: boolean;
|
|
35
|
+
read: boolean;
|
|
36
|
+
update: boolean;
|
|
37
|
+
delete: boolean;
|
|
38
|
+
submit?: boolean; // Can submit submittable documents (locks it)
|
|
39
|
+
cancel?: boolean; // Can cancel submitted documents (reverts it)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type NamingRule = 'Autoincrement' | 'UUID' | 'Expression' | 'NamingSeries';
|
|
43
|
+
|
|
44
|
+
export interface WorkflowTransition {
|
|
45
|
+
state: string; // Current state (e.g. "Draft")
|
|
46
|
+
action: string; // Triggering action (e.g. "Approve")
|
|
47
|
+
next_state: string; // Resulting state (e.g. "Approved")
|
|
48
|
+
allowed_roles: string[]; // Roles authorized to trigger this transition
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DocTypeWorkflow {
|
|
52
|
+
fieldname: string; // Database column storing state (e.g. "workflow_state")
|
|
53
|
+
initial_state: string; // Initial state value
|
|
54
|
+
transitions: WorkflowTransition[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DocTypeDefinition {
|
|
58
|
+
name: string; // Unique name (e.g. "Customer", "Sales Invoice")
|
|
59
|
+
istable?: boolean; // If true, this is a Child Table
|
|
60
|
+
is_submittable?: boolean; // If true, supports Submit/Cancel states
|
|
61
|
+
naming_rule?: NamingRule;
|
|
62
|
+
naming_series?: string; // E.g. "INV-.YYYY.-.#####" or "ACC-.#####"
|
|
63
|
+
title_field?: string; // Field to display when resolving Link fields (defaults to "name")
|
|
64
|
+
track_changes?: boolean; // Explicitly opt-in to audit tracking (default true)
|
|
65
|
+
max_attachments?: number; // Attachment limit
|
|
66
|
+
fields: DocField[];
|
|
67
|
+
permissions: PermissionRule[];
|
|
68
|
+
workflow?: DocTypeWorkflow;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DatabaseColumnMeta {
|
|
72
|
+
columnName: string;
|
|
73
|
+
dataType: string;
|
|
74
|
+
isNullable: boolean;
|
|
75
|
+
characterMaximumLength: number | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reserved SQL keywords that cannot be used as table or column identifiers.
|
|
80
|
+
*/
|
|
81
|
+
export const SQL_RESERVED_WORDS = new Set([
|
|
82
|
+
'select', 'insert', 'update', 'delete', 'drop', 'alter', 'create',
|
|
83
|
+
'table', 'index', 'from', 'where', 'join', 'order', 'group', 'having',
|
|
84
|
+
'limit', 'offset', 'union', 'all', 'and', 'or', 'not', 'null', 'true',
|
|
85
|
+
'false', 'primary', 'key', 'foreign', 'references', 'constraint',
|
|
86
|
+
'default', 'check', 'unique', 'cascade', 'set', 'into', 'values',
|
|
87
|
+
'returning', 'exists', 'between', 'like', 'in', 'is', 'as', 'on',
|
|
88
|
+
'begin', 'commit', 'rollback', 'grant', 'revoke', 'user', 'role',
|
|
89
|
+
'schema', 'database', 'trigger', 'function', 'procedure', 'view',
|
|
90
|
+
'sequence', 'serial', 'bigserial', 'text', 'integer', 'boolean',
|
|
91
|
+
'varchar', 'timestamp', 'date', 'numeric', 'uuid'
|
|
92
|
+
]);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { DocTypeDefinition, SQL_RESERVED_WORDS } from './types/index.js';
|
|
2
|
+
|
|
3
|
+
const RESERVED_FIELD_NAMES = new Set([
|
|
4
|
+
'name',
|
|
5
|
+
'uuid',
|
|
6
|
+
'created_at',
|
|
7
|
+
'updated_at',
|
|
8
|
+
'created_by',
|
|
9
|
+
'modified_by',
|
|
10
|
+
'docstatus',
|
|
11
|
+
'parent',
|
|
12
|
+
'parenttype',
|
|
13
|
+
'parentfield',
|
|
14
|
+
'idx'
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const VALID_FIELDNAME_REGEX = /^[a-z][a-z0-9_]*$/;
|
|
18
|
+
const VALID_DOCTYPE_NAME_REGEX = /^[A-Za-z][A-Za-z0-9 _-]*$/;
|
|
19
|
+
const MAX_DOCTYPE_NAME_LENGTH = 100;
|
|
20
|
+
const MAX_FIELD_COUNT = 200;
|
|
21
|
+
const MAX_FIELDNAME_LENGTH = 63; // PostgreSQL identifier limit
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validates a DocTypeDefinition schema structure for database safety and logical compliance.
|
|
25
|
+
* Enterprise-grade: checks SQL injection vectors, PostgreSQL limits, and structural integrity.
|
|
26
|
+
*/
|
|
27
|
+
export function validateDocType(definition: DocTypeDefinition): { valid: boolean; errors: string[] } {
|
|
28
|
+
const errors: string[] = [];
|
|
29
|
+
|
|
30
|
+
// === DocType Name Validations ===
|
|
31
|
+
if (!definition.name || definition.name.trim() === '') {
|
|
32
|
+
errors.push('DocType name is required.');
|
|
33
|
+
} else {
|
|
34
|
+
if (definition.name.length > MAX_DOCTYPE_NAME_LENGTH) {
|
|
35
|
+
errors.push(`DocType name '${definition.name}' exceeds maximum length of ${MAX_DOCTYPE_NAME_LENGTH} characters.`);
|
|
36
|
+
}
|
|
37
|
+
if (!VALID_DOCTYPE_NAME_REGEX.test(definition.name)) {
|
|
38
|
+
errors.push(`DocType name '${definition.name}' contains invalid characters. Allowed: letters, digits, spaces, hyphens, underscores.`);
|
|
39
|
+
}
|
|
40
|
+
// Block SQL reserved words as DocType names (prevents injection via table names)
|
|
41
|
+
if (SQL_RESERVED_WORDS.has(definition.name.toLowerCase().trim())) {
|
|
42
|
+
errors.push(`DocType name '${definition.name}' is a reserved SQL keyword and cannot be used.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// === Fields Array ===
|
|
47
|
+
if (!definition.fields || !Array.isArray(definition.fields)) {
|
|
48
|
+
errors.push('DocType must contain an array of fields.');
|
|
49
|
+
return { valid: false, errors };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (definition.fields.length > MAX_FIELD_COUNT) {
|
|
53
|
+
errors.push(`DocType '${definition.name}' exceeds maximum of ${MAX_FIELD_COUNT} fields.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const seenFields = new Set<string>();
|
|
57
|
+
|
|
58
|
+
for (const field of definition.fields) {
|
|
59
|
+
const { fieldname, fieldtype } = field;
|
|
60
|
+
|
|
61
|
+
// --- Fieldname presence ---
|
|
62
|
+
if (!fieldname || fieldname.trim() === '') {
|
|
63
|
+
errors.push(`Fieldname is missing for a field in ${definition.name}.`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- Fieldname format ---
|
|
68
|
+
if (!VALID_FIELDNAME_REGEX.test(fieldname)) {
|
|
69
|
+
errors.push(
|
|
70
|
+
`Fieldname '${fieldname}' in ${definition.name} is invalid. It must be snake_case, start with a lowercase letter, and contain only lowercase letters, digits, and underscores.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- Fieldname length (PostgreSQL limit) ---
|
|
75
|
+
if (fieldname.length > MAX_FIELDNAME_LENGTH) {
|
|
76
|
+
errors.push(`Fieldname '${fieldname}' exceeds PostgreSQL identifier limit of ${MAX_FIELDNAME_LENGTH} characters.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- Reserved names ---
|
|
80
|
+
if (RESERVED_FIELD_NAMES.has(fieldname)) {
|
|
81
|
+
errors.push(
|
|
82
|
+
`Fieldname '${fieldname}' in ${definition.name} is reserved by the framework database architecture.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- SQL reserved words as column names ---
|
|
87
|
+
if (SQL_RESERVED_WORDS.has(fieldname.toLowerCase())) {
|
|
88
|
+
errors.push(
|
|
89
|
+
`Fieldname '${fieldname}' in ${definition.name} is a reserved SQL keyword and cannot be used as a column name.`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- Duplicates ---
|
|
94
|
+
if (seenFields.has(fieldname)) {
|
|
95
|
+
errors.push(`Duplicate fieldname '${fieldname}' declared in ${definition.name}.`);
|
|
96
|
+
}
|
|
97
|
+
seenFields.add(fieldname);
|
|
98
|
+
|
|
99
|
+
// --- Fieldtype presence ---
|
|
100
|
+
if (!fieldtype) {
|
|
101
|
+
errors.push(`Field '${fieldname}' is missing fieldtype in ${definition.name}.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// === Specific field validations ===
|
|
105
|
+
if (fieldtype === 'Select') {
|
|
106
|
+
if (!field.options || field.options.trim() === '') {
|
|
107
|
+
errors.push(`Select field '${fieldname}' in ${definition.name} requires options (comma-separated list).`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (fieldtype === 'Link' || fieldtype === 'Table') {
|
|
112
|
+
if (!field.options || field.options.trim() === '') {
|
|
113
|
+
errors.push(`Relationship field '${fieldname}' of type '${fieldtype}' in ${definition.name} requires target option.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Numeric constraints validation ---
|
|
118
|
+
if (field.min_value !== undefined && field.max_value !== undefined) {
|
|
119
|
+
if (field.min_value > field.max_value) {
|
|
120
|
+
errors.push(`Field '${fieldname}' in ${definition.name}: min_value (${field.min_value}) cannot exceed max_value (${field.max_value}).`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- max_length validation ---
|
|
125
|
+
if (field.max_length !== undefined) {
|
|
126
|
+
if (field.max_length <= 0 || field.max_length > 10485760) {
|
|
127
|
+
errors.push(`Field '${fieldname}' in ${definition.name}: max_length must be between 1 and 10485760.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// === Validate naming configuration ===
|
|
133
|
+
if (!definition.istable) {
|
|
134
|
+
const rule = definition.naming_rule || 'UUID';
|
|
135
|
+
if (rule === 'NamingSeries' && (!definition.naming_series || definition.naming_series.trim() === '')) {
|
|
136
|
+
errors.push(`DocType '${definition.name}' requires naming_series configuration if NamingSeries rule is selected.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// === Validate permissions structure ===
|
|
141
|
+
if (!definition.istable && (!definition.permissions || definition.permissions.length === 0)) {
|
|
142
|
+
errors.push(`DocType '${definition.name}' must define at least one permission rule (non-child tables require explicit access control).`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (definition.permissions) {
|
|
146
|
+
for (const perm of definition.permissions) {
|
|
147
|
+
if (!perm.role || perm.role.trim() === '') {
|
|
148
|
+
errors.push(`A permission rule in ${definition.name} is missing a role name.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Validate submittable config
|
|
154
|
+
if (definition.is_submittable && definition.istable) {
|
|
155
|
+
errors.push(`DocType '${definition.name}' cannot be both a child table (istable) and submittable.`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Validate title_field references an existing field
|
|
159
|
+
if (definition.title_field) {
|
|
160
|
+
const fieldExists = definition.fields.some(f => f.fieldname === definition.title_field);
|
|
161
|
+
if (!fieldExists) {
|
|
162
|
+
errors.push(`title_field '${definition.title_field}' in ${definition.name} does not reference an existing field.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
valid: errors.length === 0,
|
|
168
|
+
errors
|
|
169
|
+
};
|
|
170
|
+
}
|