@volter/twin-upstash 0.1.0 → 0.1.2

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.
@@ -37,7 +37,7 @@
37
37
  // SDK, whose `base64decode` SWALLOWS failures and returns the raw input — so an unencoded
38
38
  // `"PONG"` (valid base64!) decodes to mojibake rather than erroring.
39
39
  import { commandShapeError, execRedisRun, RedisCommandError, RedisStatus, ReadOnlyError, } from "./upstash-store.js";
40
- import { ownFields, twinResources } from '@volter/world-core';
40
+ import { ownFields, twinResources, worldNow } from '@volter/world-core';
41
41
  import { SERVICE } from "./upstash-store.js";
42
42
  /** The literal 401 body the real service returns — LIVE-PROBED, byte for byte, including the
43
43
  * `docs.upstash.com` host (NOT `upstash.com/docs`) and the trailing period. The SAME body and
@@ -185,7 +185,7 @@ export async function handleUpstashRedisTwinRequest(req) {
185
185
  const query = new URLSearchParams(rawQuery ?? '');
186
186
  const path = (rawPath ?? '/').replace(/\/+$/, '') || '/';
187
187
  const headers = lowerHeaders(req.headers);
188
- const occurredAt = req.occurredAt ?? new Date().toISOString();
188
+ const occurredAt = req.occurredAt ?? worldNow();
189
189
  // CORS preflight: LIVE-PROBED as 200 with an empty body, echoing the request's own origin and
190
190
  // requested headers — NOT a 405, despite the docs listing only HEAD/GET/POST/PUT.
191
191
  if (method === 'OPTIONS')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-upstash",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local Upstash Redis (REST API) twin: a real stateful Redis command core (strings/counters/TTL/hashes/sets/sorted-sets/keyspace) served over Upstash's per-database REST protocol — single-command POST /, path-style GET /get/key, POST /pipeline, POST /multi-exec, Bearer-token auth, the {result}/{error} envelope, Upstash-Encoding: base64 — plus an EVAL/EVALSHA Lua-subset interpreter that runs @upstash/ratelimit's real fixed-window/sliding-window/token-bucket scripts. Built on @volter/world-core.",
5
5
  "author": "Volter (https://github.com/volter-ai)",
6
6
  "license": "Apache-2.0",
@@ -37,17 +37,17 @@
37
37
  "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
38
38
  },
39
39
  "dependencies": {
40
- "@volter/world-ui": "0.1.0",
40
+ "@volter/world-ui": "0.1.2",
41
41
  "react": "^19.2.7",
42
42
  "react-dom": "^19.2.7"
43
43
  },
44
44
  "peerDependencies": {
45
- "@volter/world-core": "2.0.0"
45
+ "@volter/world-core": "2.0.2"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/bun": "^1.2.20",
49
49
  "@types/node": "^24.0.0",
50
- "@volter/world-core": "2.0.0",
50
+ "@volter/world-core": "2.0.2",
51
51
  "@volter/world-tooling": "0.1.0",
52
52
  "@upstash/redis": "1.35.7",
53
53
  "@upstash/ratelimit": "2.0.8",
@@ -5,7 +5,7 @@
5
5
  // `POST /_twin/recipients/:domain`).
6
6
  //
7
7
  // - GET /_twin/deliveries?to=<url> → {to, deliveries}: every request QStash made to that URL, oldest first, each as the
8
- // endpoint received it (its method, headers and body) and the status it answered.
8
+ // endpoint received it (its method, headers and body), when it fell due (`at`) and was sent (`sentAt`), and the status it answered.
9
9
  // - POST /_twin/destinations {url, status, body?, headers?, when?} → 204: the World's word on how the endpoints under a URL
10
10
  // answer, and, with `when` (header names and values), how they answer a delivery carrying those headers (a workflow's
11
11
  // route answers its failure call, `Upstash-Workflow-Is-Failure: true`, apart from its steps). The statement that names
@@ -36,7 +36,7 @@ export async function door(ctx: SemanticsContext, request: Request, report: Catc
36
36
  if (!to) return Response.json({ error: 'to is required' }, { status: 400 });
37
37
  const deliveries = ctx.rowsRaw(DELIVERY).filter((d) => d.to === to)
38
38
  .sort((a, b) => String(a.at).localeCompare(String(b.at)) || Number(a.n) - Number(b.n))
39
- .map((d) => ({ messageId: d.message_id, retried: d.retried, at: d.at, method: d.method, headers: d.headers, body: d.body, status: d.status }));
39
+ .map((d) => ({ messageId: d.message_id, retried: d.retried, at: d.at, ...(d.sent_at !== undefined ? { sentAt: d.sent_at } : {}), method: d.method, headers: d.headers, body: d.body, status: d.status }));
40
40
  return Response.json({ to, deliveries });
41
41
  }
42
42
  if (request.method === 'POST' && path === '/_twin/destinations') {
@@ -2,7 +2,7 @@
2
2
  // World's doors and the lane's gate in front, and the catch-up before all of them. The upstash pack's fetch
3
3
  // (../../src/upstash-server.ts) sends it every request for a QStash host, every `/v2/` path (an application reaches QStash
4
4
  // at its QSTASH_URL, the pack's own address) and the lane's doors.
5
- import { bindSemantics, coreFor, createDerivedFetch, crossCutting, semanticsContext, type DerivedFetch, type DerivedSurface } from '@volter/world-core';
5
+ import { bindSemantics, coreFor, worldNow, createDerivedFetch, crossCutting, semanticsContext, type DerivedFetch, type DerivedSurface } from '@volter/world-core';
6
6
  import surface from './generated/surface.gen.json' with { type: 'json' };
7
7
  import { manifest, spanning } from './manifest.ts';
8
8
  import { door, DOOR } from './doors.ts';
@@ -36,7 +36,7 @@ export function createQstashLaneFetch(options: QstashLaneOptions = {}): DerivedF
36
36
  let report: CatchUpReport = { delivered: [], retried: [], failed: [] };
37
37
  if (!options.readOnly && !catchingUp.has(key)) {
38
38
  catchingUp.add(key);
39
- try { report = await catchUp(ctx); } finally { catchingUp.delete(key); }
39
+ try { report = await catchUp(ctx, () => Date.parse((options.clock ?? worldNow)())); } finally { catchingUp.delete(key); }
40
40
  }
41
41
  const own = await door(ctx, request, report);
42
42
  if (own) return own;
@@ -125,7 +125,7 @@ export type CatchUpReport = { delivered: Row[]; retried: Row[]; failed: Row[] };
125
125
 
126
126
  type Due = { when: number; order: number; act: (at: SemanticsContext, report: CatchUpReport) => Promise<void> };
127
127
 
128
- function nextDue(ctx: SemanticsContext, until: number): Due | undefined {
128
+ function nextDue(ctx: SemanticsContext, until: number, live: () => number): Due | undefined {
129
129
  const due: Due[] = [];
130
130
  for (const s of ctx.rowsRaw('Schedule')) {
131
131
  if (typeof s.nextScheduleTime === 'number' && s.nextScheduleTime <= until) due.push({ when: s.nextScheduleTime, order: 0, act: (at) => fire(at, s) });
@@ -139,7 +139,7 @@ function nextDue(ctx: SemanticsContext, until: number): Due | undefined {
139
139
  const when = Number(m._due);
140
140
  if (when > until) continue;
141
141
  if (m.state === 'CANCEL_REQUESTED') due.push({ when, order: 2 + Number(m._ordinal) / 1e9, act: (at) => cancelled(at, m) });
142
- else if (eligible(ctx, m, all)) due.push({ when, order: 3 + Number(m._ordinal) / 1e9, act: (at, report) => attempt(at, m, report) });
142
+ else if (eligible(ctx, m, all)) due.push({ when, order: 3 + Number(m._ordinal) / 1e9, act: (at, report) => attempt(at, m, report, live) });
143
143
  }
144
144
  for (const resource of ['DLQMessage', 'WorkflowDLQMessage']) {
145
145
  for (const e of ctx.rowsRaw(resource)) {
@@ -151,14 +151,14 @@ function nextDue(ctx: SemanticsContext, until: number): Due | undefined {
151
151
  }
152
152
 
153
153
  /** Run everything due by the World's instant, in order, each at its own moment. */
154
- export async function catchUp(ctx: SemanticsContext): Promise<CatchUpReport> {
154
+ export async function catchUp(ctx: SemanticsContext, live: () => number = () => nowMs(ctx)): Promise<CatchUpReport> {
155
155
  const until = nowMs(ctx);
156
156
  const report: CatchUpReport = { delivered: [], retried: [], failed: [] };
157
157
  // a message held behind another (the one before it in its queue or its run) goes once that one has left, so the World
158
158
  // clock never runs back: each move happens at its own due time or at the last move's, whichever is later
159
159
  let last = 0;
160
160
  for (let n = 0; n < 5000; n += 1) {
161
- const next = nextDue(ctx, until);
161
+ const next = nextDue(ctx, until, live);
162
162
  if (!next) break;
163
163
  last = Math.max(last, next.when);
164
164
  await next.act(await ctx.at(iso(last)), report);
@@ -197,9 +197,14 @@ async function move(ctx: SemanticsContext, m: Row, to: string, actor: 'time' | '
197
197
  m.state = to;
198
198
  }
199
199
 
200
- /** One delivery attempt of a message, at the moment it is due. */
201
- async function attempt(ctx: SemanticsContext, m: Row, report: CatchUpReport): Promise<void> {
200
+ /** One delivery attempt of a message, at the moment it is due. Its signature is issued when it is sent — the World
201
+ * clock as the post leaves (`live`), never before the due time: QStash signs a request when it sends it, and a
202
+ * delivery the World makes late (its clock moved past the due time) reaches an application already living at that
203
+ * instant, which refuses a token that expired while it waited (dub walk 4: every workflow step after a clock shift
204
+ * answered "signature verification failed"). A long catch-up on a running clock signs each post at its own instant. */
205
+ async function attempt(ctx: SemanticsContext, m: Row, report: CatchUpReport, live: () => number): Promise<void> {
202
206
  const when = nowMs(ctx);
207
+ const sentAt = Math.max(when, live());
203
208
  const tried = Number(m._attempts ?? 0);
204
209
  await move(ctx, m, 'ACTIVE', 'time', { _attempts: tried });
205
210
  const published = (m._published ?? {}) as Record<string, string>;
@@ -213,11 +218,11 @@ async function attempt(ctx: SemanticsContext, m: Row, report: CatchUpReport): Pr
213
218
  const key = accountOf(ctx, String(m._owner ?? 'world')).current;
214
219
  const headers: Record<string, string> = {
215
220
  ...base, 'upstash-message-id': String(m.messageId), 'upstash-retried': String(tried),
216
- 'upstash-signature': sign(body, url, Math.floor(when / 1000), `jwt_${String(m.messageId)}_${tried}`, key),
221
+ 'upstash-signature': sign(body, url, Math.floor(sentAt / 1000), `jwt_${String(m.messageId)}_${tried}`, key),
217
222
  };
218
223
  const method = String(m.method ?? 'POST');
219
224
  const outcome = stated(ctx, url, headers) ?? await post(url, method, headers, body);
220
- await ctx.record(DELIVERY, { to: url, message_id: m.messageId, retried: tried, at: ctx.occurredAt, n: ctx.rowsRaw(DELIVERY).length, method, headers, body, status: outcome.status }, `delivery:${String(m.messageId)}:${tried}`);
225
+ await ctx.record(DELIVERY, { to: url, message_id: m.messageId, retried: tried, at: ctx.occurredAt, sent_at: iso(sentAt), n: ctx.rowsRaw(DELIVERY).length, method, headers, body, status: outcome.status }, `delivery:${String(m.messageId)}:${tried}`);
221
226
  const n = tried + 1;
222
227
  const response = { _response_status: outcome.status, _response_body: outcome.body, _response_headers: outcome.headers };
223
228
  if (outcome.status >= 200 && outcome.status < 300) {
package/src/manifest.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Upstash Redis's manifest: the vendor facts Redis's command table does not carry (docs/contributing/architecture.md,
2
2
  // "Protocol 3"). The surface is generated (./generated/surface.gen.json, from ../spec/commands by scripts/derive-pack.ts):
3
- // Redis's command table, one entry per command id with its arity, dispatched by upstash-store.ts. The wire is
3
+ // Redis's command table, one entry per command id with its arity, dispatched by the kernel's Redis command core
4
+ // (@volter/world-core/redis) under this pack's dialect (upstash-store.ts). The wire is
4
5
  // Upstash's REST envelope (upstash.com/docs/redis/features/restapi), served by the pack's own fetch
5
6
  // (upstash-server.ts over upstash-twin.ts), each form it serves cited to that page there.
6
7
  //
@@ -10,7 +11,7 @@
10
11
  // kernel would enforce a declared `auth`, so none is declared, as psdb's manifest declares none.
11
12
  //
12
13
  // THE STATE. Keys and their values are the World's state: each key one world-store subject `key:<name>` (its kind,
13
- // value, deadline and a write ordinal), each cached script one subject `script:<sha1>` (upstash-store.ts, the
14
+ // value, deadline and a write ordinal), each cached script one subject `script:<sha1>` (the kernel's engine.ts, the
14
15
  // KeySpace). No command moves a declared state: a key's TTL is a deadline its reads compare with the World clock, and a
15
16
  // key's kind is fixed by the command that creates it (a command against another kind answers WRONGTYPE). So no state
16
17
  // machine is declared.