@simtlix/simfinity-postgres 3.2.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.
@@ -0,0 +1,142 @@
1
+ import { identifier as q, qualified, literal, generatedName, invalid } from './sql.js';
2
+
3
+ // JavaScript uses astronomical ISO years (including zero and signed extended
4
+ // years). Validate their Gregorian components without PostgreSQL's AD/BC parser
5
+ // or timestamp range. Mapping years modulo 400 preserves leap-year rules.
6
+ const dateTimeValidation = `DECLARE parts integer[]; BEGIN
7
+ IF jsonb_typeof($1) <> 'string' THEN RETURN false; END IF;
8
+ parts := regexp_match($1 #>> '{}', '^([0-9]{4}|[+-][0-9]{6})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})[.]([0-9]{3})Z$')::integer[];
9
+ IF parts IS NULL OR ($1 #>> '{}') LIKE '-000000-%' THEN RETURN false; END IF;
10
+ PERFORM make_date(2000 + ((parts[1] % 400 + 400) % 400), parts[2], parts[3]);
11
+ RETURN parts[4] BETWEEN 0 AND 23 AND parts[5] BETWEEN 0 AND 59 AND parts[6] BETWEEN 0 AND 59
12
+ AND parts >= ARRAY[-271821, 4, 20, 0, 0, 0, 0] AND parts <= ARRAY[275760, 9, 13, 0, 0, 0, 0];
13
+ END;`;
14
+
15
+ /** Database-side validation and multikey maintenance. All bodies resolve names in pg_catalog. */
16
+ export const constraintBuilder = (schema, tables, { addTable, addColumn, addReference, addIndex, addCheck }) => {
17
+ const functions = []; const triggers = []; const uniquePaths = [];
18
+ const fn = (name, args, returns, body, immutable = false) => {
19
+ if (functions.some((item) => item.name === name)) invalid(`Function name collision: ${name}`);
20
+ const result = { name, arguments: args, returns, language: 'plpgsql', volatility: immutable ? 'IMMUTABLE' : 'VOLATILE', configuration: ['search_path=pg_catalog', 'TimeZone=UTC', 'DateStyle=ISO, YMD'], body };
21
+ functions.push(result);
22
+ return qualified(schema, name);
23
+ };
24
+ const jsonValidator = (table, field, path = []) => {
25
+ const name = generatedName(table.name, ...path, field.name, 'json_valid');
26
+ let test;
27
+ if (field.list) {
28
+ const item = jsonValidator(table, { ...field, name: field.name, list: false, required: field.itemRequired }, [...path, field.name, 'item']);
29
+ test = `IF jsonb_typeof($1) <> 'array' THEN RETURN false; END IF;
30
+ RETURN NOT EXISTS (SELECT 1 FROM jsonb_array_elements($1) AS e(value) WHERE NOT ${item}(e.value));`;
31
+ } else if (field.kind === 'embedded') {
32
+ const checks = field.fields.map((child) => `${jsonValidator(table, child, [...path, field.name])}($1 -> ${literal(child.storageName)})`);
33
+ test = `IF jsonb_typeof($1) <> 'object' THEN RETURN false; END IF; RETURN ${checks.join(' AND ') || 'true'};`;
34
+ } else {
35
+ const value = '($1 #>> \'{}\')';
36
+ const conditions = {
37
+ String: 'jsonb_typeof($1) = \'string\'', Boolean: 'jsonb_typeof($1) = \'boolean\'',
38
+ Enum: `jsonb_typeof($1) = 'string' AND ${value} = ANY (ARRAY[${(field.values || []).map(literal).join(', ')}]::text[])`,
39
+ ID: `jsonb_typeof($1) = 'string' AND ${value} ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'`,
40
+ Int: `jsonb_typeof($1) = 'number' AND ${value}::numeric = trunc(${value}::numeric) AND ${value}::numeric BETWEEN -2147483648 AND 2147483647`,
41
+ Float: `jsonb_typeof($1) = 'number' AND ${value}::double precision BETWEEN '-1.7976931348623157e308'::double precision AND '1.7976931348623157e308'::double precision`,
42
+ };
43
+ test = field.scalar === 'DateTime' ? dateTimeValidation : `RETURN ${conditions[field.scalar]};`;
44
+ }
45
+ return fn(name, ['jsonb'], 'boolean', `BEGIN
46
+ IF $1 IS NULL OR $1 = 'null'::jsonb THEN RETURN ${!field.required}; END IF;
47
+ ${test}
48
+ EXCEPTION WHEN data_exception THEN RETURN false;
49
+ END`, true);
50
+ };
51
+ const addJSON = (table, field) => addCheck(table, field.storageName, 'shape', `${table.ownership?.nullableItems ? `(NOT ${q('__item_present')}) OR ` : ''}${jsonValidator(table, field)}(${q(field.storageName)})`);
52
+ const addUnique = (table, field, type) => { uniquePaths.push({ table, field, type }); };
53
+ const chain = (table) => {
54
+ const result = [table];
55
+ while (result[0].ownership) result.unshift(tables.find((item) => item.name === result[0].ownership.ownerTable));
56
+ return result;
57
+ };
58
+ const joins = (path, left = false) => path.map((table, index) => {
59
+ if (!index) return `${qualified(schema, table.name)} t0`;
60
+ const parent = path[index - 1];
61
+ return `${left ? 'LEFT ' : ''}JOIN ${qualified(schema, table.name)} t${index} ON t${index}.__owner_id = t${index - 1}.${q(parent.primaryKey.columns[0])}${left ? ` AND t${index - 1}.${q(table.ownership.stateColumn)} = 'present'${parent.ownership?.nullableItems ? ` AND t${index - 1}.__item_present` : ''}` : ''}`;
62
+ }).join(' ');
63
+ const finish = () => {
64
+ const sources = [...tables];
65
+ const roots = sources.filter((table) => !table.ownership && (sources.some((owned) => owned.ownership && chain(owned)[0] === table) || uniquePaths.some((key) => chain(key.table)[0] === table)));
66
+ const maintenance = [];
67
+ for (const root of roots) {
68
+ const ownedTables = sources.filter((table) => table.ownership && chain(table)[0] === root);
69
+ const guard = addTable(generatedName(root.name, 'guard'));
70
+ guard.auxiliary = { rootTable: root.name, kind: 'guard' };
71
+ guard.columns = [{ name: '__owner_id', type: 'uuid', nullable: false }, { name: 'version', type: 'bigint', nullable: false, default: '0' }];
72
+ guard.primaryKey.columns = ['__owner_id'];
73
+ addReference(guard, '__owner_id', root.name, 'id', true);
74
+ let checks = '';
75
+ for (const table of ownedTables) {
76
+ const parentPath = chain(table).slice(0, -1); const parent = parentPath.at(-1); const alias = `t${parentPath.length - 1}`;
77
+ const children = `SELECT * FROM ${qualified(schema, table.name)} c WHERE c.__owner_id = ${alias}.${q(parent.primaryKey.columns[0])}`;
78
+ const invalidCount = table.ownership.list ? 'counts.n > 0 AND (counts.lo <> 0 OR counts.hi <> counts.n - 1)' : 'counts.n <> 1';
79
+ checks += `
80
+ IF EXISTS (SELECT 1 FROM ${joins(parentPath)} CROSS JOIN LATERAL (SELECT count(*) n, ${table.ownership.list ? 'min(c.__position) lo, max(c.__position) hi' : '0 lo, 0 hi'} FROM (${children}) c) counts
81
+ WHERE t0.id = $1 AND ((${alias}.${q(table.ownership.stateColumn)} <> 'present' AND counts.n <> 0) OR (${alias}.${q(table.ownership.stateColumn)} = 'present' AND (${invalidCount})))) THEN
82
+ RAISE EXCEPTION 'Invalid owned shape: %', ${literal(table.name)} USING ERRCODE = '23514'; END IF;`;
83
+ if (table.ownership.nullableItems) {
84
+ const nested = ownedTables.filter((item) => item.ownership.ownerTable === table.name);
85
+ const payload = table.columns.filter((column) => !column.name.startsWith('__')).map((column) => `c.${q(column.name)} IS NOT NULL`);
86
+ for (const column of table.columns.filter((item) => item.presenceColumn)) payload.push(`c.${q(column.presenceColumn)}`);
87
+ for (const child of nested) payload.push(`c.${q(child.ownership.stateColumn)} <> 'missing'`, `EXISTS (SELECT 1 FROM ${qualified(schema, child.name)} x WHERE x.__owner_id = c.__id)`);
88
+ if (payload.length) checks += `
89
+ IF EXISTS (SELECT 1 FROM ${joins(chain(table))} JOIN ${qualified(schema, table.name)} c ON c.__id = t${parentPath.length}.__id WHERE t0.id = $1 AND NOT c.__item_present AND (${payload.join(' OR ')})) THEN
90
+ RAISE EXCEPTION 'Null owned item has payload: %', ${literal(table.name)} USING ERRCODE = '23514'; END IF;`;
91
+ }
92
+ }
93
+ const validateName = generatedName(root.name, 'owned_valid');
94
+ const validate = fn(validateName, ['uuid'], 'void', `BEGIN${checks}\nEND`);
95
+ let rebuild = '';
96
+ for (const key of uniquePaths.filter((item) => chain(item.table)[0] === root)) {
97
+ const path = chain(key.table); const alias = `t${path.length - 1}`;
98
+ const column = `${alias}.${q(key.field.storageName)}`;
99
+ const source = key.table.ownership?.nullableItems ? `CASE WHEN ${alias}.__item_present THEN ${column} END` : column;
100
+ const value = key.field.list ? 'v.value' : source;
101
+ const kind = `CASE ${key.field.list ? `WHEN cardinality(${source}) = 0 THEN 'empty' ` : ''}WHEN ${value} IS NULL THEN 'null' ELSE 'value' END`;
102
+ const query = `SELECT DISTINCT t0.id AS __owner_id, ${kind}::text COLLATE pg_catalog."C" AS kind, ${value} AS value FROM ${joins(path, true)}${key.field.list ? ` LEFT JOIN LATERAL unnest(${source}) v(value) ON true` : ''} WHERE t0.id = $1`;
103
+ const storage = addTable(generatedName(key.table.name, key.field.name, 'keys'));
104
+ storage.uniqueKeys = { rootTable: root.name, sourceTable: key.table.name, field: key.field.storageName };
105
+ storage.auxiliary = { rootTable: root.name, kind: 'uniqueKeys' };
106
+ addColumn(storage, { name: '__owner_id', type: 'uuid', nullable: false });
107
+ addColumn(storage, { name: 'kind', type: 'text', nullable: false });
108
+ addColumn(storage, { name: 'value', type: key.type.replace(/\[\]$/, ''), nullable: true });
109
+ addReference(storage, '__owner_id', root.name, 'id', true);
110
+ addIndex(storage, ['kind', 'value'], true);
111
+ addCheck(storage, 'kind', 'key', '((kind = \'value\'::text) AND (value IS NOT NULL)) OR ((kind = ANY (ARRAY[\'null\'::text, \'empty\'::text])) AND (value IS NULL))');
112
+ rebuild += `\n DELETE FROM ${qualified(schema, storage.name)} WHERE __owner_id = $1;\n INSERT INTO ${qualified(schema, storage.name)} (__owner_id, kind, value) ${query};`;
113
+ }
114
+ const refreshName = generatedName(root.name, 'refresh');
115
+ const refresh = fn(refreshName, ['uuid'], 'void', `BEGIN\n PERFORM ${validate}($1);${rebuild}\nEND`);
116
+ maintenance.push({ rootTable: root.name, guardTable: guard.name, validateFunction: validateName, refreshFunction: refreshName });
117
+ // Touch a real shared tuple at write time, not only at deferred-check time.
118
+ // RR writers must conflict on its version; RC writers then refresh using a new
119
+ // command snapshot after acquiring it. Lock-only reads permit stale RR keys.
120
+ for (const table of [root, ...ownedTables]) {
121
+ const path = chain(table);
122
+ const owner = (record) => table === root ? `${record}.id` : `(SELECT t0.id FROM ${joins(path.slice(0, -1))} WHERE t${path.length - 2}.${q(path.at(-2).primaryKey.columns[0])} = ${record}.__owner_id)`;
123
+ for (const deferred of [false, true]) {
124
+ const name = generatedName(table.name, deferred ? 'deferred' : 'touch');
125
+ const action = deferred ? `PERFORM ${refresh}(__root_id);` : `INSERT INTO ${qualified(schema, guard.name)} (__owner_id) SELECT __root_id WHERE EXISTS (SELECT 1 FROM ${qualified(schema, root.name)} WHERE id = __root_id) ON CONFLICT (__owner_id) DO UPDATE SET version = ${q(guard.name)}.version + 1;`;
126
+ fn(name, [], 'trigger', `DECLARE __root_id uuid; __old_root_id uuid; BEGIN
127
+ IF TG_OP <> 'INSERT' THEN __old_root_id := ${owner('OLD')}; END IF;
128
+ IF TG_OP <> 'DELETE' THEN __root_id := ${owner('NEW')}; END IF;
129
+ IF __old_root_id IS NOT NULL AND __old_root_id IS DISTINCT FROM __root_id THEN
130
+ DECLARE __new_root_id uuid := __root_id; BEGIN __root_id := __old_root_id; ${action} __root_id := __new_root_id; END;
131
+ END IF;
132
+ IF __root_id IS NOT NULL THEN ${action} END IF;
133
+ RETURN NULL;
134
+ END`);
135
+ triggers.push({ name, table: table.name, function: name, constraint: deferred, deferrable: deferred, initiallyDeferred: deferred });
136
+ }
137
+ }
138
+ }
139
+ return { functions, triggers, maintenance };
140
+ };
141
+ return { addJSON, addUnique, finish };
142
+ };
@@ -0,0 +1,29 @@
1
+ import { identifier, qualified, columnsSQL, literal } from './sql.js';
2
+
3
+ export const createTableSQL = (schema, table) => {
4
+ const columns = table.columns.map((column) => `${identifier(column.name)} ${column.type}${column.collation ? ` COLLATE ${qualified('pg_catalog', column.collation)}` : ''}${column.nullable ? '' : ' NOT NULL'}${column.default ? ` DEFAULT ${column.default}` : ''}`);
5
+ columns.push(`CONSTRAINT ${identifier(table.primaryKey.name)} PRIMARY KEY (${columnsSQL(table.primaryKey.columns)})`);
6
+ for (const check of table.checks) columns.push(`CONSTRAINT ${identifier(check.name)} CHECK (${check.expression})`);
7
+ return `CREATE TABLE ${qualified(schema, table.name)} (\n ${columns.join(',\n ')}\n)`;
8
+ };
9
+ export const createForeignKeySQL = (schema, table, key) => `ALTER TABLE ${qualified(schema, table.name)} ADD CONSTRAINT ${identifier(key.name)} FOREIGN KEY (${columnsSQL(key.columns)}) REFERENCES ${qualified(schema, key.targetTable)} (${columnsSQL(key.targetColumns)}) ON UPDATE ${key.onUpdate} ON DELETE ${key.onDelete} ${key.deferrable ? `DEFERRABLE INITIALLY ${key.initiallyDeferred ? 'DEFERRED' : 'IMMEDIATE'}` : 'NOT DEFERRABLE'}`;
10
+ export const createIndexSQL = (schema, table, index) => `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${identifier(index.name)} ON ${qualified(schema, table.name)} (${columnsSQL(index.columns)})${index.nullsNotDistinct ? ' NULLS NOT DISTINCT' : ''}`;
11
+
12
+ export const createFunctionSQL = (schema, fn) => {
13
+ const settings = fn.configuration.map((setting) => {
14
+ const position = setting.indexOf('=');
15
+ return `SET ${identifier(setting.slice(0, position))} TO ${literal(setting.slice(position + 1))}`;
16
+ }).join(' ');
17
+ return `CREATE FUNCTION ${qualified(schema, fn.name)}(${fn.arguments.join(', ')}) RETURNS ${fn.returns} LANGUAGE ${fn.language} ${fn.volatility} CALLED ON NULL INPUT SECURITY INVOKER PARALLEL UNSAFE ${settings} AS ${literal(fn.body)}`;
18
+ };
19
+ export const createTriggerSQL = (schema, trigger) => `CREATE ${trigger.constraint ? 'CONSTRAINT ' : ''}TRIGGER ${identifier(trigger.name)} AFTER INSERT OR UPDATE OR DELETE ON ${qualified(schema, trigger.table)} ${trigger.constraint ? 'DEFERRABLE INITIALLY DEFERRED ' : ''}FOR EACH ROW EXECUTE FUNCTION ${qualified(schema, trigger.function)}()`;
20
+
21
+ /** Tables/primary keys precede all FKs so forward and cyclic references work. */
22
+ export const compileDatabaseSchema = (description) => [
23
+ `CREATE SCHEMA IF NOT EXISTS ${identifier(description.schema)}`,
24
+ ...(description.functions || []).map((fn) => createFunctionSQL(description.schema, fn)),
25
+ ...description.tables.map((table) => createTableSQL(description.schema, table)),
26
+ ...description.tables.flatMap((table) => table.foreignKeys.map((key) => createForeignKeySQL(description.schema, table, key))),
27
+ ...description.tables.flatMap((table) => table.indexes.map((index) => createIndexSQL(description.schema, table, index))),
28
+ ...(description.triggers || []).map((trigger) => createTriggerSQL(description.schema, trigger)),
29
+ ];
@@ -0,0 +1,138 @@
1
+ import { describeModels } from '@simtlix/simfinity-core';
2
+ import { queryFunctions } from '../query/functions.js';
3
+ import { constraintBuilder } from './constraints.js';
4
+ import { identifier, literal, generatedName, invalid } from './sql.js';
5
+
6
+ const scalarTypes = { ID: 'uuid', String: 'text', Enum: 'text', Int: 'integer', Float: 'double precision', Boolean: 'boolean', DateTime: 'timestamp with time zone' };
7
+ const hasOwnedFields = (fields) => fields.some((field) => field.kind === 'reference' || field.unique || (field.fields && hasOwnedFields(field.fields)));
8
+
9
+ /** Compile GraphQL storage metadata into a serializable PostgreSQL description. */
10
+ export const describeDatabase = (registrations, { schema = 'public' } = {}) => {
11
+ identifier(schema);
12
+ const { entities } = describeModels(registrations);
13
+ const tables = [];
14
+ const names = new Set();
15
+ const addTable = (name, ownership) => {
16
+ identifier(name);
17
+ if (names.has(name)) invalid(`Table name collision: ${name}`);
18
+ names.add(name);
19
+ const key = ownership ? '__id' : 'id';
20
+ const table = {
21
+ name, columns: [{ name: key, type: 'uuid', nullable: false, default: 'gen_random_uuid()' }],
22
+ primaryKey: { name: generatedName(name, 'pk'), columns: [key] },
23
+ foreignKeys: [], indexes: [], checks: [],
24
+ ...(ownership ? { ownership } : {}),
25
+ };
26
+ tables.push(table);
27
+ return table;
28
+ };
29
+ const addColumn = (table, column) => {
30
+ identifier(column.name);
31
+ if (table.columns.some((item) => item.name === column.name)) invalid(`Column collision: ${table.name}.${column.name}`);
32
+ if (column.type === 'text' || column.type === 'text[]') column.collation = 'C';
33
+ table.columns.push(column);
34
+ };
35
+ const addIndex = (table, columns, unique = false) => {
36
+ if (table.indexes.some((index) => JSON.stringify(index.columns) === JSON.stringify(columns) && index.unique === unique)) return;
37
+ table.indexes.push({ name: generatedName(table.name, ...columns, unique ? 'uq' : 'idx'), columns, unique, nullsNotDistinct: unique });
38
+ };
39
+ const addCheck = (table, field, suffix, expression) => {
40
+ table.checks.push({ name: generatedName(table.name, field, suffix), expression });
41
+ };
42
+ const addReference = (table, column, targetTable, targetColumn = 'id', owned = false) => {
43
+ table.foreignKeys.push({
44
+ name: generatedName(table.name, column, 'fk'), columns: [column], targetTable, targetColumns: [targetColumn],
45
+ onDelete: owned ? 'CASCADE' : 'NO ACTION', onUpdate: 'NO ACTION', deferrable: true, initiallyDeferred: false,
46
+ });
47
+ addIndex(table, [column]);
48
+ };
49
+ const constraints = constraintBuilder(schema, tables, { addTable, addColumn, addReference, addIndex, addCheck });
50
+ const fieldsInto = (table, fields, path = []) => {
51
+ for (const field of fields) {
52
+ if (field.kind === 'collection') continue;
53
+ if (field.kind === 'embedded' && field.unique) invalid(`Whole embedded-object uniqueness is not supported: ${table.name}.${field.name}`);
54
+ if (!table.ownership && ['id', '_id'].includes(field.name)) {
55
+ if (field.kind !== 'scalar' || field.list || !['ID', 'String'].includes(field.scalar)) invalid(`Reserved identity field ${table.name}.${field.name} must be a scalar ID or String`);
56
+ continue;
57
+ }
58
+ if (field.storageName.startsWith('__')) invalid(`Reserved private column name ${table.name}.${field.storageName}`);
59
+ const conditional = table.ownership?.nullableItems === true;
60
+ const required = field.required && !conditional;
61
+ if (field.kind === 'embedded' && hasOwnedFields(field.fields)) {
62
+ // State preserves absent/null/empty values when the runtime reassembles nested objects.
63
+ const stateColumn = `__${field.name}_state`;
64
+ addColumn(table, { name: stateColumn, type: 'text', nullable: false, default: '\'missing\'::text' });
65
+ const values = field.required && !conditional ? ['present'] : ['missing', 'null', 'present'];
66
+ if (conditional && field.required) addCheck(table, stateColumn, 'required', `(NOT ${identifier('__item_present')}) OR (${identifier(stateColumn)} = 'present'::text)`);
67
+ addCheck(table, stateColumn, 'state', `${identifier(stateColumn)} = ANY (ARRAY[${values.map((v) => `${literal(v)}::text`).join(', ')}])`);
68
+ const owned = addTable(generatedName(table.name, field.name), {
69
+ ownerTable: table.name, ownerColumn: table.primaryKey.columns[0], field: field.name,
70
+ path: [...path, field.name], list: field.list, required: field.required,
71
+ nullableItems: field.list && !field.itemRequired, stateColumn,
72
+ });
73
+ addColumn(owned, { name: '__owner_id', type: 'uuid', nullable: false });
74
+ addReference(owned, '__owner_id', table.name, table.primaryKey.columns[0], true);
75
+ if (field.list) {
76
+ addColumn(owned, { name: '__position', type: 'integer', nullable: false });
77
+ addCheck(owned, '__position', 'nonnegative', `${identifier('__position')} >= 0`);
78
+ addIndex(owned, ['__owner_id', '__position'], true);
79
+ if (!field.itemRequired) addColumn(owned, { name: '__item_present', type: 'boolean', nullable: false, default: 'true' });
80
+ } else addIndex(owned, ['__owner_id'], true);
81
+ fieldsInto(owned, field.fields, [...path, field.name]);
82
+ continue;
83
+ }
84
+ const type = field.kind === 'embedded' ? 'jsonb' : field.kind === 'reference' ? 'uuid' : `${scalarTypes[field.scalar]}${field.list ? '[]' : ''}`;
85
+ if (!type || type.startsWith('undefined')) invalid(`Unsupported scalar at ${table.name}.${field.name}`);
86
+ const presenceColumn = table.ownership || field.kind === 'embedded' ? generatedName('__field', field.name, 'present') : null;
87
+ addColumn(table, { name: field.storageName, type, nullable: !required, ...(presenceColumn ? { presenceColumn } : {}) });
88
+ if (presenceColumn) addColumn(table, { name: presenceColumn, type: 'boolean', nullable: false, default: 'false' });
89
+ if (conditional && field.required) addCheck(table, field.storageName, 'required', `(NOT ${identifier('__item_present')}) OR (${identifier(field.storageName)} IS NOT NULL)`);
90
+ if (field.kind === 'reference') addReference(table, field.storageName, field.target);
91
+ if (field.scalar === 'ID') addIndex(table, [field.storageName]);
92
+ if (field.kind === 'embedded') constraints.addJSON(table, field);
93
+ if (field.unique) {
94
+ if (field.list || table.ownership) constraints.addUnique(table, field, type);
95
+ else addIndex(table, [field.storageName], true);
96
+ }
97
+ if (field.scalar === 'Enum') {
98
+ const values = `ARRAY[${field.values.map((value) => `${literal(value)}::text`).join(', ')}]`;
99
+ const expression = field.list
100
+ ? `array_remove(${identifier(field.storageName)}, NULL::text) <@ ${values}`
101
+ : `${identifier(field.storageName)} = ANY (${values})`;
102
+ addCheck(table, field.storageName, 'enum', expression);
103
+ }
104
+ if (field.kind === 'scalar' && field.list && field.itemRequired) {
105
+ addCheck(table, field.storageName, 'items', `array_position(${identifier(field.storageName)}, NULL::${scalarTypes[field.scalar]}) IS NULL`);
106
+ }
107
+ }
108
+ };
109
+ for (const entity of entities) addTable(entity.name);
110
+ for (const entity of entities) {
111
+ const table = tables.find((item) => item.name === entity.name);
112
+ fieldsInto(table, entity.fields);
113
+ for (const index of entity.indexes) {
114
+ const columns = index.fields.map((name) => {
115
+ const field = entity.fields.find((item) => item.name === name || item.storageName === name);
116
+ if (!field || !['scalar', 'reference'].includes(field.kind) || field.list) invalid(`Index ${entity.name}.${name} must refer to a stored scalar or reference`);
117
+ return field.name === '_id' ? 'id' : field.storageName;
118
+ });
119
+ addIndex(table, columns, index.unique);
120
+ }
121
+ }
122
+ const generated = constraints.finish();
123
+ generated.functions.push(...queryFunctions(schema));
124
+ // PostgreSQL shares one namespace for table and index names.
125
+ const relationNames = new Set(tables.map((table) => table.name));
126
+ for (const table of tables) {
127
+ const constraints = new Set();
128
+ for (const constraint of [table.primaryKey, ...table.foreignKeys, ...table.checks]) {
129
+ if (constraints.has(constraint.name)) invalid(`Constraint name collision: ${table.name}.${constraint.name}`);
130
+ constraints.add(constraint.name);
131
+ }
132
+ for (const index of [table.primaryKey, ...table.indexes]) {
133
+ if (relationNames.has(index.name)) invalid(`Index name collision: ${index.name}`);
134
+ relationNames.add(index.name);
135
+ }
136
+ }
137
+ return { schema, tables: tables.sort((a, b) => a.name.localeCompare(b.name)), ...generated };
138
+ };
@@ -0,0 +1,271 @@
1
+ import { SimfinityError } from '@simtlix/simfinity-core';
2
+ import { identifier, qualified } from './sql.js';
3
+ import { createTableSQL, createForeignKeySQL, createIndexSQL, createFunctionSQL, createTriggerSQL } from './ddl.js';
4
+
5
+ const mismatch = (object, reason) => {
6
+ throw new SimfinityError(`Schema mismatch at ${object}: ${reason}. Apply an explicit migration before startup.`, 'SCHEMA_MISMATCH', 409);
7
+ };
8
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
9
+
10
+ // Ignore renderer whitespace/optional quotes, keeping string contents and expression grouping.
11
+ const normalizeExpression = (expression) => {
12
+ if (expression == null) return null;
13
+ const tokens = expression.match(/'(?:[^']|'')*'|"(?:[^"]|"")*"|\s+|./gs) || [];
14
+ let value = tokens.map((token) => {
15
+ if (token.startsWith('\'')) return token;
16
+ if (/^"[a-z_][a-z0-9_]*"$/.test(token)) return token.slice(1, -1);
17
+ return /^\s+$/.test(token) ? '' : token;
18
+ }).join('');
19
+ while (value.startsWith('(') && value.endsWith(')')) {
20
+ const parts = value.match(/'(?:[^']|'')*'|"(?:[^"]|"")*"|./gs);
21
+ let depth = 0;
22
+ let encloses = true;
23
+ for (let i = 0; i < parts.length; i += 1) {
24
+ if (parts[i] === '(') depth += 1;
25
+ if (parts[i] === ')') depth -= 1;
26
+ if (depth === 0 && i < parts.length - 1) { encloses = false; break; }
27
+ }
28
+ if (!encloses) break;
29
+ value = value.slice(1, -1);
30
+ }
31
+ return value;
32
+ };
33
+
34
+ const readTable = async (client, schema, name) => {
35
+ const { rows: relations } = await client.query(`SELECT c.oid, c.relkind, c.relpersistence, c.relrowsecurity, c.relforcerowsecurity
36
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = $1 AND c.relname = $2`, [schema, name]);
37
+ if (!relations.length) return null;
38
+ const relation = relations[0];
39
+ if (relation.relkind !== 'r' || relation.relpersistence !== 'p' || relation.relrowsecurity || relation.relforcerowsecurity) mismatch(`${schema}.${name}`, 'expected a persistent ordinary table without row security');
40
+ const { rows: columns } = await client.query(`SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type,
41
+ NOT a.attnotnull AS nullable, pg_get_expr(d.adbin, d.adrelid) AS "default", a.attidentity, a.attgenerated,
42
+ col.collname AS collation, ns.nspname AS collation_schema
43
+ FROM pg_attribute a LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
44
+ LEFT JOIN pg_collation col ON col.oid = a.attcollation LEFT JOIN pg_namespace ns ON ns.oid = col.collnamespace
45
+ WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, [relation.oid]);
46
+ const { rows: constraints } = await client.query(`SELECT c.conname AS name, c.contype AS type,
47
+ c.condeferrable AS deferrable, c.condeferred AS deferred, c.convalidated AS validated,
48
+ COALESCE((to_jsonb(c)->>'conenforced')::boolean, true) AS enforced,
49
+ c.confdeltype AS delete_action, c.confupdtype AS update_action, c.confmatchtype AS match_type, c.connoinherit AS no_inherit,
50
+ (SELECT count(*) = 4 AND bool_and(t.tgenabled IN ('O', 'A') AND t.tgisinternal)
51
+ FROM pg_trigger t WHERE t.tgconstraint = c.oid) AS enforcement_enabled,
52
+ n.nspname AS target_schema, t.relname AS target_table, pg_get_expr(c.conbin, c.conrelid) AS expression,
53
+ ARRAY(SELECT a.attname::text FROM unnest(c.conkey) WITH ORDINALITY k(num, pos)
54
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.num ORDER BY k.pos) AS columns,
55
+ ARRAY(SELECT a.attname::text FROM unnest(c.confkey) WITH ORDINALITY k(num, pos)
56
+ JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.num ORDER BY k.pos) AS target_columns
57
+ FROM pg_constraint c LEFT JOIN pg_class t ON t.oid = c.confrelid LEFT JOIN pg_namespace n ON n.oid = t.relnamespace
58
+ WHERE c.conrelid = $1`, [relation.oid]);
59
+ const { rows: indexes } = await client.query(`SELECT ic.relname AS name, i.indisprimary AS primary,
60
+ i.indisunique AS unique, i.indnullsnotdistinct AS nulls_not_distinct, i.indisvalid AS valid, i.indisready AS ready,
61
+ i.indimmediate AS immediate, am.amname AS method, i.indnatts = i.indnkeyatts AS no_include,
62
+ i.indpred IS NULL AS no_predicate, i.indexprs IS NULL AS no_expression,
63
+ ARRAY(SELECT a.attname::text FROM unnest(i.indkey) WITH ORDINALITY k(num, pos)
64
+ LEFT JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.num ORDER BY k.pos) AS columns,
65
+ NOT EXISTS (SELECT 1 FROM unnest(i.indoption) opt WHERE opt <> 0) AS default_order,
66
+ NOT EXISTS (SELECT 1 FROM unnest(i.indclass) cls JOIN pg_opclass opc ON opc.oid = cls WHERE NOT opc.opcdefault) AS default_ops,
67
+ NOT EXISTS (SELECT 1 FROM unnest(i.indcollation) WITH ORDINALITY col(oid, pos)
68
+ JOIN unnest(i.indkey) WITH ORDINALITY k(num, pos) USING(pos)
69
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.num WHERE col.oid <> a.attcollation) AS default_collation
70
+ FROM pg_index i JOIN pg_class ic ON ic.oid = i.indexrelid JOIN pg_am am ON am.oid = ic.relam WHERE i.indrelid = $1`, [relation.oid]);
71
+ return { columns, constraints, indexes };
72
+ };
73
+
74
+ const validateBase = (schema, table, actual, triggers = []) => {
75
+ const name = `${schema}.${table.name}`;
76
+ if (!actual) mismatch(name, 'table is missing');
77
+ if (actual.columns.length !== table.columns.length) mismatch(name, 'column set differs');
78
+ for (const column of table.columns) {
79
+ const stored = actual.columns.find((item) => item.name === column.name);
80
+ if (!stored || stored.type !== column.type || stored.nullable !== column.nullable
81
+ || (column.collation && (stored.collation !== column.collation || stored.collation_schema !== 'pg_catalog'))
82
+ || normalizeExpression(stored.default) !== normalizeExpression(column.default || null) || stored.attidentity || stored.attgenerated) {
83
+ mismatch(`${name}.${column.name}`, 'column type, collation, nullability or default differs');
84
+ }
85
+ }
86
+ const primary = actual.constraints.find((item) => item.type === 'p');
87
+ if (!primary || primary.name !== table.primaryKey.name || !same(primary.columns, table.primaryKey.columns) || primary.deferrable || !primary.validated) mismatch(name, 'primary key differs');
88
+ const primaryIndex = actual.indexes.find((item) => item.primary);
89
+ if (!primaryIndex?.valid || !primaryIndex.ready) mismatch(name, 'primary key index is invalid');
90
+ for (const check of table.checks) {
91
+ const stored = actual.constraints.find((item) => item.name === check.name);
92
+ if (!stored || stored.type !== 'c' || !stored.validated || !stored.enforced || stored.no_inherit
93
+ || normalizeExpression(stored.expression) !== normalizeExpression(check.expression)) {
94
+ mismatch(`${name}.${check.name}`, `check constraint differs (expected ${check.expression}; found ${stored?.expression || 'missing'})`);
95
+ }
96
+ }
97
+ const expectedNames = new Set([table.primaryKey.name, ...table.checks.map((c) => c.name), ...table.foreignKeys.map((c) => c.name), ...triggers.filter((t) => t.table === table.name && t.constraint).map((t) => t.name)]);
98
+ for (const constraint of actual.constraints) {
99
+ // PostgreSQL 18 also catalogs table NOT NULL constraints (already checked via pg_attribute).
100
+ if (constraint.type === 'n') {
101
+ const column = table.columns.find((item) => same(constraint.columns, [item.name]));
102
+ if (!column || column.nullable || !constraint.validated || !constraint.enforced) mismatch(`${name}.${constraint.name}`, 'NOT NULL enforcement differs');
103
+ continue;
104
+ }
105
+ if (!expectedNames.has(constraint.name)) mismatch(`${name}.${constraint.name}`, 'unexpected constraint');
106
+ }
107
+ };
108
+
109
+ const validateForeignKey = (schema, table, key, actual) => {
110
+ if (actual.type !== 'f' || !same(actual.columns, key.columns) || actual.target_schema !== schema
111
+ || actual.target_table !== key.targetTable || !same(actual.target_columns, key.targetColumns)
112
+ || actual.delete_action !== (key.onDelete === 'CASCADE' ? 'c' : 'a') || actual.update_action !== 'a'
113
+ || actual.deferrable !== key.deferrable || actual.deferred !== key.initiallyDeferred || !actual.validated || !actual.enforced || actual.match_type !== 's' || !actual.enforcement_enabled) {
114
+ mismatch(`${schema}.${table.name}.${key.name}`, 'foreign key target, columns, actions, deferrability or enforcement differs');
115
+ }
116
+ };
117
+ const validateIndex = (schema, table, index, actual) => {
118
+ if (!same(actual.columns, index.columns) || actual.unique !== index.unique || actual.nulls_not_distinct !== index.nullsNotDistinct
119
+ || !actual.valid || !actual.ready || !actual.immediate || actual.method !== 'btree' || !actual.no_include
120
+ || !actual.no_predicate || !actual.no_expression || !actual.default_order || !actual.default_ops || !actual.default_collation) {
121
+ mismatch(`${schema}.${table.name}.${index.name}`, 'index definition differs');
122
+ }
123
+ };
124
+
125
+ const ensureFunctions = async (client, description, mode, created) => {
126
+ for (const fn of description.functions || []) {
127
+ const { rows } = await client.query(`SELECT p.oid, pg_get_function_identity_arguments(p.oid) AS arguments,
128
+ pg_get_function_result(p.oid) AS returns, p.prosrc AS body, l.lanname AS language, p.provolatile AS volatility,
129
+ p.proconfig AS configuration, p.prosecdef, p.proisstrict, p.proleakproof, p.proparallel, p.prokind,
130
+ p.proretset, p.pronargdefaults, p.procost, p.prorows, p.prosupport::oid AS support
131
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace JOIN pg_language l ON l.oid = p.prolang
132
+ WHERE n.nspname = $1 AND p.proname = $2`, [description.schema, fn.name]);
133
+ if (!rows.length) {
134
+ if (mode === 'validate') mismatch(`${description.schema}.${fn.name}`, 'function is missing');
135
+ await client.query(createFunctionSQL(description.schema, fn));
136
+ created.push(`function:${description.schema}.${fn.name}`);
137
+ continue;
138
+ }
139
+ const stored = rows[0];
140
+ if (rows.length !== 1 || stored.arguments !== fn.arguments.join(', ') || stored.returns !== fn.returns
141
+ || stored.body !== fn.body || stored.language !== fn.language || stored.volatility !== fn.volatility[0].toLowerCase()
142
+ || !same(stored.configuration, fn.configuration) || stored.prosecdef || stored.proisstrict || stored.proleakproof
143
+ || stored.proparallel !== 'u' || stored.prokind !== 'f' || stored.proretset || stored.pronargdefaults
144
+ || stored.procost !== 100 || stored.prorows !== 0 || stored.support !== 0) {
145
+ mismatch(`${description.schema}.${fn.name}`, 'function identity, body, signature or configuration differs');
146
+ }
147
+ }
148
+ };
149
+
150
+ const ensureTriggers = async (client, description, mode, created) => {
151
+ for (const trigger of description.triggers || []) {
152
+ const { rows } = await client.query(`SELECT t.tgtype, t.tgenabled, t.tgisinternal, t.tgdeferrable, t.tginitdeferred,
153
+ t.tgnargs, t.tgqual IS NULL AS no_when, t.tgattr::text AS columns, p.proname AS function, n.nspname AS function_schema,
154
+ p.pronargs, t.tgconstraint <> 0 AS constraint, t.tgconstrrelid::oid AS constraint_relation,
155
+ t.tgparentid::oid AS parent, c.condeferrable, c.condeferred, c.convalidated,
156
+ COALESCE((to_jsonb(c)->>'conenforced')::boolean, true) AS enforced,
157
+ t.tgoldtable, t.tgnewtable
158
+ FROM pg_trigger t JOIN pg_proc p ON p.oid = t.tgfoid JOIN pg_namespace n ON n.oid = p.pronamespace
159
+ LEFT JOIN pg_constraint c ON c.oid = t.tgconstraint
160
+ WHERE t.tgrelid = to_regclass($1) AND t.tgname = $2`, [qualified(description.schema, trigger.table), trigger.name]);
161
+ if (!rows.length) {
162
+ if (mode === 'validate') mismatch(`${description.schema}.${trigger.name}`, 'trigger is missing');
163
+ const collision = await client.query('SELECT 1 FROM pg_constraint WHERE conrelid = to_regclass($1) AND conname = $2', [qualified(description.schema, trigger.table), trigger.name]);
164
+ if (collision.rowCount) mismatch(`${description.schema}.${trigger.table}.${trigger.name}`, 'constraint name is already in use');
165
+ await client.query(createTriggerSQL(description.schema, trigger));
166
+ created.push(`trigger:${description.schema}.${trigger.table}.${trigger.name}`);
167
+ continue;
168
+ }
169
+ const stored = rows[0];
170
+ if (stored.tgtype !== 29 || stored.tgenabled !== 'O' || stored.tgisinternal || stored.tgdeferrable !== trigger.deferrable
171
+ || stored.tginitdeferred !== trigger.initiallyDeferred || stored.tgnargs || !stored.no_when || stored.columns !== ''
172
+ || stored.function !== trigger.function || stored.function_schema !== description.schema || stored.pronargs
173
+ || stored.constraint !== trigger.constraint || stored.constraint_relation || stored.parent || stored.tgoldtable || stored.tgnewtable
174
+ || (trigger.constraint && (!stored.condeferrable || !stored.condeferred || !stored.convalidated || !stored.enforced))) {
175
+ mismatch(`${description.schema}.${trigger.table}.${trigger.name}`, 'trigger definition or enforcement differs');
176
+ }
177
+ }
178
+ };
179
+
180
+ const backfill = async (client, description) => {
181
+ for (const table of description.tables) {
182
+ for (const check of table.checks) {
183
+ const invalid = await client.query(`SELECT 1 FROM ${qualified(description.schema, table.name)} WHERE NOT (${check.expression}) LIMIT 1`);
184
+ if (invalid.rowCount) mismatch(`${description.schema}.${table.name}.${check.name}`, 'existing data violates generated check');
185
+ }
186
+ }
187
+ // Existing keys can be stale when missing maintenance triggers are recreated.
188
+ // All managed tables are already locked; rebuild the complete derived key set
189
+ // before testing owners against one another. Rollback restores the old set.
190
+ for (const table of description.tables.filter((item) => item.uniqueKeys)) {
191
+ await client.query(`DELETE FROM ${qualified(description.schema, table.name)}`);
192
+ }
193
+ for (const item of description.maintenance || []) {
194
+ await client.query(`INSERT INTO ${qualified(description.schema, item.guardTable)} (__owner_id)
195
+ SELECT id FROM ${qualified(description.schema, item.rootTable)} ON CONFLICT (__owner_id) DO NOTHING`);
196
+ await client.query(`SELECT ${qualified(description.schema, item.refreshFunction)}(id) FROM ${qualified(description.schema, item.rootTable)} ORDER BY id`);
197
+ }
198
+ };
199
+
200
+ /** Create missing objects or validate an existing schema; never alter/drop existing storage. */
201
+ export const initializeDatabase = async (pool, description, { mode = 'create' } = {}) => {
202
+ if (!['create', 'validate'].includes(mode)) throw new SimfinityError(`Unknown initialization mode: ${mode}`, 'INVALID_INITIALIZATION_MODE', 400);
203
+ const { schema, tables } = description;
204
+ identifier(schema);
205
+ if (schema.startsWith('pg_') || schema === 'information_schema') throw new SimfinityError('System schemas cannot be managed', 'INVALID_DATABASE_SCHEMA', 400);
206
+ const client = await pool.connect();
207
+ const created = [];
208
+ try {
209
+ await client.query(mode === 'validate' ? 'BEGIN READ ONLY' : 'BEGIN');
210
+ await client.query('SET LOCAL search_path = pg_catalog');
211
+ await client.query('SET LOCAL standard_conforming_strings = \'on\'');
212
+ const version = await client.query('SELECT current_setting(\'server_version_num\')::integer AS version');
213
+ if (version.rows[0].version < 150000) throw new SimfinityError('PostgreSQL 15 or later is required', 'UNSUPPORTED_DATABASE_VERSION', 400);
214
+ await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [`simfinity:${schema}`]);
215
+ const namespace = await client.query('SELECT 1 FROM pg_namespace WHERE nspname = $1', [schema]);
216
+ if (!namespace.rowCount) {
217
+ if (mode === 'validate') mismatch(schema, 'schema is missing');
218
+ await client.query(`CREATE SCHEMA ${identifier(schema)}`);
219
+ created.push(`schema:${schema}`);
220
+ }
221
+ await ensureFunctions(client, description, mode, created);
222
+ const catalog = new Map();
223
+ for (const table of tables) {
224
+ let actual = await readTable(client, schema, table.name);
225
+ if (!actual) {
226
+ if (mode === 'validate') mismatch(`${schema}.${table.name}`, 'table is missing');
227
+ await client.query(createTableSQL(schema, table));
228
+ created.push(`table:${schema}.${table.name}`);
229
+ actual = await readTable(client, schema, table.name);
230
+ }
231
+ if (mode === 'create') await client.query(`LOCK TABLE ${qualified(schema, table.name)} IN SHARE ROW EXCLUSIVE MODE`);
232
+ validateBase(schema, table, actual, description.triggers);
233
+ catalog.set(table.name, actual);
234
+ }
235
+ for (const table of tables) {
236
+ const actual = catalog.get(table.name);
237
+ for (const key of table.foreignKeys) {
238
+ const stored = actual.constraints.find((item) => item.name === key.name);
239
+ if (stored) validateForeignKey(schema, table, key, stored);
240
+ else {
241
+ if (mode === 'validate') mismatch(`${schema}.${table.name}.${key.name}`, 'foreign key is missing');
242
+ await client.query(createForeignKeySQL(schema, table, key));
243
+ created.push(`foreignKey:${schema}.${table.name}.${key.name}`);
244
+ }
245
+ }
246
+ for (const index of table.indexes) {
247
+ const stored = actual.indexes.find((item) => item.name === index.name);
248
+ if (stored) validateIndex(schema, table, index, stored);
249
+ else {
250
+ if (mode === 'validate') mismatch(`${schema}.${table.name}.${index.name}`, 'index is missing');
251
+ const collision = await client.query('SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = $1 AND c.relname = $2', [schema, index.name]);
252
+ if (collision.rowCount) mismatch(`${schema}.${index.name}`, 'relation name is already in use');
253
+ await client.query(createIndexSQL(schema, table, index));
254
+ created.push(`index:${schema}.${index.name}`);
255
+ }
256
+ }
257
+ for (const index of actual.indexes) {
258
+ if (index.unique && !index.primary && !table.indexes.some((item) => item.name === index.name)) mismatch(`${schema}.${table.name}.${index.name}`, 'unexpected unique index');
259
+ }
260
+ }
261
+ await ensureTriggers(client, description, mode, created);
262
+ if (mode === 'create' && created.length) await backfill(client, description);
263
+ await client.query('COMMIT');
264
+ return { mode, created };
265
+ } catch (error) {
266
+ await client.query('ROLLBACK').catch(() => {});
267
+ throw error;
268
+ } finally {
269
+ client.release();
270
+ }
271
+ };
@@ -0,0 +1,28 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { SimfinityError } from '@simtlix/simfinity-core';
3
+
4
+ export const invalid = (message) => {
5
+ throw new SimfinityError(message, 'INVALID_DATABASE_SCHEMA', 400);
6
+ };
7
+
8
+ export const identifier = (value) => {
9
+ if (typeof value !== 'string' || !value.length || value.includes('\0') || Buffer.byteLength(value) > 63) {
10
+ invalid(`Invalid PostgreSQL identifier: ${String(value)}`);
11
+ }
12
+ return `"${value.replaceAll('"', '""')}"`;
13
+ };
14
+ export const literal = (value) => {
15
+ const text = String(value);
16
+ const escaped = text.replaceAll('\\', '\\\\').replaceAll('\'', '\'\'');
17
+ return `${text.includes('\\') ? 'E' : ''}'${escaped}'`;
18
+ };
19
+ export const qualified = (schema, table) => `${identifier(schema)}.${identifier(table)}`;
20
+ export const columnsSQL = (columns) => columns.map(identifier).join(', ');
21
+ export const generatedName = (...parts) => {
22
+ const name = parts.join('__');
23
+ if (Buffer.byteLength(name) <= 63) return name;
24
+ const hash = createHash('sha256').update(name).digest('hex').slice(0, 12);
25
+ let prefix = name;
26
+ while (Buffer.byteLength(prefix) > 50) prefix = prefix.slice(0, -1);
27
+ return `${prefix}_${hash}`;
28
+ };