forked from retoor/devplacepy
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>
361 lines
12 KiB
Python
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
|