@volter/twin-upstash 0.1.0 → 0.1.1

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 CHANGED
@@ -30,7 +30,8 @@ silent no-op.
30
30
 
31
31
  ## `EVAL` runs a Lua interpreter
32
32
 
33
- `upstash-lua.ts` is a tokenizer, parser and evaluator for the Lua subset real Redis scripts use.
33
+ The kernel's Redis library (`@volter/world-core/redis`, `lua.ts`; this pack's `upstash-lua.ts` names its command
34
+ API `redis`) is a tokenizer, parser and evaluator for the Lua subset real Redis scripts use.
34
35
  It was a deliberate choice over the cheaper alternative — recognising known script SHA1s and
35
36
  dispatching to hand-written TypeScript — because recognition is a fake-success generator: it passes
36
37
  for the exact bytes it memorised and silently mis-answers anything one character different, and the
@@ -17,7 +17,7 @@ export async function door(ctx, request, report) {
17
17
  return Response.json({ error: 'to is required' }, { status: 400 });
18
18
  const deliveries = ctx.rowsRaw(DELIVERY).filter((d) => d.to === to)
19
19
  .sort((a, b) => String(a.at).localeCompare(String(b.at)) || Number(a.n) - Number(b.n))
20
- .map((d) => ({ messageId: d.message_id, retried: d.retried, at: d.at, method: d.method, headers: d.headers, body: d.body, status: d.status }));
20
+ .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 }));
21
21
  return Response.json({ to, deliveries });
22
22
  }
23
23
  if (request.method === 'POST' && path === '/_twin/destinations') {
@@ -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 } from '@volter/world-core';
5
+ import { bindSemantics, coreFor, worldNow, createDerivedFetch, crossCutting, semanticsContext } from '@volter/world-core';
6
6
  import surface from './generated/surface.gen.json' with { type: 'json' };
7
7
  import { manifest, spanning } from "./manifest.js";
8
8
  import { door, DOOR } from "./doors.js";
@@ -33,7 +33,7 @@ export function createQstashLaneFetch(options = {}) {
33
33
  if (!options.readOnly && !catchingUp.has(key)) {
34
34
  catchingUp.add(key);
35
35
  try {
36
- report = await catchUp(ctx);
36
+ report = await catchUp(ctx, () => Date.parse((options.clock ?? worldNow)()));
37
37
  }
38
38
  finally {
39
39
  catchingUp.delete(key);
@@ -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;
@@ -14,4 +14,4 @@ export type CatchUpReport = {
14
14
  failed: Row[];
15
15
  };
16
16
  /** Run everything due by the World's instant, in order, each at its own moment. */
17
- export declare function catchUp(ctx: SemanticsContext): Promise<CatchUpReport>;
17
+ export declare function catchUp(ctx: SemanticsContext, live?: () => number): Promise<CatchUpReport>;
@@ -119,7 +119,7 @@ function eligible(ctx, m, all) {
119
119
  }
120
120
  return true;
121
121
  }
122
- function nextDue(ctx, until) {
122
+ function nextDue(ctx, until, live) {
123
123
  const due = [];
124
124
  for (const s of ctx.rowsRaw('Schedule')) {
125
125
  if (typeof s.nextScheduleTime === 'number' && s.nextScheduleTime <= until)
@@ -138,7 +138,7 @@ function nextDue(ctx, until) {
138
138
  if (m.state === 'CANCEL_REQUESTED')
139
139
  due.push({ when, order: 2 + Number(m._ordinal) / 1e9, act: (at) => cancelled(at, m) });
140
140
  else if (eligible(ctx, m, all))
141
- due.push({ when, order: 3 + Number(m._ordinal) / 1e9, act: (at, report) => attempt(at, m, report) });
141
+ due.push({ when, order: 3 + Number(m._ordinal) / 1e9, act: (at, report) => attempt(at, m, report, live) });
142
142
  }
143
143
  for (const resource of ['DLQMessage', 'WorkflowDLQMessage']) {
144
144
  for (const e of ctx.rowsRaw(resource)) {
@@ -150,14 +150,14 @@ function nextDue(ctx, until) {
150
150
  return due.sort((a, b) => a.when - b.when || a.order - b.order)[0];
151
151
  }
152
152
  /** Run everything due by the World's instant, in order, each at its own moment. */
153
- export async function catchUp(ctx) {
153
+ export async function catchUp(ctx, live = () => nowMs(ctx)) {
154
154
  const until = nowMs(ctx);
155
155
  const report = { delivered: [], retried: [], failed: [] };
156
156
  // a message held behind another (the one before it in its queue or its run) goes once that one has left, so the World
157
157
  // clock never runs back: each move happens at its own due time or at the last move's, whichever is later
158
158
  let last = 0;
159
159
  for (let n = 0; n < 5000; n += 1) {
160
- const next = nextDue(ctx, until);
160
+ const next = nextDue(ctx, until, live);
161
161
  if (!next)
162
162
  break;
163
163
  last = Math.max(last, next.when);
@@ -198,9 +198,14 @@ async function move(ctx, m, to, actor, fields) {
198
198
  await ctx.write('Message', String(m.id), { ...fields, state: to }, `message.${to.toLowerCase()}`);
199
199
  m.state = to;
200
200
  }
201
- /** One delivery attempt of a message, at the moment it is due. */
202
- async function attempt(ctx, m, report) {
201
+ /** One delivery attempt of a message, at the moment it is due. Its signature is issued when it is sent — the World
202
+ * clock as the post leaves (`live`), never before the due time: QStash signs a request when it sends it, and a
203
+ * delivery the World makes late (its clock moved past the due time) reaches an application already living at that
204
+ * instant, which refuses a token that expired while it waited (dub walk 4: every workflow step after a clock shift
205
+ * answered "signature verification failed"). A long catch-up on a running clock signs each post at its own instant. */
206
+ async function attempt(ctx, m, report, live) {
203
207
  const when = nowMs(ctx);
208
+ const sentAt = Math.max(when, live());
204
209
  const tried = Number(m._attempts ?? 0);
205
210
  await move(ctx, m, 'ACTIVE', 'time', { _attempts: tried });
206
211
  const published = (m._published ?? {});
@@ -214,11 +219,11 @@ async function attempt(ctx, m, report) {
214
219
  const key = accountOf(ctx, String(m._owner ?? 'world')).current;
215
220
  const headers = {
216
221
  ...base, 'upstash-message-id': String(m.messageId), 'upstash-retried': String(tried),
217
- 'upstash-signature': sign(body, url, Math.floor(when / 1000), `jwt_${String(m.messageId)}_${tried}`, key),
222
+ 'upstash-signature': sign(body, url, Math.floor(sentAt / 1000), `jwt_${String(m.messageId)}_${tried}`, key),
218
223
  };
219
224
  const method = String(m.method ?? 'POST');
220
225
  const outcome = stated(ctx, url, headers) ?? await post(url, method, headers, body);
221
- 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}`);
226
+ 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}`);
222
227
  const n = tried + 1;
223
228
  const response = { _response_status: outcome.status, _response_body: outcome.body, _response_headers: outcome.headers };
224
229
  if (outcome.status >= 200 && outcome.status < 300) {
@@ -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) {
@@ -1,140 +1,8 @@
1
- /** A Lua runtime/parse error. Surfaces to the caller as a Redis script error, never as a result. */
2
- export declare class LuaError extends Error {
3
- constructor(message: string);
4
- }
5
- /** A Lua table. Array part + hash part, exactly like the real thing (1-based array indices). */
6
- export declare class LuaTable {
7
- readonly map: Map<string | number | boolean, LuaValue>;
8
- get(k: LuaValue): LuaValue;
9
- set(k: LuaValue, v: LuaValue): void;
10
- /** A non-nil value at a non-nil key. */
11
- private store;
12
- /** `#t` — the border of the array part: the largest n where t[1..n] are all non-nil. */
13
- get length(): number;
14
- static fromArray(values: LuaValue[]): LuaTable;
15
- toArray(): LuaValue[];
16
- }
17
- export type LuaFunction = (args: LuaValue[]) => LuaValue[];
18
- /** `null` stands for Lua `nil` (JS `undefined` is never used as a value in this interpreter). */
19
- export type LuaValue = null | boolean | number | string | LuaTable | LuaFunction;
20
- type TokKind = 'name' | 'number' | 'string' | 'op' | 'keyword' | 'eof';
21
- type Token = {
22
- kind: TokKind;
23
- value: string;
24
- line: number;
25
- };
26
- export declare function tokenize(src: string): Token[];
27
- type Expr = {
28
- t: 'nil';
29
- } | {
30
- t: 'true';
31
- } | {
32
- t: 'false';
33
- } | {
34
- t: 'num';
35
- v: number;
36
- } | {
37
- t: 'str';
38
- v: string;
39
- } | {
40
- t: 'name';
41
- v: string;
42
- } | {
43
- t: 'index';
44
- obj: Expr;
45
- key: Expr;
46
- } | {
47
- t: 'call';
48
- fn: Expr;
49
- args: Expr[];
50
- line?: number;
51
- } | {
52
- t: 'bin';
53
- op: string;
54
- l: Expr;
55
- r: Expr;
56
- } | {
57
- t: 'un';
58
- op: string;
59
- e: Expr;
60
- } | {
61
- t: 'table';
62
- array: Expr[];
63
- pairs: Array<{
64
- k: Expr;
65
- v: Expr;
66
- }>;
67
- } | {
68
- t: 'func';
69
- params: string[];
70
- body: Stat[];
71
- };
72
- type Stat = {
73
- t: 'local';
74
- names: string[];
75
- exprs: Expr[];
76
- } | {
77
- t: 'assign';
78
- targets: Expr[];
79
- exprs: Expr[];
80
- } | {
81
- t: 'callstat';
82
- call: Expr;
83
- } | {
84
- t: 'if';
85
- branches: Array<{
86
- cond: Expr;
87
- body: Stat[];
88
- }>;
89
- orelse: Stat[] | null;
90
- } | {
91
- t: 'while';
92
- cond: Expr;
93
- body: Stat[];
94
- } | {
95
- t: 'repeat';
96
- body: Stat[];
97
- cond: Expr;
98
- } | {
99
- t: 'fornum';
100
- name: string;
101
- from: Expr;
102
- to: Expr;
103
- step: Expr | null;
104
- body: Stat[];
105
- } | {
106
- t: 'forin';
107
- names: string[];
108
- exprs: Expr[];
109
- body: Stat[];
110
- } | {
111
- t: 'do';
112
- body: Stat[];
113
- } | {
114
- t: 'return';
115
- exprs: Expr[];
116
- } | {
117
- t: 'break';
118
- };
119
- /** Parse Lua source into a chunk. Exported so a test can prove a bad script fails at PARSE time. */
120
- export declare function parseLua(src: string): Stat[];
121
- export declare function luaTruthy(v: LuaValue): boolean;
122
- export declare function luaTypeName(v: LuaValue): string;
123
- /** Lua 5.1's `%.14g` number formatting — `3` prints as "3", not "3.0". */
124
- export declare function numberToLuaString(n: number): string;
125
- /** `tonumber`, exactly: a number passes through, a numeric string converts, anything else is nil. */
126
- export declare function luaToNumber(v: LuaValue, base?: number): LuaValue;
127
- /** What the host must supply: a synchronous `redis.call`, plus KEYS/ARGV. */
128
- export type LuaHost = {
129
- /** Run one Redis command, called from the script's line `line`. MUST throw to abort the script the way `redis.call` does. */
130
- call: (args: string[], line: number) => LuaValue;
131
- keys: string[];
132
- argv: string[];
133
- };
134
- /** Lua `==`: same type required (no string/number coercion); tables compare by identity. */
135
- export declare function luaEquals(l: LuaValue, r: LuaValue): boolean;
136
- /** Parse + run a script. Returns the Lua value the chunk returned (or `nil`). */
137
- export declare function runLua(script: string, host: LuaHost): LuaValue;
138
- /** The SHA1 real Redis keys the script cache by — the same hex digest `SCRIPT LOAD` returns. */
139
- export declare function luaScriptSha1(script: string): string;
140
- export {};
1
+ import { type LuaHost, type LuaValue } from '@volter/world-core/redis';
2
+ export { LuaError, LuaTable, luaEquals, luaScriptSha1, luaToNumber, luaTruthy, luaTypeName, numberToLuaString, parseLua, tokenize, } from '@volter/world-core/redis';
3
+ export type { LuaFunction, LuaHost, LuaValue } from '@volter/world-core/redis';
4
+ type Globals = Parameters<NonNullable<LuaHost['extend']>>[0];
5
+ /** The command API under the names Redis's scripts call it by. */
6
+ export declare function nameCommandApi(g: Globals): void;
7
+ /** Parse + run a script with the command API named: the kernel's `runLua`. */
8
+ export declare function runLua(script: string, host: Omit<LuaHost, 'numberArg'> & Partial<Pick<LuaHost, 'numberArg'>>): LuaValue;