Guide: keep a knowledge base in sync
A complete, restartable sync job that copies every SOP out of SOPCompass, then picks up only what changed, including archives and deletions.
On this page
This guide builds a job that keeps a copy of every SOP somewhere else: an intranet, a wiki, a search index or a warehouse table. It needs a read only key.
How it works
GET /sopsis ordered byupdated_at, oldest first, andupdated_sincereturns everything that changed at or after a time. So a job that remembers the largestupdated_atit has seen can ask for only what changed since.- The list doesn’t include content, so the job fetches
GET /sops/{id}only for SOPs that are new or changed. include_archived=truemakes archived SOPs come back witharchived_atset, so the job can hide them.- A periodic full pass (no
updated_since) catches the things a change feed can’t: deleted SOPs and libraries that stopped being shared.
Where to store things
The job needs two things to persist between runs:
- The checkpoint: the largest
updated_atstring seen. Store the API’s string exactly. - Your copy of each SOP, keyed by
id, including itsupdated_at.
The example stores both in a SQLite file; swap in your own database or target system.
The job
Python
"""Sync SOPCompass SOPs into a local SQLite database.
Run it on a schedule (every 15 minutes is plenty). Pass --full once a week
to also remove SOPs that were deleted or stopped being shared.
"""
import json
import os
import sqlite3
import sys
import time
import requests
BASE = "https://app.sopcompass.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SOPCOMPASS_API_KEY']}"
db = sqlite3.connect("sopcompass.db")
db.execute("create table if not exists sops (id text primary key, updated_at text, archived integer, doc text)")
db.execute("create table if not exists state (k text primary key, v text)")
def call(path, params=None):
for attempt in range(6):
res = session.get(f"{BASE}{path}", params=params, timeout=30)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", "5")))
continue
if res.status_code >= 500:
time.sleep(min(30, 2 ** attempt))
continue
body = res.json()
if not res.ok:
raise RuntimeError(f"{res.status_code} {body['error']['code']}: {body['error']['message']}")
return body
raise RuntimeError(f"gave up on {path}")
def list_sops(updated_since):
params = {"include_archived": "true", "limit": 100}
if updated_since:
params["updated_since"] = updated_since
while True:
page = call("/sops", params)
yield from page["data"]
if not page["has_more"]:
return
params["cursor"] = page["next_cursor"]
def run(full):
row = db.execute("select v from state where k = 'checkpoint'").fetchone()
checkpoint = None if full or not row else row[0]
newest = checkpoint
seen = set()
for item in list_sops(checkpoint):
seen.add(item["id"])
mine = db.execute("select updated_at from sops where id = ?", (item["id"],)).fetchone()
if mine is None or mine[0] < item["updated_at"]:
sop = call(f"/sops/{item['id']}")["data"]
db.execute(
"insert into sops (id, updated_at, archived, doc) values (?, ?, ?, ?) "
"on conflict(id) do update set updated_at = excluded.updated_at, "
"archived = excluded.archived, doc = excluded.doc",
(sop["id"], sop["updated_at"], sop["archived_at"] is not None, json.dumps(sop)),
)
if newest is None or item["updated_at"] > newest:
newest = item["updated_at"]
if full:
# Anything we hold that a full pass didn't return is gone: deleted,
# unshared, or archived in a library shared in from another workspace.
for (sop_id,) in db.execute("select id from sops").fetchall():
if sop_id not in seen:
db.execute("delete from sops where id = ?", (sop_id,))
if newest:
db.execute("insert into state (k, v) values ('checkpoint', ?) "
"on conflict(k) do update set v = excluded.v", (newest,))
db.commit()
if __name__ == "__main__":
run(full="--full" in sys.argv)Why it’s correct
- Nothing is missed.
updated_sinceis inclusive and the list is ordered byupdated_at, so every change at or after the checkpoint is returned. An SOP edited while the job is paging moves to the end of the list and is picked up on a later page, or on the next run. - Nothing is fetched twice for no reason. The
updated_atcomparison skips SOPs you already have at that version, including the one sitting exactly on the checkpoint. - It’s restartable. The checkpoint is saved only after a run finishes. If a run dies halfway, the next one starts from the old checkpoint and the
updated_atcomparison skips what was already stored. - Archives are handled. Archived SOPs come back with
archived_atset and the job marks them; restoring clears it.
Rendering what you stored
cards[].body_htmlandcards[].steps[].htmlare stored HTML. Sanitize them before showing them on a page of yours.cards[].image_urlslists every image a card shows. Images are hosted by SOPCompass; if your mirror must work without SOPCompass, download and re-host them.app_urllinks back to the SOP in the app, for people who can sign in.
Cost in requests
A run costs one request per 100 SOPs listed, plus one per changed SOP. At 60 requests a minute per key, the first full copy of 1,000 SOPs takes about 17 minutes, which the retry logic handles on its own. Incremental runs usually cost a handful of requests.