WATCH & Triggers

WATCH observes a resource continuously and fires an expression automatically when a real change occurs — no incoming request required. It shares the exact same persistent, registry-backed architecture as SCHEDULE & Cron Jobs, applied to an observed condition instead of a fixed moment or interval.

One Form, Any Resource

NEW WATCH [check] EVERY n UNIT ONCHANGE [trigger] is the whole grammar. The check is an ordinary inline expression; what is being watched is simply whatever it returns. A database, a file, a URL, a stored value and anything else expressible inline all use the same shape — there is no per-resource syntax to learn.

The check is compared on what it RETURNS, not on what it prints. [FETCH "url"] emits nothing and still works: the fetched result is its return value, and that is what the baseline holds. Adding EMIT is unnecessary. If a check does emit, its printed output is what gets compared instead.

Writing the Triggered Expression

A watch fires later, in a script of its own — the script that registered it is long gone, and operations are script-scoped. Both brackets take one statement, so an AFTER chain inside a bracket will not parse. Use SIDELOAD to carry a whole body inline, or INCLUDE to point at a reusable file.

SIDELOAD — the body travels with the watch
NEW WATCH [COUNT ROWS FROM DB "shopdb" TABLE "orders"] EVERY 1 MINUTE ONCHANGE [SIDELOAD `
NEW OPERATION notify_new_order
OPEN
  COUNT ROWS FROM DB "shopdb" TABLE "orders" SET ?n
  AFTER MAIL TEXT BODY "Order count is now " & ?n TO "admin@example.com" SUBJECT "New Order"
CLOSE
AFTER RUN notify_new_order
`] SET ?watch
INCLUDE — the body lives in a reusable file
NEW WATCH [COUNT ROWS FROM DB "shopdb" TABLE "orders"] EVERY 1 MINUTE ONCHANGE [INCLUDE "/root/ops/notify_new_order.oql"] SET ?watch
Which to use. SIDELOAD keeps everything in the registered row, and a parse error inside it is an ordinary catchable error. INCLUDE shares one file across many jobs, but a missing include is INFINITE — uncatchable. Delete the file and every fire records status = failed.
The trigger receives nothing. It runs as an independent expression; the changed value is not passed to it. A trigger that needs the new state reads it itself, exactly as the check does.

Watching a Database Table

The check decides what “changed” means. Counting rows detects a net change in how many there are — an insert and a delete between two polls cancel out. To react to a specific value instead, select that value: [SELECT ROWS FROM DB "shopdb" TABLE "orders" WHERE "status" IS EQUAL TO "pending"] fires whenever that result differs from the one captured when the watch was registered.

Watching a File

Fire When a File Appears
NEW WATCH [FILE EXISTS "/root/uploads/invoice.pdf"] EVERY 1 MINUTE ONCHANGE [INCLUDE "/root/ops/process_upload.oql"] SET ?watch
Fire When Its Contents Change
NEW WATCH [FILE READ "/root/config.txt"] EVERY 1 MINUTE ONCHANGE [INCLUDE "/root/ops/on_config_file.oql"] SET ?watch
Files use the same one form as everything else. [FILE EXISTS "path"] goes falsetrue when a file appears; [FILE READ "path"] fires whenever the contents differ. Detection is by polling at your chosen interval — not instant.

Watching a PERSISTENT Value

Trigger on Change
NEW WATCH [RECALL "config"] EVERY 1 MINUTE ONCHANGE [INCLUDE "/root/ops/on_config_changed.oql"] SET ?watch

Watching an External URL

The same form again, pointed outward: WATCH is not limited to internal OcaltQL state — it can poll any external resource and fire when the fetched result differs from what was captured at registration time.

Trigger on an External Page Changing
NEW WATCH [FETCH "https://ql.ocalt.com/tutorial/fixture.html"] EVERY 1 HOUR ONCHANGE [SIDELOAD `
MAIL TEXT BODY "The watched page changed" TO "admin@example.com" SUBJECT "Page Changed"
`] SET ?watch
The baseline is captured when you register. Creating a watch evaluates its check once, immediately, and stores the result. Nothing fires until a later poll returns something different, so a freshly registered watch never fires against an empty starting state. If the check cannot be evaluated at registration — an unreachable URL, a table that does not exist, invalid syntax — the watch is refused outright rather than registered in a state it could never compare against.
A real limitation, not solved by syntax alone. A naive whole-response diff against a noisy page — view counters, timestamps, rotating ad content — will report a change on nearly every poll, making "changed" close to meaningless as a signal. WATCH compares the full fetched result against the baseline captured at registration; scoping what actually counts as a meaningful change is the script author's own responsibility, typically by extracting only the specific value that matters before comparing.

Intervals

Ten unit words are accepted: SECOND SECONDS MINUTE MINUTES HOUR HOURS DAY DAYS WEEK WEEKS. The master scheduler wakes every 10 seconds, so that is the effective floor — EVERY 1 SECOND behaves as roughly ten. An interval is a minimum, not a grid: the next poll is scheduled from the moment the last one finished, so a watch drifts progressively later rather than holding the clock.

Lifecycle, Status and Errors

A watch is a standing observer. It fires on a change, stores the new value as its baseline, and re-arms — so the next change fires it again, indefinitely. It is never marked done and never removed automatically; deleting it is your call. WATCH LIST and WATCH DESCRIBE return done, status (pending, ok or failed) and, on DESCRIBE, last_error with the failure message verbatim.

Every poll and every fire spends a query. A watch run is an ordinary API call made in your name: your namespace, your daily query usage. A watch at EVERY 1 MINUTE costs at least 1,440 queries a day before it ever fires.

Registry — List, Delete, Describe

WATCH Registry
WATCH LIST SET ?watches
AFTER FOREACH ?watches SET ?key AS ?watch
OPEN
WATCH DESCRIBE ?watch SET ?info
AFTER EMIT ?info & "\n"
CLOSE
WATCH Registry — Delete Every Job
WATCH LIST SET ?watches
AFTER FOREACH ?watches SET ?key AS ?watch
OPEN
WATCH DESCRIBE ?watch SET ?info
AFTER WATCH DELETE ?watch
CLOSE
Internal architecture. Every registered watch is a row in the same persistent registry as SCHEDULE and CRON, inside ocalt-core. A master scheduler wakes every 10 seconds, takes whatever is due, and runs each one as its owner through the ordinary ql-runtime API — same endpoint, same credentials, same quota and namespace as a request you make yourself. Every kind is polling-based without exception, so detection is bounded by your interval. Runs execute in parallel and each is allowed the full 300-second OcaltQL ceiling, so a slow watch never holds up anyone else's. Windows missed while the scheduler is down are not replayed — the job runs once when it returns and re-schedules from that moment.