Files
devplacepy/devplacepy/docs_api/groups/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

289 lines
15 KiB
Python

# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
ENVELOPE_PARAMS = [
field("origin", "body", "string", True, "other.example.org", "Stable id of the sending community, 2-63 characters of a-z, 0-9, dots and hyphens. Lower-cased by the server."),
field("records", "body", "array", True, "[]", "1-50 records of the kind this endpoint takes. Each record is validated on its own."),
field("dry_run", "body", "boolean", False, "false", "Validate, normalize and report what would happen without writing anything."),
]
RESULT_SAMPLE = {
"origin": "other.example.org",
"kind": "posts",
"dry_run": False,
"total": 2,
"counts": {"created": 1, "updated": 0, "unchanged": 0, "stale": 0, "rejected": 0, "failed": 0, "deferred": 1},
"results": [
{"index": 0, "origin_uid": "p-1", "status": "created", "reason": "", "local_uid": "LOCAL_UID", "errors": [], "ignored": ["stars"], "needs_kind": "", "needs_uid": ""},
{"index": 1, "origin_uid": "p-2", "status": "deferred", "reason": "unknown_author", "local_uid": "", "errors": ["author u-9 is not synced yet, send users first"], "ignored": [], "needs_kind": "users", "needs_uid": "u-9"},
],
"released": {"created": 3},
}
GROUP = {
"slug": "sync",
"title": "Community sync",
"admin": True,
"intro": """
# Community sync
Copy users, projects, posts, comments, polls, votes, reactions, follows, poll ballots and attachments from another community into this one, and pull this community's own public data out. The API is idempotent
and safe to repeat: every record carries the id it has in the **origin** community
(`origin_uid`), the server remembers which local row that id maps to, and a repeated record is
an update or a no-op, never a duplicate.
- **Validated and normalized here.** The sender posts raw data; the server fixes what can be
fixed (usernames, whitespace, control characters, unknown topics, timestamps) and rejects what
cannot, record by record. One bad record never fails the batch.
- **Counters are never sent as numbers.** Stars, XP and levels cannot be verified across
communities, so they are ignored and listed under `ignored` in the result. A post or project
gets stars only from synced votes, and a vote needs a synced voter and a synced target.
- **No side effects.** Synced content sends no notifications, awards no XP and runs no AI
correction. Content that matches the content filter is rejected.
- **Order is forgiven, not required.** Send users, projects, posts, comments, polls, then the
engagement kinds. A record whose author, post, parent, target, poll or project is not synced
yet is answered `deferred` with `needs_kind` and `needs_uid`, parked for seven days (at most
5000 per origin) and applied by itself when that record arrives; the response then lists
`released` counts.
- **Off by default.** An administrator enables it at `/admin/settings` (`sync_enabled`) and may
restrict senders with `sync_origins` (comma separated; empty allows any origin).
- **JSON or form.** The body is JSON. A form-encoded body is accepted too, with `records` as a
JSON array string; Devii uses that form through the `sync_<kind>` tools.
- **Pull feed.** `GET /sync/export/{kind}` pages out this community's own users, projects,
posts, comments, polls, follows and attachments. Votes, reactions and poll ballots are never
exported because who voted is not public, and rows that were themselves synced in are never
echoed back.
- **Deletions are reported.** `GET /sync/deletions` lists synced records deleted or purged here
and `POST /sync/deletions/ack` stops them being reported.
The accepted fields and every limit are published at `/sync/spec.json`. Every endpoint follows
the shared [Conventions & Errors](/docs/conventions.html).
""",
"endpoints": [
endpoint(
id="sync-spec",
method="GET",
path="/sync/spec.json",
title="Sync specification",
summary="Limits, statuses, refusal reasons and the JSON schema of each record kind.",
auth="public",
interactive=True,
sample_response={
"limits": {"max_batch_records": 50},
"kinds": ["users", "projects", "posts", "comments", "polls", "votes", "reactions", "follows", "poll_votes", "attachments"],
"statuses": ["created", "updated", "unchanged", "stale", "rejected", "failed", "deferred"],
"reasons": ["invalid", "unknown_author"],
"records": {"users": {"title": "UserRecord", "type": "object"}},
},
),
endpoint(
id="sync-users",
method="POST",
path="/sync/users",
title="Upsert users",
summary="Create or update synced accounts. A synced account cannot sign in; it only owns the content that was synced for it.",
auth="admin",
encoding="json",
interactive=False,
params=ENVELOPE_PARAMS,
sample_response=RESULT_SAMPLE,
notes=[
"Record fields: `origin_uid`, `username`, `created_at`, optional `updated_at`, `bio`, `location`, `git_link`, `website`, `avatar_seed`, `deleted`.",
"A username that is invalid is normalized to letters, digits, `_` and `-`. A name already taken by a different account gets the origin as a suffix, so the result may differ from the one sent.",
"`deleted: true` deactivates the account. Email, password, role, XP and level are never accepted.",
],
),
endpoint(
id="sync-posts",
method="POST",
path="/sync/posts",
title="Upsert posts",
summary="Create or update posts. The author must already be synced.",
auth="admin",
encoding="json",
interactive=False,
params=ENVELOPE_PARAMS,
sample_response=RESULT_SAMPLE,
notes=[
"Record fields: `origin_uid`, `author_uid` (the author's `origin_uid`), `content`, `created_at`, optional `title`, `topic`, `updated_at`, `deleted`.",
"An unknown `topic` becomes `random`. Content shorter than 10 characters is rejected.",
],
),
endpoint(
id="sync-comments",
method="POST",
path="/sync/comments",
title="Upsert comments",
summary="Create or update comments on synced posts. The author, the post and any parent comment must already be synced.",
auth="admin",
encoding="json",
interactive=False,
params=ENVELOPE_PARAMS,
sample_response=RESULT_SAMPLE,
notes=[
"Record fields: `origin_uid`, `author_uid`, `post_uid` (the post's `origin_uid`), `content`, `created_at`, optional `parent_uid`, `updated_at`, `deleted`.",
"A parent that belongs to a different post is rejected with `parent_mismatch`.",
],
),
endpoint(
id="sync-projects",
method="POST",
path="/sync/projects",
title="Upsert projects",
summary="Create or update public, writable projects. The author must already be synced.",
auth="admin",
encoding="json",
interactive=False,
params=ENVELOPE_PARAMS,
sample_response=RESULT_SAMPLE,
notes=[
"Record fields: `origin_uid`, `author_uid`, `title`, `description`, `created_at`, optional `project_type` (game, game_asset, software, mobile_app, website), `status`, `platforms`, `website_url`, `repo_url`, `updated_at`, `deleted`.",
"Posts link to a project with `project_uid`.",
],
),
endpoint(
id="sync-polls",
method="POST",
path="/sync/polls",
title="Upsert polls",
summary="Create or update the poll of a synced post (one poll per post, 2 to 6 options).",
auth="admin",
encoding="json",
interactive=False,
params=ENVELOPE_PARAMS,
sample_response=RESULT_SAMPLE,
notes=[
"Record fields: `origin_uid`, `author_uid`, `post_uid`, `question`, `options` (a list of `{origin_uid, label}`), `created_at`, optional `deleted`.",
"Editing the options adds, renames and removes them; ballots for a removed option stay.",
],
),
endpoint(
id="sync-votes",
method="POST",
path="/sync/votes",
title="Upsert votes",
summary="Create, change or clear a vote of a synced user on a synced post, comment or project.",
auth="admin",
encoding="json",
interactive=False,
params=ENVELOPE_PARAMS,
sample_response=RESULT_SAMPLE,
notes=[
"Record fields: `origin_uid`, `voter_uid`, `target_kind` (post, comment, project), `target_uid`, `value` (1 or -1), `created_at`, optional `deleted` to clear.",
"A second origin id for the same voter and target revives the same row. Stars of posts and projects are recomputed from the live synced votes.",
],
),
endpoint(
id="sync-reactions",
method="POST",
path="/sync/reactions",
title="Upsert reactions",
summary="Add or remove an emoji reaction of a synced user.",
auth="admin",
encoding="json",
interactive=False,
params=ENVELOPE_PARAMS,
sample_response=RESULT_SAMPLE,
notes=["Record fields: `origin_uid`, `author_uid`, `target_kind`, `target_uid`, `emoji` (one emoji), `created_at`, optional `deleted`."],
),
endpoint(
id="sync-follows",
method="POST",
path="/sync/follows",
title="Upsert follows",
summary="Create or remove a follow between two synced users. No notification, XP or badge is produced.",
auth="admin",
encoding="json",
interactive=False,
params=ENVELOPE_PARAMS,
sample_response=RESULT_SAMPLE,
notes=["Record fields: `origin_uid`, `follower_uid`, `following_uid`, `created_at`, optional `deleted`. Following yourself is rejected with `self_follow`."],
),
endpoint(
id="sync-poll-votes",
method="POST",
path="/sync/poll_votes",
title="Upsert poll ballots",
summary="Record a synced user's ballot in a synced poll (one per voter; changing the option updates it).",
auth="admin",
encoding="json",
interactive=False,
params=ENVELOPE_PARAMS,
sample_response=RESULT_SAMPLE,
notes=["Record fields: `origin_uid`, `voter_uid`, `poll_uid`, `option_uid`, `created_at`, optional `deleted`. An option that does not exist in that poll is rejected with `unknown_option`."],
),
endpoint(
id="sync-attachments",
method="POST",
path="/sync/attachments",
title="Upsert attachments",
summary="Store a file for a synced post, comment or project. The sender downloads the file and sends its bytes.",
auth="admin",
encoding="json",
interactive=False,
params=ENVELOPE_PARAMS,
sample_response=RESULT_SAMPLE,
notes=[
"Record fields: `origin_uid`, `author_uid`, `target_kind`, `target_uid`, `filename`, `content_b64` (strict base64, at most 5 MB), `created_at`, optional `deleted`.",
"The normal upload rules apply (allowed types, size). An attachment cannot change: a known `origin_uid` with different bytes is rejected with `immutable`; only a tombstone or a restore is accepted.",
],
),
endpoint(
id="sync-export",
method="GET",
path="/sync/export/{kind}",
title="Export own records",
summary="Page out this community's users, projects, posts, comments, polls, follows or attachments in sync record form.",
auth="admin",
encoding="json",
interactive=False,
params=[
field("kind", "path", "string", True, "posts", "users, projects, posts, comments, polls, follows or attachments."),
field("since", "query", "string", False, "", "Only records changed after this ISO 8601 instant. Users have no change time, so for them it filters by sign-up time."),
field("cursor", "query", "string", False, "", "The `next_cursor` of the previous page."),
field("limit", "query", "integer", False, "50", "Records per page, 1 to 50."),
],
sample_response={"kind": "posts", "count": 1, "has_more": False, "next_cursor": "", "records": [{"origin_uid": "POST_UID", "author_uid": "USER_UID", "content": "...", "deleted": False}]},
notes=[
"Only public data: no email, password hash, role or API key; private projects are left out; attachments carry a `url` instead of bytes.",
"Votes, reactions and poll ballots are never exported. Rows that arrived by sync are never exported, so two communities cannot echo each other's data.",
"Soft-deleted rows appear as records with `deleted: true`. Audited as `sync.export`.",
],
),
endpoint(
id="sync-deletions",
method="GET",
path="/sync/deletions",
title="List local deletions",
summary="Synced records of one origin that were deleted or purged on this side and not yet acknowledged.",
auth="admin",
encoding="json",
interactive=False,
params=[
field("origin", "query", "string", True, "other.example.org", "The sending community's origin id."),
field("cursor", "query", "string", False, "", "The `next_cursor` of the previous page."),
field("limit", "query", "integer", False, "50", "Entries per page, 1 to 50."),
],
sample_response={"origin": "other.example.org", "count": 1, "has_more": False, "next_cursor": "", "items": [{"kind": "posts", "origin_uid": "p-1", "by": "member", "deleted_at": "2026-10-16T10:00:00+00:00"}]},
notes=["`by` is `purged` (the row is gone), `system` or `member`. A deletion made by the sync itself is never listed."],
),
endpoint(
id="sync-deletions-ack",
method="POST",
path="/sync/deletions/ack",
title="Acknowledge local deletions",
summary="Tell this community that the origin has seen the deletions, so they are no longer listed or reported.",
auth="admin",
encoding="json",
interactive=False,
params=[
field("origin", "body", "string", True, "other.example.org", "The sending community's origin id."),
field("items", "body", "array", True, "[]", "1-50 objects `{kind, origin_uid}`."),
],
sample_response={"origin": "other.example.org", "acknowledged": 1},
notes=["After acknowledging, a re-sent record answers `unchanged` with reason `acknowledged_deleted`. Audited as `sync.deletions.ack`."],
),
],
}