anbaric 1.36.0 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,10 @@ rejected`. The review step waits for a human.
22
22
 
23
23
  ```ts
24
24
  // src/machine.ts
25
- import {Action, Await, Code, PropertyDefinition, State, StateMachine, Terminal, Transition} from "anbaric";
25
+ import {Action, Await, Code, JobPersistenceFactory, PropertyDefinition, State, StateMachine, Terminal, Transition} from "anbaric";
26
+
27
+ // Shared with the web layer so pages can read a job's state without a reload.
28
+ const persistence = JobPersistenceFactory.instance();
26
29
 
27
30
  const amount = new PropertyDefinition("amount");
28
31
  amount.required = true;
@@ -56,9 +59,9 @@ const expenses = new StateMachine("expenses", [
56
59
  ]),
57
60
  new Terminal("paid", Terminal.Outcome.SUCCESS),
58
61
  new Terminal("rejected", Terminal.Outcome.FAILURE),
59
- ], "submitted", [amount, approved, paid]);
62
+ ], "submitted", [amount, approved, paid], persistence);
60
63
 
61
- export {expenses};
64
+ export {expenses, persistence};
62
65
  ```
63
66
 
64
67
  Notice the shape: the `submitted` state parks on `review` until a manager sets
@@ -68,16 +71,53 @@ state pays automatically and ends at `paid`.
68
71
  ## 3. Serve a tiny UI
69
72
 
70
73
  The review page reads the job id from the query string and resolves the `Await`
71
- by updating the job. (Any HTML works; here's the shape.)
74
+ by updating the job. Updating a job only *queues* the change, so the page
75
+ acknowledges the click, then **polls the job and updates in place** until it
76
+ settles - it never reloads to find out what happened. (Any HTML works; here's
77
+ the shape.)
72
78
 
73
79
  ```ts
74
80
  // src/main.ts
75
81
  import {createServer} from "node:http";
76
- import {Human} from "anbaric";
77
- import {expenses} from "./machine.js";
82
+ import {Human, serializeJob, SystemActor} from "anbaric";
83
+ import {expenses, persistence} from "./machine.js";
84
+
85
+ const reviewPage = (jobId : string) => `<!doctype html>
86
+ <p id="status">Loading…</p>
87
+ <button id="approve">Approve</button>
88
+ <script>
89
+ const jobId = ${JSON.stringify(jobId)};
90
+ const status = document.getElementById("status");
91
+ const approve = document.getElementById("approve");
92
+ let submitted = false;
93
+ let polls = 0;
94
+ // Done when the job ended, or is parked waiting for this person's decision.
95
+ const settled = (job) => job.status !== "ACTIVE" || (job.waitingFor && ! submitted);
96
+
97
+ const refresh = async () => {
98
+ const job = await (await fetch("status?job=" + jobId)).json(); // relative link!
99
+ status.textContent = job.status === "FAILED" ? "Failed - see the audit trail" : "State: " + job.state;
100
+ approve.disabled = job.state !== "submitted";
101
+ if (settled(job)) return;
102
+ if (polls++ < 60) setTimeout(refresh, 1000); // at most once a second
103
+ else status.textContent += " - still working, check back shortly";
104
+ };
105
+
106
+ approve.onclick = async () => {
107
+ approve.disabled = true;
108
+ submitted = true;
109
+ polls = 0;
110
+ status.textContent = "Approval submitted - processing…";
111
+ await fetch("review?job=" + jobId, { method: "POST" });
112
+ refresh();
113
+ };
114
+
115
+ refresh();
116
+ </script>`;
78
117
 
79
118
  createServer(async (req, res) => {
80
119
  const url = new URL(req.url ?? "/", "http://localhost");
120
+ const jobId = url.searchParams.get("job") ?? "";
81
121
 
82
122
  if (url.pathname === "/submit") {
83
123
  const job = await expenses.startJob(new Map([["amount", 42]]));
@@ -85,19 +125,28 @@ createServer(async (req, res) => {
85
125
  return;
86
126
  }
87
127
 
128
+ if (url.pathname === "/status") {
129
+ const job = await persistence.retrieve(jobId, SystemActor.actor);
130
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(serializeJob(job)));
131
+ return;
132
+ }
133
+
88
134
  if (url.pathname === "/review" && req.method === "POST") {
89
- const jobId = url.searchParams.get("job")!;
90
135
  const actor = await Human.fromSession(req); // the logged-in manager
91
136
  await expenses.updateJob(jobId, new Map([["approved", true]]), actor);
92
- res.writeHead(303, { Location: "review?job=" + jobId }).end(); // relative link!
137
+ res.writeHead(202).end(); // queued - the page polls for the outcome
93
138
  return;
94
139
  }
95
140
 
96
- res.writeHead(200, { "content-type": "text/html" })
97
- .end(`<form method="post" action="review?job=${url.searchParams.get("job") ?? ""}">…</form>`);
141
+ res.writeHead(200, { "content-type": "text/html" }).end(reviewPage(jobId));
98
142
  }).listen(Number(process.env.PORT ?? 3000));
99
143
  ```
100
144
 
145
+ Only fall back to a plain form `POST` with a redirect back to the page when the
146
+ client can't run script; then the page shows the state as of the reload, and the
147
+ person has to refresh to see it move. See
148
+ [UX practices](ux-practices.md) for the rules on polling and feedback.
149
+
101
150
  > Locally there's no signed session, so `Human.fromSession` will throw — for a
102
151
  > local run, substitute `new Human("dev", "manager")`. Deployed, the real session
103
152
  > is resolved. See [Serving a web UI](../features/serving-a-web-ui.md).
@@ -26,6 +26,12 @@ sent twice.
26
26
  After acknowledging, watch the job so the page reflects reality rather than a
27
27
  guess. Poll it and re-render as the state changes:
28
28
 
29
+ - **Update the page in place - don't reload it.** Expose the job's state as JSON
30
+ (read it from the machine's persistence and `serializeJob` it) and `fetch` that
31
+ from the page, updating the DOM as it changes. A full page refresh - a form
32
+ `POST` answered with a redirect, a meta refresh, `location.reload()` - is the
33
+ fallback for a client that cannot run script, not the default: it shows the
34
+ state as of the reload and leaves the person refreshing by hand to see it move.
29
35
  - **Poll at most once per second.** Anything faster adds load without telling
30
36
  the user anything new; a job that transitions immediately is still only
31
37
  observable per processing pass.
@@ -46,6 +52,30 @@ A job whose action threw is `Job.Status.FAILED`, with the reason in its audit
46
52
  trail. Show that the work stopped and why. Silently leaving the last-known state
47
53
  on screen turns a failure into a mystery.
48
54
 
55
+ ## Say it was built with Anbaric - quietly
56
+
57
+ Every app built on Anbaric carries a small **"Built with Anbaric"** line, at the
58
+ foot of the page or the bottom of the nav. It's an attribution, not a feature:
59
+ small type, a muted tint, the ident (its Asriel Ink is near enough black) at
60
+ text height, and nothing that draws the eye away from the app's own UI.
61
+
62
+ With the design system, it's one component:
63
+
64
+ ```tsx
65
+ import { BuiltWithAnbaric } from '@anbaric/design-system/components/BuiltWithAnbaric'
66
+
67
+ <footer><BuiltWithAnbaric /></footer>
68
+ ```
69
+
70
+ Without it, the same thing by hand - the ident lives at
71
+ `shared/assets/anbaric-ident.svg` in the design system:
72
+
73
+ ```html
74
+ <a href="https://anbaric.ai" style="display:inline-flex;align-items:center;gap:.4rem;font-size:.75rem;opacity:.7;text-decoration:none;color:inherit">
75
+ <img src="anbaric-ident.svg" alt="" width="12" height="12"> Built with Anbaric
76
+ </a>
77
+ ```
78
+
49
79
  ## Styling (optional)
50
80
 
51
81
  If the user hasn't asked for a particular look, you may use the **Anbaric design
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anbaric",
3
- "version": "1.36.0",
3
+ "version": "1.38.0",
4
4
  "description": "Everything needed to write an Anbaric app: state machines, jobs, document and secret stores, local in-memory implementations and the Anbaric Cloud clients",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,9 +24,9 @@
24
24
  "prepublishOnly": "npm run build"
25
25
  },
26
26
  "dependencies": {
27
- "anbaric-impl-cloud": "^1.36.0",
28
- "anbaric-data-store": "^1.36.0",
29
- "anbaric-state-machine": "^1.36.0",
30
- "anbaric-tsapi": "^1.36.0"
27
+ "anbaric-impl-cloud": "^1.38.0",
28
+ "anbaric-data-store": "^1.38.0",
29
+ "anbaric-state-machine": "^1.38.0",
30
+ "anbaric-tsapi": "^1.38.0"
31
31
  }
32
32
  }