refactor: implement database-specific schema architecture with schema-as-query pattern
🏗️ **Major Database Schema Refactoring** **Problem Solved**: Eliminated model duplication and multiple sources of truth by: - Removed duplicate models (`internal/models/content.go`) - Replaced inlined schema strings with sqlc-generated setup functions - Implemented database-specific schemas with proper NOT NULL constraints **Key Improvements**: ✅ **Single Source of Truth**: Database schemas define all types, no manual sync needed ✅ **Clean Generated Types**: sqlc generates `string` and `int64` instead of `sql.NullString/sql.NullTime` ✅ **Schema-as-Query Pattern**: Setup functions generated by sqlc for type safety ✅ **Database-Specific Optimization**: SQLite INTEGER timestamps, PostgreSQL BIGINT timestamps ✅ **Cross-Database Compatibility**: Single codebase supports both SQLite and PostgreSQL **Architecture Changes**: - `db/sqlite/` - SQLite-specific schema and setup queries - `db/postgresql/` - PostgreSQL-specific schema and setup queries - `db/queries/` - Cross-database CRUD queries using `sqlc.arg()` syntax - `internal/db/database.go` - Database abstraction with runtime selection - `internal/api/models.go` - Clean API models for requests/responses **Version Control System**: Complete element-level history with user attribution and rollback **Verification**: ✅ Full API workflow tested (create → update → rollback → versions) **Production Ready**: Supports SQLite (development) → PostgreSQL (production) migration
This commit is contained in:
@@ -105,17 +105,205 @@ export class InsertrCore {
|
||||
// Get element metadata
|
||||
getElementMetadata(element) {
|
||||
return {
|
||||
contentId: element.getAttribute('data-content-id') || this.generateTempId(element),
|
||||
contentId: element.getAttribute('data-content-id') || this.generateDeterministicId(element),
|
||||
contentType: element.getAttribute('data-content-type') || this.detectContentType(element),
|
||||
element: element
|
||||
};
|
||||
}
|
||||
|
||||
// Generate temporary ID for elements without data-content-id
|
||||
// Generate deterministic ID using same algorithm as CLI parser
|
||||
generateTempId(element) {
|
||||
return this.generateDeterministicId(element);
|
||||
}
|
||||
|
||||
// Generate deterministic content ID (matches CLI parser algorithm)
|
||||
generateDeterministicId(element) {
|
||||
const context = this.getSemanticContext(element);
|
||||
const purpose = this.getPurpose(element);
|
||||
const contentHash = this.getContentHash(element);
|
||||
|
||||
return this.createBaseId(context, purpose, contentHash);
|
||||
}
|
||||
|
||||
// Get semantic context from parent elements (matches CLI algorithm)
|
||||
getSemanticContext(element) {
|
||||
let parent = element.parentElement;
|
||||
|
||||
while (parent && parent.nodeType === Node.ELEMENT_NODE) {
|
||||
const classList = Array.from(parent.classList);
|
||||
|
||||
// Check for common semantic section classes
|
||||
const semanticClasses = ['hero', 'services', 'nav', 'navbar', 'footer', 'about', 'contact', 'testimonial'];
|
||||
for (const semanticClass of semanticClasses) {
|
||||
if (classList.includes(semanticClass)) {
|
||||
return semanticClass;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for semantic HTML elements
|
||||
const tag = parent.tagName.toLowerCase();
|
||||
if (['nav', 'header', 'footer', 'main', 'aside'].includes(tag)) {
|
||||
return tag;
|
||||
}
|
||||
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
return 'content';
|
||||
}
|
||||
|
||||
// Get purpose/role of the element (matches CLI algorithm)
|
||||
getPurpose(element) {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const text = element.textContent.trim().substring(0, 20).replace(/\s+/g, '-').toLowerCase();
|
||||
return `${tag}-${text}-${Date.now()}`;
|
||||
const classList = Array.from(element.classList);
|
||||
|
||||
// Check for specific CSS classes that indicate purpose
|
||||
for (const className of classList) {
|
||||
if (className.includes('title')) return 'title';
|
||||
if (className.includes('headline')) return 'headline';
|
||||
if (className.includes('description')) return 'description';
|
||||
if (className.includes('subtitle')) return 'subtitle';
|
||||
if (className.includes('cta')) return 'cta';
|
||||
if (className.includes('button')) return 'button';
|
||||
if (className.includes('logo')) return 'logo';
|
||||
if (className.includes('lead')) return 'lead';
|
||||
}
|
||||
|
||||
// Infer purpose from HTML tag
|
||||
switch (tag) {
|
||||
case 'h1':
|
||||
return 'title';
|
||||
case 'h2':
|
||||
return 'subtitle';
|
||||
case 'h3':
|
||||
case 'h4':
|
||||
case 'h5':
|
||||
case 'h6':
|
||||
return 'heading';
|
||||
case 'p':
|
||||
return 'text';
|
||||
case 'a':
|
||||
return 'link';
|
||||
case 'button':
|
||||
return 'button';
|
||||
default:
|
||||
return 'content';
|
||||
}
|
||||
}
|
||||
|
||||
// Generate content hash (matches CLI algorithm)
|
||||
getContentHash(element) {
|
||||
const text = element.textContent.trim();
|
||||
|
||||
// Simple SHA-1 implementation for consistent hashing
|
||||
return this.sha1(text).substring(0, 6);
|
||||
}
|
||||
|
||||
// Simple SHA-1 implementation (matches Go crypto/sha1)
|
||||
sha1(str) {
|
||||
// Convert string to UTF-8 bytes
|
||||
const utf8Bytes = new TextEncoder().encode(str);
|
||||
|
||||
// SHA-1 implementation
|
||||
const h = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
|
||||
const messageLength = utf8Bytes.length;
|
||||
|
||||
// Pre-processing: adding padding bits
|
||||
const paddedMessage = new Uint8Array(Math.ceil((messageLength + 9) / 64) * 64);
|
||||
paddedMessage.set(utf8Bytes);
|
||||
paddedMessage[messageLength] = 0x80;
|
||||
|
||||
// Append original length in bits as 64-bit big-endian integer
|
||||
const bitLength = messageLength * 8;
|
||||
const view = new DataView(paddedMessage.buffer);
|
||||
view.setUint32(paddedMessage.length - 4, bitLength, false); // big-endian
|
||||
|
||||
// Process message in 512-bit chunks
|
||||
for (let chunk = 0; chunk < paddedMessage.length; chunk += 64) {
|
||||
const w = new Array(80);
|
||||
|
||||
// Break chunk into sixteen 32-bit words
|
||||
for (let i = 0; i < 16; i++) {
|
||||
w[i] = view.getUint32(chunk + i * 4, false); // big-endian
|
||||
}
|
||||
|
||||
// Extend the words
|
||||
for (let i = 16; i < 80; i++) {
|
||||
w[i] = this.leftRotate(w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16], 1);
|
||||
}
|
||||
|
||||
// Initialize hash value for this chunk
|
||||
let [a, b, c, d, e] = h;
|
||||
|
||||
// Main loop
|
||||
for (let i = 0; i < 80; i++) {
|
||||
let f, k;
|
||||
if (i < 20) {
|
||||
f = (b & c) | ((~b) & d);
|
||||
k = 0x5A827999;
|
||||
} else if (i < 40) {
|
||||
f = b ^ c ^ d;
|
||||
k = 0x6ED9EBA1;
|
||||
} else if (i < 60) {
|
||||
f = (b & c) | (b & d) | (c & d);
|
||||
k = 0x8F1BBCDC;
|
||||
} else {
|
||||
f = b ^ c ^ d;
|
||||
k = 0xCA62C1D6;
|
||||
}
|
||||
|
||||
const temp = (this.leftRotate(a, 5) + f + e + k + w[i]) >>> 0;
|
||||
e = d;
|
||||
d = c;
|
||||
c = this.leftRotate(b, 30);
|
||||
b = a;
|
||||
a = temp;
|
||||
}
|
||||
|
||||
// Add this chunk's hash to result
|
||||
h[0] = (h[0] + a) >>> 0;
|
||||
h[1] = (h[1] + b) >>> 0;
|
||||
h[2] = (h[2] + c) >>> 0;
|
||||
h[3] = (h[3] + d) >>> 0;
|
||||
h[4] = (h[4] + e) >>> 0;
|
||||
}
|
||||
|
||||
// Produce the final hash value as a 160-bit hex string
|
||||
return h.map(x => x.toString(16).padStart(8, '0')).join('');
|
||||
}
|
||||
|
||||
// Left rotate function for SHA-1
|
||||
leftRotate(value, amount) {
|
||||
return ((value << amount) | (value >>> (32 - amount))) >>> 0;
|
||||
}
|
||||
|
||||
// Create base ID from components (matches CLI algorithm)
|
||||
createBaseId(context, purpose, contentHash) {
|
||||
const parts = [];
|
||||
|
||||
// Add context if meaningful
|
||||
if (context !== 'content') {
|
||||
parts.push(context);
|
||||
}
|
||||
|
||||
// Add purpose
|
||||
parts.push(purpose);
|
||||
|
||||
// Always add content hash for uniqueness
|
||||
parts.push(contentHash);
|
||||
|
||||
let baseId = parts.join('-');
|
||||
|
||||
// Clean up the ID
|
||||
baseId = baseId.replace(/-+/g, '-');
|
||||
baseId = baseId.replace(/^-+|-+$/g, '');
|
||||
|
||||
// Ensure it's not empty
|
||||
if (!baseId) {
|
||||
baseId = `content-${contentHash}`;
|
||||
}
|
||||
|
||||
return baseId;
|
||||
}
|
||||
|
||||
// Detect content type for elements without data-content-type
|
||||
|
||||
Reference in New Issue
Block a user