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:
2025-09-09 00:25:07 +02:00
parent 161c320304
commit bab329b429
41 changed files with 3703 additions and 561 deletions

View File

@@ -33,7 +33,8 @@ export class ApiClient {
const response = await fetch(`${this.baseUrl}/${contentId}?site_id=${this.siteId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'X-User-ID': this.getCurrentUser()
},
body: JSON.stringify({ value: content })
});
@@ -62,7 +63,8 @@ export class ApiClient {
const response = await fetch(`${this.baseUrl}?site_id=${this.siteId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'X-User-ID': this.getCurrentUser()
},
body: JSON.stringify({
id: contentId,
@@ -88,4 +90,52 @@ export class ApiClient {
return false;
}
}
async getContentVersions(contentId) {
try {
const response = await fetch(`${this.baseUrl}/${contentId}/versions?site_id=${this.siteId}`);
if (response.ok) {
const result = await response.json();
return result.versions || [];
} else {
console.warn(`⚠️ Failed to fetch versions (${response.status}): ${contentId}`);
return [];
}
} catch (error) {
console.error('Failed to fetch version history:', contentId, error);
return [];
}
}
async rollbackContent(contentId, versionId) {
try {
const response = await fetch(`${this.baseUrl}/${contentId}/rollback?site_id=${this.siteId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-ID': this.getCurrentUser()
},
body: JSON.stringify({
version_id: versionId
})
});
if (response.ok) {
console.log(`✅ Content rolled back: ${contentId} to version ${versionId}`);
return await response.json();
} else {
console.warn(`⚠️ Rollback failed (${response.status}): ${contentId}`);
return false;
}
} catch (error) {
console.error('Failed to rollback content:', contentId, error);
return false;
}
}
// Helper to get current user (for user attribution)
getCurrentUser() {
// This could be enhanced to get from authentication system
return 'anonymous';
}
}

View File

@@ -10,7 +10,7 @@ export class InsertrEditor {
this.apiClient = apiClient;
this.options = options;
this.isActive = false;
this.formRenderer = new InsertrFormRenderer();
this.formRenderer = new InsertrFormRenderer(apiClient);
}
start() {
@@ -200,6 +200,178 @@ export class InsertrEditor {
z-index: 1000;
font-family: monospace;
}
/* Version History Modal Styles */
.insertr-version-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10001;
}
.insertr-version-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.insertr-version-content-modal {
background: white;
border-radius: 8px;
max-width: 600px;
width: 100%;
max-height: 80vh;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
}
.insertr-version-header {
padding: 20px 20px 0;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.insertr-version-header h3 {
margin: 0 0 20px;
color: #333;
font-size: 18px;
}
.insertr-btn-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.insertr-btn-close:hover {
color: #333;
}
.insertr-version-list {
overflow-y: auto;
padding: 20px;
flex: 1;
}
.insertr-version-item {
border: 1px solid #e1e5e9;
border-radius: 6px;
padding: 16px;
margin-bottom: 12px;
background: #f8f9fa;
}
.insertr-version-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
font-size: 13px;
}
.insertr-version-label {
font-weight: 600;
color: #0969da;
}
.insertr-version-date {
color: #656d76;
}
.insertr-version-user {
color: #656d76;
}
.insertr-version-content {
margin-bottom: 12px;
padding: 8px;
background: white;
border-radius: 4px;
font-family: monospace;
font-size: 14px;
color: #24292f;
white-space: pre-wrap;
}
.insertr-version-actions {
display: flex;
gap: 8px;
}
.insertr-btn-restore {
background: #0969da;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.insertr-btn-restore:hover {
background: #0860ca;
}
.insertr-btn-view-diff {
background: #f6f8fa;
color: #24292f;
border: 1px solid #d1d9e0;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.insertr-btn-view-diff:hover {
background: #f3f4f6;
}
.insertr-version-empty {
text-align: center;
color: #656d76;
font-style: italic;
padding: 40px 20px;
}
/* History Button in Form */
.insertr-btn-history {
background: #6f42c1;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
margin-left: auto;
}
.insertr-btn-history:hover {
background: #5a359a;
}
`;
const styleSheet = document.createElement('style');

View File

@@ -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

View File

@@ -186,7 +186,8 @@ class LivePreviewManager {
* Enhanced with debounced live preview and comfortable input sizing
*/
export class InsertrFormRenderer {
constructor() {
constructor(apiClient = null) {
this.apiClient = apiClient;
this.currentOverlay = null;
this.previewManager = new LivePreviewManager();
this.markdownEditor = new MarkdownEditor();
@@ -358,6 +359,7 @@ export class InsertrFormRenderer {
<div class="insertr-form-actions">
<button type="button" class="insertr-btn-save">Save</button>
<button type="button" class="insertr-btn-cancel">Cancel</button>
<button type="button" class="insertr-btn-history" data-content-id="${contentId}">View History</button>
</div>
`;
@@ -446,50 +448,182 @@ export class InsertrFormRenderer {
}
/**
* Position form relative to element and ensure visibility with scroll-to-fit
* Get element ID for preview tracking
*/
positionForm(element, overlay) {
const rect = element.getBoundingClientRect();
const form = overlay.querySelector('.insertr-edit-form');
getElementId(element) {
return element.id || element.getAttribute('data-content-id') ||
`element-${element.tagName}-${Date.now()}`;
}
// Calculate optimal width for comfortable editing (60-80 characters)
const viewportWidth = window.innerWidth;
let formWidth;
/**
* Show version history modal
*/
async showVersionHistory(contentId, element, onRestore) {
try {
// Get version history from API (we'll need to pass this in)
const apiClient = this.getApiClient();
const versions = await apiClient.getContentVersions(contentId);
if (viewportWidth < 768) {
// Mobile: prioritize usability over character count
formWidth = Math.min(viewportWidth - 40, 500);
// Create version history modal
const historyModal = this.createVersionHistoryModal(contentId, versions, onRestore);
document.body.appendChild(historyModal);
// Focus and setup handlers
this.setupVersionHistoryHandlers(historyModal, contentId);
} catch (error) {
console.error('Failed to load version history:', error);
this.showVersionHistoryError('Failed to load version history. Please try again.');
}
}
/**
* Create version history modal
*/
createVersionHistoryModal(contentId, versions, onRestore) {
const modal = document.createElement('div');
modal.className = 'insertr-version-modal';
let versionsHTML = '';
if (versions && versions.length > 0) {
versionsHTML = versions.map((version, index) => `
<div class="insertr-version-item" data-version-id="${version.version_id}">
<div class="insertr-version-meta">
<span class="insertr-version-label">${index === 0 ? 'Previous Version' : `Version ${versions.length - index}`}</span>
<span class="insertr-version-date">${this.formatDate(version.created_at)}</span>
${version.created_by ? `<span class="insertr-version-user">by ${version.created_by}</span>` : ''}
</div>
<div class="insertr-version-content">${this.escapeHtml(this.truncateContent(version.value, 100))}</div>
<div class="insertr-version-actions">
<button type="button" class="insertr-btn-restore" data-version-id="${version.version_id}">Restore</button>
<button type="button" class="insertr-btn-view-diff" data-version-id="${version.version_id}">View Full</button>
</div>
</div>
`).join('');
} else {
// Desktop: ensure comfortable 60-80 character editing
const minComfortableWidth = 600; // ~70 characters at 1rem
const maxWidth = Math.min(viewportWidth * 0.9, 800); // Max 800px or 90% viewport
const elementWidth = rect.width;
// Use larger of: comfortable width, 1.5x element width, but cap at maxWidth
formWidth = Math.max(
minComfortableWidth,
Math.min(elementWidth * 1.5, maxWidth)
);
versionsHTML = '<div class="insertr-version-empty">No previous versions found</div>';
}
form.style.width = `${formWidth}px`;
modal.innerHTML = `
<div class="insertr-version-backdrop">
<div class="insertr-version-content-modal">
<div class="insertr-version-header">
<h3>Version History</h3>
<button type="button" class="insertr-btn-close">&times;</button>
</div>
<div class="insertr-version-list">
${versionsHTML}
</div>
</div>
</div>
`;
// Position below element with some spacing
const top = rect.bottom + window.scrollY + 10;
return modal;
}
// Center form relative to element, but keep within viewport
const centerLeft = rect.left + window.scrollX + (rect.width / 2) - (formWidth / 2);
const minLeft = 20;
const maxLeft = window.innerWidth - formWidth - 20;
const left = Math.max(minLeft, Math.min(centerLeft, maxLeft));
/**
* Setup version history modal handlers
*/
setupVersionHistoryHandlers(modal, contentId) {
const closeBtn = modal.querySelector('.insertr-btn-close');
const backdrop = modal.querySelector('.insertr-version-backdrop');
// Close handlers
if (closeBtn) {
closeBtn.addEventListener('click', () => modal.remove());
}
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) {
modal.remove();
}
});
overlay.style.position = 'absolute';
overlay.style.top = `${top}px`;
overlay.style.left = `${left}px`;
overlay.style.zIndex = '10000';
// Restore handlers
const restoreButtons = modal.querySelectorAll('.insertr-btn-restore');
restoreButtons.forEach(btn => {
btn.addEventListener('click', async () => {
const versionId = btn.getAttribute('data-version-id');
if (await this.confirmRestore()) {
await this.restoreVersion(contentId, versionId);
modal.remove();
// Refresh the current form or close it
this.closeForm();
}
});
});
// Ensure modal is fully visible after positioning
this.ensureModalVisible(element, overlay);
// View diff handlers
const viewButtons = modal.querySelectorAll('.insertr-btn-view-diff');
viewButtons.forEach(btn => {
btn.addEventListener('click', () => {
const versionId = btn.getAttribute('data-version-id');
this.showVersionDetails(versionId);
});
});
}
/**
* Helper methods for version history
*/
formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
// Less than 24 hours ago
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
if (hours < 1) {
const minutes = Math.floor(diff / (60 * 1000));
return `${minutes}m ago`;
}
return `${hours}h ago`;
}
// Less than 7 days ago
if (diff < 7 * 24 * 60 * 60 * 1000) {
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
return `${days}d ago`;
}
// Older - show actual date
return date.toLocaleDateString();
}
truncateContent(content, maxLength) {
if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + '...';
}
async confirmRestore() {
return confirm('Are you sure you want to restore this version? This will replace the current content.');
}
async restoreVersion(contentId, versionId) {
try {
const apiClient = this.getApiClient();
await apiClient.rollbackContent(contentId, versionId);
return true;
} catch (error) {
console.error('Failed to restore version:', error);
alert('Failed to restore version. Please try again.');
return false;
}
}
showVersionDetails(versionId) {
// TODO: Implement detailed version view with diff
alert(`Version details not implemented yet (Version ID: ${versionId})`);
}
showVersionHistoryError(message) {
alert(message);
}
// Helper to get API client
getApiClient() {
return this.apiClient || window.insertrAPIClient || null;
}
/**
@@ -568,6 +702,15 @@ export class InsertrFormRenderer {
});
}
// Version History button
const historyBtn = form.querySelector('.insertr-btn-history');
if (historyBtn) {
historyBtn.addEventListener('click', () => {
const contentId = historyBtn.getAttribute('data-content-id');
this.showVersionHistory(contentId, element, onSave);
});
}
// ESC key to cancel
const keyHandler = (e) => {
if (e.key === 'Escape') {