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:
浅念
2026-10-23 12:31:03 +08:00
parent 257c89e159
commit 601451f22b
218 changed files with 10441 additions and 628 deletions
+78 -25
View File
@@ -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):