upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)
Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.
New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.
compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).
Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+78
-25
@@ -1,32 +1,87 @@
|
||||
"""PostgreSQL DB-API bridge for legacy '?' SQL (optional, requires psycopg[binary]).
|
||||
"""Conservative PostgreSQL DB-API bridge for the existing SQLite-shaped application.
|
||||
|
||||
No automatic conversion of existing SQLite files is attempted. Production migration
|
||||
requires a tested one-time data migration and reconciliation.
|
||||
The row adapter intentionally supports BOTH row['column'] and row[0], because
|
||||
several existing queue paths use positional aggregate results while API paths
|
||||
serialize rows as dictionaries. PostgreSQL data migration remains explicit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
|
||||
class PGRow(dict):
|
||||
"""Mapping-compatible row with SQLite-style positional indexing."""
|
||||
__slots__ = ('_values',)
|
||||
|
||||
def __init__(self, columns, values):
|
||||
values = tuple(values)
|
||||
super().__init__(zip(columns, values))
|
||||
self._values = values
|
||||
|
||||
def __getitem__(self, key):
|
||||
if isinstance(key, int):
|
||||
return self._values[key]
|
||||
return super().__getitem__(key)
|
||||
|
||||
|
||||
def row_factory(cursor):
|
||||
columns = [col.name for col in (cursor.description or ())]
|
||||
return lambda values: PGRow(columns, values)
|
||||
|
||||
|
||||
def placeholders(sql: str) -> str:
|
||||
"""Translate SQLite positional placeholders outside SQL quoted strings."""
|
||||
out=[];quote=None;i=0
|
||||
"""Translate ? placeholders without modifying quoted strings or comments."""
|
||||
out=[]; i=0; state='code'
|
||||
while i<len(sql):
|
||||
ch=sql[i]
|
||||
if quote:
|
||||
ch=sql[i]; nxt=sql[i+1] if i+1<len(sql) else ''
|
||||
if state=='code':
|
||||
if ch in ("'",'"'):
|
||||
state=ch;out.append(ch)
|
||||
elif ch=='-' and nxt=='-':
|
||||
state='line';out.extend((ch,nxt));i+=1
|
||||
elif ch=='/' and nxt=='*':
|
||||
state='block';out.extend((ch,nxt));i+=1
|
||||
elif ch=='?':out.append('%s')
|
||||
else:out.append(ch)
|
||||
elif state in ("'",'"'):
|
||||
out.append(ch)
|
||||
if ch==quote:
|
||||
if i+1<len(sql) and sql[i+1]==quote:
|
||||
out.append(sql[i+1]);i+=1
|
||||
else:quote=None
|
||||
elif ch in ("'",'"'):
|
||||
quote=ch;out.append(ch)
|
||||
elif ch=='?':out.append('%s')
|
||||
else:out.append(ch)
|
||||
if ch==state:
|
||||
if nxt==state:out.append(nxt);i+=1
|
||||
else:state='code'
|
||||
elif state=='line':
|
||||
out.append(ch)
|
||||
if ch=='\n':state='code'
|
||||
elif state=='block':
|
||||
out.append(ch)
|
||||
if ch=='*' and nxt=='/':out.append(nxt);i+=1;state='code'
|
||||
i+=1
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def statements(script: str):
|
||||
"""Split migration DDL at top-level semicolons, not inside quoted literals."""
|
||||
start=0;i=0;state='code'
|
||||
while i<len(script):
|
||||
ch=script[i];nxt=script[i+1] if i+1<len(script) else ''
|
||||
if state=='code':
|
||||
if ch in ("'",'"'):state=ch
|
||||
elif ch=='-' and nxt=='-':state='line';i+=1
|
||||
elif ch=='/' and nxt=='*':state='block';i+=1
|
||||
elif ch==';':
|
||||
candidate=script[start:i].strip()
|
||||
if candidate:yield candidate
|
||||
start=i+1
|
||||
elif state in ("'",'"'):
|
||||
if ch==state:
|
||||
if nxt==state:i+=1
|
||||
else:state='code'
|
||||
elif state=='line':
|
||||
if ch=='\n':state='code'
|
||||
elif state=='block':
|
||||
if ch=='*' and nxt=='/':i+=1;state='code'
|
||||
i+=1
|
||||
candidate=script[start:].strip()
|
||||
if candidate:yield candidate
|
||||
|
||||
|
||||
def normalize(sql: str) -> str:
|
||||
sql=sql.strip()
|
||||
if re.match(r'^INSERT\s+OR\s+IGNORE\s+INTO\b',sql,re.I):
|
||||
@@ -38,8 +93,7 @@ def normalize(sql: str) -> str:
|
||||
class PGConnection:
|
||||
def __init__(self,dsn):
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
self.connection=psycopg.connect(dsn,row_factory=dict_row,connect_timeout=5)
|
||||
self.connection=psycopg.connect(dsn,row_factory=row_factory,connect_timeout=5)
|
||||
self.total_changes=0
|
||||
|
||||
def execute(self,sql,params=()):
|
||||
@@ -49,20 +103,19 @@ class PGConnection:
|
||||
if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',table):raise ValueError('Invalid table name')
|
||||
return self.connection.execute('SELECT column_name AS name FROM information_schema.columns WHERE table_schema=current_schema() AND table_name=%s',(table,))
|
||||
if stripped.upper()=='BEGIN IMMEDIATE':
|
||||
# Conservative transaction-wide lock while porting SQLite queue semantics.
|
||||
# Replace with per-site advisory locks before large multi-worker scale.
|
||||
# Serialize short queue claim/limit transactions, not the worker execution.
|
||||
# This prevents per-site races between concurrent enqueuers and claimers.
|
||||
return self.connection.execute('SELECT pg_advisory_xact_lock(823947231)')
|
||||
if stripped.upper().startswith('PRAGMA '):
|
||||
return _EmptyResult()
|
||||
if stripped.upper().startswith('PRAGMA '):return _EmptyResult()
|
||||
cursor=self.connection.execute(normalize(sql),params)
|
||||
if cursor.rowcount and cursor.rowcount>0 and stripped.split(None,1)[0].upper() in ('UPDATE','INSERT','DELETE'):
|
||||
self.total_changes+=cursor.rowcount
|
||||
return cursor
|
||||
|
||||
def executescript(self,script):
|
||||
# Schema migrations contain only simple DDL and SQLite PRAGMA statements.
|
||||
for statement in script.split(';'):
|
||||
if statement.strip():self.execute(statement)
|
||||
for statement in statements(script):
|
||||
if not statement.lstrip().startswith('--') or '\n' in statement:
|
||||
self.execute(statement)
|
||||
|
||||
def __enter__(self):return self
|
||||
def __exit__(self,typ,val,tb):
|
||||
|
||||
Reference in New Issue
Block a user