Files
devplacepy/devplacepy/database/sync.py
T
retoorandClaude Sonnet 5.5 02ab747cd0 Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work
Continuous Delivery could never be turned off once a crop was planted. Add a
one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool
game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant,
autoreplant claim or Refactor. Autoreplant now also respects crop locks through
the shared _crop_lock predicate. Plan and measurements in happyplanting.md.

Also includes the pending community sync, node network and related work in the
tree; the full suite passed (4999) with all of it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 02:23:43 +02:00

361 lines
12 KiB
Python

# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import text
from devplacepy.utils.text import generate_uid
from .core import _now_iso, db, get_table
def get_link(origin: str, kind: str, origin_uid: str) -> dict | None:
return get_table("sync_links").find_one(
origin=origin, kind=kind, origin_uid=origin_uid
)
def get_links_by_origin_uids(
origin: str, kind: str, origin_uids: list[str]
) -> dict[str, dict]:
uids = [uid for uid in dict.fromkeys(origin_uids) if uid]
if not uids:
return {}
table = get_table("sync_links")
column = table.table.columns.origin_uid
rows = table.find(column.in_(uids), origin=origin, kind=kind)
return {row["origin_uid"]: row for row in rows}
def claim_link(
origin: str,
kind: str,
origin_uid: str,
local_uid: str,
fingerprint: str,
origin_updated_at: str,
) -> bool:
now = _now_iso()
with db:
result = db.executable.execute(
text(
"INSERT OR IGNORE INTO sync_links (uid, origin, kind, origin_uid, "
"local_uid, fingerprint, origin_updated_at, first_synced_at, synced_at) "
"VALUES (:uid, :origin, :kind, :origin_uid, :local_uid, :fingerprint, "
":origin_updated_at, :now, :now)"
),
{
"uid": generate_uid(),
"origin": origin,
"kind": kind,
"origin_uid": origin_uid,
"local_uid": local_uid,
"fingerprint": fingerprint,
"origin_updated_at": origin_updated_at,
"now": now,
},
)
return result.rowcount == 1
def release_link(origin: str, kind: str, origin_uid: str, local_uid: str) -> None:
get_table("sync_links").delete(
origin=origin, kind=kind, origin_uid=origin_uid, local_uid=local_uid
)
def advance_link(link_uid: str, fingerprint: str, origin_updated_at: str) -> None:
get_table("sync_links").update(
{
"uid": link_uid,
"fingerprint": fingerprint,
"origin_updated_at": origin_updated_at,
"synced_at": _now_iso(),
},
["uid"],
)
def username_taken(username: str) -> bool:
return get_table("users").find_one(username=username) is not None
def count_links(origin: str) -> int:
return get_table("sync_links").count(origin=origin)
def identity_map(origin: str) -> dict[str, dict]:
return {row["source_key"]: row for row in get_table("sync_identities").find(origin=origin)}
def identity_by_source(origin: str, source_key: str) -> dict | None:
return get_table("sync_identities").find_one(origin=origin, source_key=source_key)
def identity_name_taken(our_key: str) -> bool:
return get_table("sync_identities").find_one(our_key=our_key) is not None
def claim_identity(
origin: str,
source_name: str,
source_key: str,
our_name: str,
style_fingerprint: str,
ai_generated: bool,
) -> bool:
with db:
result = db.executable.execute(
text(
"INSERT OR IGNORE INTO sync_identities (uid, origin, source_name, source_key, "
"our_name, our_key, style_fingerprint, style_samples, ai_generated, created_at) "
"VALUES (:uid, :origin, :source_name, :source_key, :our_name, :our_key, "
":style_fingerprint, '[]', :ai_generated, :now)"
),
{
"uid": generate_uid(),
"origin": origin,
"source_name": source_name,
"source_key": source_key,
"our_name": our_name,
"our_key": our_name.lower(),
"style_fingerprint": style_fingerprint,
"ai_generated": int(ai_generated),
"now": _now_iso(),
},
)
return result.rowcount == 1
def save_style_samples(identity_uid: str, samples_json: str) -> None:
get_table("sync_identities").update({"uid": identity_uid, "style_samples": samples_json}, ["uid"])
PENDING_TABLE = "sync_pending"
def park_pending(
origin: str,
kind: str,
origin_uid: str,
missing_kind: str,
missing_uid: str,
record_json: str,
*,
attempts: int,
ttl_days: int,
cap: int,
) -> bool:
now = datetime.now(timezone.utc)
with db:
result = db.executable.execute(
text(
"INSERT INTO sync_pending (uid, origin, kind, origin_uid, missing_kind, missing_uid, "
"record, attempts, created_at, expires_at) "
"SELECT :uid, :origin, :kind, :origin_uid, :missing_kind, :missing_uid, :record, "
":attempts, :now, :expires "
"WHERE (SELECT COUNT(*) FROM sync_pending WHERE origin = :origin) < :cap "
"ON CONFLICT(origin, kind, origin_uid) DO UPDATE SET "
"missing_kind = excluded.missing_kind, missing_uid = excluded.missing_uid, "
"record = excluded.record, attempts = excluded.attempts, expires_at = excluded.expires_at"
),
{
"uid": generate_uid(),
"origin": origin,
"kind": kind,
"origin_uid": origin_uid,
"missing_kind": missing_kind,
"missing_uid": missing_uid,
"record": record_json,
"attempts": int(attempts),
"now": now.isoformat(),
"expires": (now + timedelta(days=ttl_days)).isoformat(),
"cap": int(cap),
},
)
return result.rowcount >= 1
def pending_exists(origin: str, kind: str, origin_uid: str) -> bool:
return get_table(PENDING_TABLE).find_one(origin=origin, kind=kind, origin_uid=origin_uid) is not None
def ready_pending(limit: int, origin: str = "") -> list[dict]:
clause = "AND p.origin = :origin " if origin else ""
rows = db.query(
"SELECT p.* FROM sync_pending p JOIN sync_links l ON l.origin = p.origin "
"AND l.kind = p.missing_kind AND l.origin_uid = p.missing_uid "
f"WHERE 1 = 1 {clause}ORDER BY p.created_at, p.id LIMIT :n",
n=int(limit),
**({"origin": origin} if origin else {}),
)
return [dict(row) for row in rows]
def delete_pending(uid: str) -> int:
with db:
return db.executable.execute(text("DELETE FROM sync_pending WHERE uid = :uid"), {"uid": uid}).rowcount
def expire_pending(max_attempts: int) -> int:
with db:
return db.executable.execute(
text("DELETE FROM sync_pending WHERE expires_at <= :now OR attempts >= :attempts"),
{"now": _now_iso(), "attempts": int(max_attempts)},
).rowcount
def pending_counts() -> dict[str, int]:
rows = db.query("SELECT origin, COUNT(*) AS n FROM sync_pending GROUP BY origin ORDER BY n DESC LIMIT 100")
return {row["origin"]: int(row["n"]) for row in rows}
STAR_TABLES = {"post": "posts", "project": "projects"}
def recompute_stars(target_type: str, target_uid: str) -> int | None:
table = STAR_TABLES.get(target_type)
if table is None:
return None
with db:
db.executable.execute(
text(
f"UPDATE {table} SET stars = (SELECT COALESCE(SUM(value), 0) FROM votes "
"WHERE target_uid = :uid AND target_type = :type AND deleted_at IS NULL) "
"WHERE uid = :uid"
),
{"uid": target_uid, "type": target_type},
)
row = get_table(table).find_one(uid=target_uid)
return int(row["stars"] or 0) if row else None
EXPORT_CHANGED: dict[str, str] = {
"users": "created_at",
"projects": "COALESCE(deleted_at, updated_at, created_at)",
"posts": "COALESCE(deleted_at, updated_at, created_at)",
"comments": "COALESCE(deleted_at, updated_at, created_at)",
"polls": "COALESCE(deleted_at, created_at)",
"follows": "COALESCE(deleted_at, created_at)",
"attachments": "COALESCE(deleted_at, created_at)",
}
def ensure_export_indexes() -> None:
from .core import _index
for table, expression in EXPORT_CHANGED.items():
if table in db.tables:
_index(db, table, f"idx_{table}_changed", [expression, "uid"])
def deletion_rows(origin: str, kind: str, table: str, after_id: int, limit: int) -> list[dict]:
if table not in db.tables:
return []
soft = db[table].has_column("deleted_at")
columns = "t.deleted_at AS deleted_at, t.deleted_by AS deleted_by" if soft else "NULL AS deleted_at, NULL AS deleted_by"
deleted = "OR (t.deleted_at IS NOT NULL AND COALESCE(t.deleted_by, '') != :actor)" if soft else ""
rows = db.query(
f"SELECT l.id AS link_id, l.kind AS kind, l.origin_uid AS origin_uid, t.uid AS local_uid, {columns} "
f"FROM sync_links l LEFT JOIN {table} t ON t.uid = l.local_uid "
"WHERE l.origin = :origin AND l.kind = :kind AND l.id > :after "
"AND COALESCE(l.ack_at, '') = '' "
f"AND (t.uid IS NULL {deleted}) "
"ORDER BY l.id LIMIT :n",
origin=origin,
kind=kind,
after=int(after_id),
n=int(limit),
**({"actor": "sync"} if soft else {}),
)
return [dict(row) for row in rows]
def acknowledge_links(origin: str, items: list[tuple[str, str]]) -> int:
stamp = _now_iso()
done = 0
with db:
for kind, origin_uid in items:
done += db.executable.execute(
text(
"UPDATE sync_links SET ack_at = :now WHERE origin = :origin AND kind = :kind "
"AND origin_uid = :origin_uid AND COALESCE(ack_at, '') = ''"
),
{"now": stamp, "origin": origin, "kind": kind, "origin_uid": origin_uid},
).rowcount
return done
def rename_identity(identity_uid: str, our_name: str) -> bool:
try:
with db:
result = db.executable.execute(
text("UPDATE sync_identities SET our_name = :name, our_key = :key WHERE uid = :uid"),
{"name": our_name, "key": our_name.lower(), "uid": identity_uid},
)
return result.rowcount == 1
except Exception:
return False
def linked_rows_containing(origin: str, kind: str, table: str, columns: tuple[str, ...], needle: str, limit: int) -> list[dict]:
pattern = "%" + needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
matches = " OR ".join(f"t.{column} LIKE :pattern ESCAPE '\\'" for column in columns)
selected = ", ".join(f"t.{column} AS {column}" for column in columns)
rows = db.query(
f"SELECT t.uid AS uid, {selected} FROM sync_links l JOIN {table} t ON t.uid = l.local_uid "
f"WHERE l.origin = :origin AND l.kind = :kind AND ({matches}) ORDER BY l.id LIMIT :limit",
origin=origin,
kind=kind,
pattern=pattern,
limit=int(limit),
)
return [dict(row) for row in rows]
def load_candidates(origin: str) -> list[dict]:
expire_candidates()
return [dict(row) for row in get_table("sync_candidates").find(origin=origin, order_by=["id"])]
def save_candidate(origin: str, post_uid: str, record_json: str, comment_count: int, *, ttl_days: int, cap: int) -> bool:
now = datetime.now(timezone.utc)
with db:
result = db.executable.execute(
text(
"INSERT INTO sync_candidates (uid, origin, post_uid, record, comment_count, created_at, expires_at) "
"SELECT :uid, :origin, :post_uid, :record, :count, :now, :expires "
"WHERE (SELECT COUNT(*) FROM sync_candidates WHERE origin = :origin) < :cap "
"ON CONFLICT(origin, post_uid) DO UPDATE SET record = excluded.record, "
"comment_count = excluded.comment_count"
),
{
"uid": generate_uid(),
"origin": origin,
"post_uid": post_uid,
"record": record_json,
"count": int(comment_count),
"now": now.isoformat(),
"expires": (now + timedelta(days=ttl_days)).isoformat(),
"cap": int(cap),
},
)
return result.rowcount >= 1
def delete_candidate(origin: str, post_uid: str) -> int:
with db:
return db.executable.execute(
text("DELETE FROM sync_candidates WHERE origin = :origin AND post_uid = :post_uid"),
{"origin": origin, "post_uid": post_uid},
).rowcount
def expire_candidates() -> int:
with db:
return db.executable.execute(
text("DELETE FROM sync_candidates WHERE expires_at <= :now"), {"now": _now_iso()}
).rowcount