@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.
- package/README.md +2 -1
- package/dist/qstash/src/doors.js +1 -1
- package/dist/qstash/src/doors.ts +2 -2
- package/dist/qstash/src/fetch.js +2 -2
- package/dist/qstash/src/fetch.ts +2 -2
- package/dist/qstash/src/semantics/delivery.d.ts +1 -1
- package/dist/qstash/src/semantics/delivery.js +13 -8
- package/dist/qstash/src/semantics/delivery.ts +13 -8
- package/dist/src/upstash-lua.d.ts +8 -140
- package/dist/src/upstash-lua.js +13 -1227
- package/dist/src/upstash-server.js +8 -4
- package/dist/src/upstash-store.d.ts +15 -87
- package/dist/src/upstash-store.js +49 -1579
- package/dist/src/upstash-twin.js +2 -2
- package/package.json +4 -4
- package/qstash/src/doors.ts +2 -2
- package/qstash/src/fetch.ts +2 -2
- package/qstash/src/semantics/delivery.ts +13 -8
- package/src/manifest.ts +3 -2
- package/src/upstash-lua.ts +31 -1119
- package/src/upstash-server.ts +8 -4
- package/src/upstash-store.ts +73 -1346
- package/src/upstash-twin.ts +2 -2
package/dist/src/upstash-twin.js
CHANGED
|
@@ -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 ??
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/qstash/src/doors.ts
CHANGED
|
@@ -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') {
|
package/qstash/src/fetch.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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>` (
|
|
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.
|