@volter/twin-upstash 0.1.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.
- package/LICENSE +202 -0
- package/README.md +202 -0
- package/api/src/fetch.ts +54 -0
- package/api/src/generated/surface.gen.json +1 -0
- package/api/src/generated/ui.gen.json +1 -0
- package/api/src/index.ts +19 -0
- package/api/src/key-gate.ts +30 -0
- package/api/src/manifest.ts +103 -0
- package/api/src/screens/developer-api.tsx +106 -0
- package/api/src/screens/qstash.tsx +99 -0
- package/api/src/screens/session.tsx +125 -0
- package/api/src/screens/teams.tsx +114 -0
- package/api/src/semantics/backups.ts +90 -0
- package/api/src/semantics/index.ts +191 -0
- package/api/src/semantics/shared.ts +42 -0
- package/api/src/semantics/teams.ts +108 -0
- package/api/src/semantics/time.ts +40 -0
- package/dist/api/src/fetch.d.ts +15 -0
- package/dist/api/src/fetch.js +44 -0
- package/dist/api/src/fetch.ts +54 -0
- package/dist/api/src/generated/surface.gen.json +1 -0
- package/dist/api/src/generated/ui.gen.json +1 -0
- package/dist/api/src/index.ts +19 -0
- package/dist/api/src/key-gate.d.ts +3 -0
- package/dist/api/src/key-gate.js +30 -0
- package/dist/api/src/key-gate.ts +30 -0
- package/dist/api/src/manifest.d.ts +2 -0
- package/dist/api/src/manifest.js +81 -0
- package/dist/api/src/manifest.ts +103 -0
- package/dist/api/src/screens/developer-api.d.ts +3 -0
- package/dist/api/src/screens/developer-api.js +101 -0
- package/dist/api/src/screens/developer-api.tsx +106 -0
- package/dist/api/src/screens/qstash.d.ts +3 -0
- package/dist/api/src/screens/qstash.js +92 -0
- package/dist/api/src/screens/qstash.tsx +99 -0
- package/dist/api/src/screens/session.d.ts +9 -0
- package/dist/api/src/screens/session.js +118 -0
- package/dist/api/src/screens/session.tsx +125 -0
- package/dist/api/src/screens/teams.d.ts +3 -0
- package/dist/api/src/screens/teams.js +99 -0
- package/dist/api/src/screens/teams.tsx +114 -0
- package/dist/api/src/semantics/backups.d.ts +7 -0
- package/dist/api/src/semantics/backups.js +75 -0
- package/dist/api/src/semantics/backups.ts +90 -0
- package/dist/api/src/semantics/index.d.ts +10 -0
- package/dist/api/src/semantics/index.js +191 -0
- package/dist/api/src/semantics/index.ts +191 -0
- package/dist/api/src/semantics/shared.d.ts +21 -0
- package/dist/api/src/semantics/shared.js +34 -0
- package/dist/api/src/semantics/shared.ts +42 -0
- package/dist/api/src/semantics/teams.d.ts +13 -0
- package/dist/api/src/semantics/teams.js +100 -0
- package/dist/api/src/semantics/teams.ts +108 -0
- package/dist/api/src/semantics/time.d.ts +2 -0
- package/dist/api/src/semantics/time.js +34 -0
- package/dist/api/src/semantics/time.ts +40 -0
- package/dist/qstash/src/doors.d.ts +6 -0
- package/dist/qstash/src/doors.js +33 -0
- package/dist/qstash/src/doors.ts +51 -0
- package/dist/qstash/src/egress.d.ts +7 -0
- package/dist/qstash/src/egress.js +66 -0
- package/dist/qstash/src/egress.ts +58 -0
- package/dist/qstash/src/fetch.d.ts +7 -0
- package/dist/qstash/src/fetch.js +48 -0
- package/dist/qstash/src/fetch.ts +46 -0
- package/dist/qstash/src/generated/surface.gen.json +1 -0
- package/dist/qstash/src/generated/ui.gen.json +1 -0
- package/dist/qstash/src/index.ts +35 -0
- package/dist/qstash/src/manifest.d.ts +10 -0
- package/dist/qstash/src/manifest.js +105 -0
- package/dist/qstash/src/manifest.ts +134 -0
- package/dist/qstash/src/semantics/account.d.ts +29 -0
- package/dist/qstash/src/semantics/account.js +91 -0
- package/dist/qstash/src/semantics/account.ts +98 -0
- package/dist/qstash/src/semantics/delivery.d.ts +17 -0
- package/dist/qstash/src/semantics/delivery.js +274 -0
- package/dist/qstash/src/semantics/delivery.ts +264 -0
- package/dist/qstash/src/semantics/dlq.d.ts +4 -0
- package/dist/qstash/src/semantics/dlq.js +51 -0
- package/dist/qstash/src/semantics/dlq.ts +61 -0
- package/dist/qstash/src/semantics/index.d.ts +2 -0
- package/dist/qstash/src/semantics/index.js +10 -0
- package/dist/qstash/src/semantics/index.ts +13 -0
- package/dist/qstash/src/semantics/keys.d.ts +2 -0
- package/dist/qstash/src/semantics/keys.js +9 -0
- package/dist/qstash/src/semantics/keys.ts +14 -0
- package/dist/qstash/src/semantics/messages.d.ts +74 -0
- package/dist/qstash/src/semantics/messages.js +233 -0
- package/dist/qstash/src/semantics/messages.ts +249 -0
- package/dist/qstash/src/semantics/queues.d.ts +2 -0
- package/dist/qstash/src/semantics/queues.js +60 -0
- package/dist/qstash/src/semantics/queues.ts +66 -0
- package/dist/qstash/src/semantics/schedules.d.ts +19 -0
- package/dist/qstash/src/semantics/schedules.js +125 -0
- package/dist/qstash/src/semantics/schedules.ts +132 -0
- package/dist/qstash/src/semantics/shared.d.ts +45 -0
- package/dist/qstash/src/semantics/shared.js +115 -0
- package/dist/qstash/src/semantics/shared.ts +121 -0
- package/dist/qstash/src/semantics/urlgroups.d.ts +2 -0
- package/dist/qstash/src/semantics/urlgroups.js +58 -0
- package/dist/qstash/src/semantics/urlgroups.ts +69 -0
- package/dist/qstash/src/semantics/workflows.d.ts +44 -0
- package/dist/qstash/src/semantics/workflows.js +379 -0
- package/dist/qstash/src/semantics/workflows.ts +401 -0
- package/dist/qstash/src/signing.d.ts +4 -0
- package/dist/qstash/src/signing.js +16 -0
- package/dist/qstash/src/signing.ts +19 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +35 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.js +124 -0
- package/dist/src/manifest.d.ts +14 -0
- package/dist/src/manifest.js +8 -0
- package/dist/src/upstash-budget.d.ts +85 -0
- package/dist/src/upstash-budget.js +440 -0
- package/dist/src/upstash-capabilities.d.ts +4 -0
- package/dist/src/upstash-capabilities.js +1286 -0
- package/dist/src/upstash-conformance.d.ts +7 -0
- package/dist/src/upstash-conformance.js +119 -0
- package/dist/src/upstash-connector.d.ts +115 -0
- package/dist/src/upstash-connector.js +309 -0
- package/dist/src/upstash-lua.d.ts +140 -0
- package/dist/src/upstash-lua.js +1229 -0
- package/dist/src/upstash-server.d.ts +29 -0
- package/dist/src/upstash-server.js +81 -0
- package/dist/src/upstash-store.d.ts +114 -0
- package/dist/src/upstash-store.js +1663 -0
- package/dist/src/upstash-twin.d.ts +73 -0
- package/dist/src/upstash-twin.js +437 -0
- package/package.json +59 -0
- package/qstash/src/doors.ts +51 -0
- package/qstash/src/egress.ts +58 -0
- package/qstash/src/fetch.ts +46 -0
- package/qstash/src/generated/surface.gen.json +1 -0
- package/qstash/src/generated/ui.gen.json +1 -0
- package/qstash/src/index.ts +35 -0
- package/qstash/src/manifest.ts +134 -0
- package/qstash/src/semantics/account.ts +98 -0
- package/qstash/src/semantics/delivery.ts +264 -0
- package/qstash/src/semantics/dlq.ts +61 -0
- package/qstash/src/semantics/index.ts +13 -0
- package/qstash/src/semantics/keys.ts +14 -0
- package/qstash/src/semantics/messages.ts +249 -0
- package/qstash/src/semantics/queues.ts +66 -0
- package/qstash/src/semantics/schedules.ts +132 -0
- package/qstash/src/semantics/shared.ts +121 -0
- package/qstash/src/semantics/urlgroups.ts +69 -0
- package/qstash/src/semantics/workflows.ts +401 -0
- package/qstash/src/signing.ts +19 -0
- package/src/cli.ts +36 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/index.ts +203 -0
- package/src/manifest.ts +26 -0
- package/src/upstash-budget.ts +486 -0
- package/src/upstash-capabilities.ts +1418 -0
- package/src/upstash-conformance.ts +131 -0
- package/src/upstash-connector.ts +340 -0
- package/src/upstash-lua.ts +1120 -0
- package/src/upstash-server.ts +103 -0
- package/src/upstash-store.ts +1437 -0
- package/src/upstash-twin.ts +465 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// upstash conformance (dev-only; lazy-imported by the CLI, NEVER from index.ts/runtime — E2).
|
|
2
|
+
//
|
|
3
|
+
// This is NOT the static self-referential snapshot check most packs ship. §9 refuted that pattern
|
|
4
|
+
// elsewhere in this repo on the grounds that comparing one constant to another cannot detect a dead
|
|
5
|
+
// handler — so this check ISSUES REAL REQUESTS (the azureformrecognizer `conformance.endpoint_probe`
|
|
6
|
+
// pattern): it drives every endpoint the snapshot claims through `handleUpstashRedisTwinRequest`
|
|
7
|
+
// against a throwaway root and requires each to be genuinely routed and to answer with the right
|
|
8
|
+
// envelope. A twin whose handler returned `{}` fails this, which is the whole point.
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { handleUpstashRedisTwinRequest, upstashTwinSnapshot, UPSTASH_RESOURCE_TYPES } from './upstash-twin.ts';
|
|
13
|
+
|
|
14
|
+
export type UpstashRedisConformanceReport = {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
endpointsChecked: number;
|
|
17
|
+
resourceTypesChecked: number;
|
|
18
|
+
violations: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const AT = '2026-01-01T00:00:00.000Z';
|
|
22
|
+
|
|
23
|
+
/** One probe per claimed endpoint: the request to make, and what a LIVE handler must answer. */
|
|
24
|
+
type Probe = { label: string; method: string; path: string; body?: string; expect: (r: { status: number; body: unknown }) => boolean };
|
|
25
|
+
|
|
26
|
+
export async function checkUpstashRedisConformance(): Promise<UpstashRedisConformanceReport> {
|
|
27
|
+
const snapshot = upstashTwinSnapshot();
|
|
28
|
+
const violations: string[] = [];
|
|
29
|
+
const root = mkdtempSync(join(tmpdir(), 'upstash-conf-'));
|
|
30
|
+
const auth = { authorization: 'Bearer twin-conformance-token' };
|
|
31
|
+
const result = (r: { body: unknown }) => (r.body as { result?: unknown }).result;
|
|
32
|
+
|
|
33
|
+
const probes: Probe[] = [
|
|
34
|
+
{
|
|
35
|
+
label: 'POST / (one command as a JSON array body)',
|
|
36
|
+
method: 'POST', path: '/', body: JSON.stringify(['SET', 'conf:a', 'one']),
|
|
37
|
+
expect: (r) => r.status === 200 && result(r) === 'OK',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: 'GET /{COMMAND}/{args...} (path-style, percent-decoded)',
|
|
41
|
+
method: 'GET', path: '/get/conf:a',
|
|
42
|
+
expect: (r) => r.status === 200 && result(r) === 'one',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: 'POST /{COMMAND}/{args...} (path-style; the raw body becomes the last argument)',
|
|
46
|
+
method: 'POST', path: '/set/conf:b?EX=100', body: 'two',
|
|
47
|
+
expect: (r) => r.status === 200 && result(r) === 'OK',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
label: 'POST /pipeline (array of command arrays, non-atomic, per-item {result}/{error})',
|
|
51
|
+
method: 'POST', path: '/pipeline', body: JSON.stringify([['GET', 'conf:b'], ['TTL', 'conf:b']]),
|
|
52
|
+
expect: (r) => {
|
|
53
|
+
const items = r.body as Array<{ result?: unknown }>;
|
|
54
|
+
return r.status === 200 && Array.isArray(items) && items.length === 2 && items[0]!.result === 'two' && items[1]!.result === 100;
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
label: 'POST /multi-exec (array of command arrays, transaction, queue-time EXECABORT)',
|
|
59
|
+
method: 'POST', path: '/multi-exec', body: JSON.stringify([['INCR', 'conf:n'], ['INCR', 'conf:n']]),
|
|
60
|
+
expect: (r) => {
|
|
61
|
+
const items = r.body as Array<{ result?: unknown }>;
|
|
62
|
+
return r.status === 200 && Array.isArray(items) && items[0]!.result === 1 && items[1]!.result === 2;
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
label: 'OPTIONS / (CORS preflight)',
|
|
67
|
+
method: 'OPTIONS', path: '/',
|
|
68
|
+
expect: (r) => r.status === 200,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
for (const probe of probes) {
|
|
74
|
+
if (!snapshot.implementedEndpoints.includes(probe.label)) {
|
|
75
|
+
violations.push(`probe '${probe.label}' has no matching entry in implementedEndpoints`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const response = await handleUpstashRedisTwinRequest({
|
|
79
|
+
method: probe.method,
|
|
80
|
+
path: probe.path,
|
|
81
|
+
headers: auth,
|
|
82
|
+
root,
|
|
83
|
+
occurredAt: AT,
|
|
84
|
+
...(probe.body !== undefined ? { body: probe.body } : {}),
|
|
85
|
+
});
|
|
86
|
+
if (!probe.expect(response)) {
|
|
87
|
+
violations.push(`endpoint '${probe.label}' did not answer as declared (status ${response.status}, body ${JSON.stringify(response.body)})`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Every claimed endpoint must have a probe — otherwise the inventory could grow entries nothing
|
|
91
|
+
// exercises, which is exactly the drift a self-referential snapshot cannot see.
|
|
92
|
+
for (const endpoint of snapshot.implementedEndpoints) {
|
|
93
|
+
if (!probes.some((p) => p.label === endpoint)) violations.push(`declared endpoint '${endpoint}' has no conformance probe`);
|
|
94
|
+
}
|
|
95
|
+
// The inventory must match what the handler WRITES, in BOTH directions.
|
|
96
|
+
//
|
|
97
|
+
// §9 round 2: the first version only rejected an EXTRA bogus type, so reverting the inventory
|
|
98
|
+
// to the round-one bug (`['key']` alone, omitting 'script') left this loop vacuous and the
|
|
99
|
+
// check still returned ok — it could not fail in the direction of the very drift its comment
|
|
100
|
+
// claimed to catch. Both directions are now asserted, so a missing type is a violation too.
|
|
101
|
+
const written = ['key', 'script'];
|
|
102
|
+
for (const type of UPSTASH_RESOURCE_TYPES) {
|
|
103
|
+
if (!written.includes(type)) violations.push(`declared resource type '${type}' is not written by any handler path`);
|
|
104
|
+
}
|
|
105
|
+
for (const type of written) {
|
|
106
|
+
if (!(UPSTASH_RESOURCE_TYPES as readonly string[]).includes(type)) {
|
|
107
|
+
violations.push(`the handler writes subject type '${type}' but UPSTASH_RESOURCE_TYPES does not declare it`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// …and 'script' must be genuinely reachable, not merely declared.
|
|
111
|
+
const loaded = await handleUpstashRedisTwinRequest({ method: 'POST', path: '/', headers: auth, root, occurredAt: AT, body: JSON.stringify(['SCRIPT', 'LOAD', 'return 1']) });
|
|
112
|
+
const sha = (loaded.body as { result?: unknown }).result;
|
|
113
|
+
const ran = await handleUpstashRedisTwinRequest({ method: 'POST', path: '/', headers: auth, root, occurredAt: AT, body: JSON.stringify(['EVALSHA', String(sha), '0']) });
|
|
114
|
+
if (typeof sha !== 'string' || (ran.body as { result?: unknown }).result !== 1) {
|
|
115
|
+
violations.push(`resource type 'script' is declared but the script cache did not round-trip (load=${JSON.stringify(loaded.body)} run=${JSON.stringify(ran.body)})`);
|
|
116
|
+
}
|
|
117
|
+
const alive = await handleUpstashRedisTwinRequest({ method: 'POST', path: '/', headers: auth, root, occurredAt: AT, body: JSON.stringify(['DBSIZE']) });
|
|
118
|
+
if (typeof (alive.body as { result?: unknown }).result !== 'number' || (alive.body as { result: number }).result < 3) {
|
|
119
|
+
violations.push(`the 'key' resource type is declared but the probes' writes did not project into the keyspace (DBSIZE=${JSON.stringify(alive.body)})`);
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
rmSync(root, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
ok: violations.length === 0,
|
|
127
|
+
endpointsChecked: snapshot.implementedEndpoints.length,
|
|
128
|
+
resourceTypesChecked: snapshot.resourceTypes.length,
|
|
129
|
+
violations,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// upstash CONNECTOR — the live-vendor pull path that gives this twin the "git for SaaS"
|
|
2
|
+
// lifecycle over an INJECTED client (the auth boundary). The pack imports NO SDK and holds NO
|
|
3
|
+
// token; a consumer injects something structurally satisfying `UpstashRedisLikeClient` (a real
|
|
4
|
+
// `@upstash/redis` client is assignable as-is, since its `get`/`type`/`ttl`/... methods have
|
|
5
|
+
// exactly these shapes).
|
|
6
|
+
//
|
|
7
|
+
// ── PULL IS HANDLE-DRIVEN, AND THAT IS A DELIBERATE SAFETY PROPERTY, NOT A GAP ─────────────────
|
|
8
|
+
// Redis has no cheap "give me everything" endpoint. `KEYS *` is O(N) and Upstash's own docs warn
|
|
9
|
+
// against it on a production database; `SCAN` is the supported walk but is unbounded in cost. So
|
|
10
|
+
// this connector pulls the CURRENT real value of keys the caller NAMES, and `syncUpstashRedisFromReal`
|
|
11
|
+
// folds them through one observation (the kernel dedupes, so a re-pull of identical state appends
|
|
12
|
+
// ZERO deltas). `pullUpstashRedisScan` exists for callers who genuinely want a walk, and it takes a
|
|
13
|
+
// hard `limit` for the same reason.
|
|
14
|
+
//
|
|
15
|
+
// ── THE RATE BUDGET IS NOT OPTIONAL HERE ──────────────────────────────────────────────────────
|
|
16
|
+
// Upstash BILLS AND LIMITS PER COMMAND, and publishes the number (10,000 commands/sec — see
|
|
17
|
+
// upstash-budget.ts). A key-by-key pull is exactly the shape that turns one careless loop into
|
|
18
|
+
// tens of thousands of billable commands, so every entrypoint below GUARDS the injected client
|
|
19
|
+
// before touching it (`guardUpstashRedisClient`, which is idempotent). There is deliberately no
|
|
20
|
+
// option that turns the guard off.
|
|
21
|
+
import { observeResources } from '@volter/world-core';
|
|
22
|
+
import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
|
|
23
|
+
import { upstashBudgetOf, guardUpstashRedisClient, type UpstashRedisBudgetedOptions } from './upstash-budget.ts';
|
|
24
|
+
|
|
25
|
+
const SERVICE = 'upstash';
|
|
26
|
+
|
|
27
|
+
export type { UpstashRedisBudgetedOptions };
|
|
28
|
+
|
|
29
|
+
/** A key the caller wants pulled. `type` may be supplied when already known, to save a round trip. */
|
|
30
|
+
export type UpstashRedisKeyHandle = { key: string; type?: string };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The subset of a real Redis client this connector calls. A `@upstash/redis` `Redis` instance
|
|
34
|
+
* satisfies it structurally. Every member is OPTIONAL: a client that cannot do one of these makes
|
|
35
|
+
* the corresponding pull observe nothing, which is the honest outcome — inventing a value would
|
|
36
|
+
* turn "cannot see" into "saw an empty key", and a subsequent push would then delete real data.
|
|
37
|
+
*/
|
|
38
|
+
export interface UpstashRedisLikeClient {
|
|
39
|
+
get?: (key: string) => Promise<unknown>;
|
|
40
|
+
type?: (key: string) => Promise<string>;
|
|
41
|
+
ttl?: (key: string) => Promise<number>;
|
|
42
|
+
hgetall?: (key: string) => Promise<Record<string, unknown> | null>;
|
|
43
|
+
smembers?: (key: string) => Promise<string[]>;
|
|
44
|
+
lrange?: (key: string, start: number, stop: number) => Promise<string[]>;
|
|
45
|
+
zrange?: (key: string, start: number, stop: number, opts?: { withScores?: boolean }) => Promise<unknown[]>;
|
|
46
|
+
scan?: (cursor: number, opts?: { match?: string; count?: number }) => Promise<[string, string[]]>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The twin-side shape of one pulled key, before it becomes a kernel resource. */
|
|
50
|
+
export type UpstashRedisRealKey = {
|
|
51
|
+
key: string;
|
|
52
|
+
type: string;
|
|
53
|
+
/** Already in this twin's storage encoding: string | string[] | [f,v][] | [member,score][]. */
|
|
54
|
+
value: unknown;
|
|
55
|
+
/** Seconds remaining, Redis-style: -1 = no TTL, -2 = missing. */
|
|
56
|
+
ttl?: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function kid(id: string): string {
|
|
60
|
+
return `key:${id}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Pure mapper (a real key → a kernel `SyncResource`). Never touches a client, so the mutation
|
|
65
|
+
* harness's connector-seam sweep (which sabotages exports matching sync/push/pull/fullSync) leaves
|
|
66
|
+
* it real — the pack convention, mirroring qstash's `mapMessage`.
|
|
67
|
+
*
|
|
68
|
+
* The field names deliberately avoid the kernel's reserved META keys (`type`/`id`/`updatedAt`,
|
|
69
|
+
* which `projectResources` SILENTLY DROPS): the Redis type is stored as `kind`, and the key name
|
|
70
|
+
* as `name`. This is the same layout `upstash-store.ts` writes, so a pulled key and a locally
|
|
71
|
+
* written one are indistinguishable to every read path.
|
|
72
|
+
*
|
|
73
|
+
* `occurredAtMs` is required rather than defaulted: a TTL in SECONDS has to be turned into an
|
|
74
|
+
* absolute deadline against SOME instant, and silently choosing `Date.now()` here would make a
|
|
75
|
+
* pulled key's expiry unreproducible.
|
|
76
|
+
*/
|
|
77
|
+
export function mapKey(real: UpstashRedisRealKey, occurredAtMs: number): SyncResource {
|
|
78
|
+
const ttl = real.ttl;
|
|
79
|
+
const pexpireAt = ttl !== undefined && ttl > 0 ? occurredAtMs + ttl * 1000 : null;
|
|
80
|
+
return {
|
|
81
|
+
type: 'key',
|
|
82
|
+
id: kid(real.key),
|
|
83
|
+
// Gotcha (kernel field MERGE, never a deep merge or an omission-clear): every field a later
|
|
84
|
+
// local write could overwrite is written explicitly here, so a pulled key can never inherit a
|
|
85
|
+
// stale `last_id` or a dead `gone` flag from a previous incarnation of the same subject.
|
|
86
|
+
fields: {
|
|
87
|
+
name: real.key,
|
|
88
|
+
kind: real.type,
|
|
89
|
+
v: real.value as never,
|
|
90
|
+
pexpire_at: pexpireAt,
|
|
91
|
+
last_id: '',
|
|
92
|
+
gone: false,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Normalize whatever the injected client returned into this twin's storage encoding. */
|
|
98
|
+
function encodeValue(type: string, raw: unknown): unknown {
|
|
99
|
+
if (type === 'hash') {
|
|
100
|
+
const obj = (raw ?? {}) as Record<string, unknown>;
|
|
101
|
+
return Object.entries(obj).map(([f, v]) => [f, typeof v === 'string' ? v : JSON.stringify(v)]);
|
|
102
|
+
}
|
|
103
|
+
if (type === 'zset') {
|
|
104
|
+
// `zrange(..., {withScores:true})` returns a flat [member, score, member, score, …] array.
|
|
105
|
+
const flat = (raw ?? []) as unknown[];
|
|
106
|
+
// Scores are stored as STRINGS, matching upstash-store.ts's StoredZSet: a JSON number
|
|
107
|
+
// cannot represent ±inf, and a pulled +inf score would otherwise persist as null.
|
|
108
|
+
const out: Array<[string, string]> = [];
|
|
109
|
+
for (let i = 0; i + 1 < flat.length; i += 2) out.push([String(flat[i]), String(flat[i + 1])]);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
if (type === 'set' || type === 'list') return ((raw ?? []) as unknown[]).map((v) => (typeof v === 'string' ? v : JSON.stringify(v)));
|
|
113
|
+
if (type === 'string') return typeof raw === 'string' ? raw : JSON.stringify(raw ?? null);
|
|
114
|
+
// A type this connector cannot faithfully encode is REFUSED BY NAME, never coerced.
|
|
115
|
+
//
|
|
116
|
+
// §9 round 2 found the old `return JSON.stringify(...)` fallback storing a pulled STREAM as a
|
|
117
|
+
// JSON string while still labelling it `kind:'stream'` — after which every stream read threw a
|
|
118
|
+
// raw TypeError out of the request handler. Silently mis-encoding a type is worse than not
|
|
119
|
+
// pulling it: the twin then holds state that looks real and cannot be read.
|
|
120
|
+
throw new Error(`upstash connector: cannot pull key of type '${type}' — this connector encodes string/hash/set/list/zset only (upstash.connector.pull_streams is the filed todo for streams)`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read the CURRENT real state of every NAMED key. A key the vendor does not have is SKIPPED — never
|
|
125
|
+
* emitted as an empty one, which would let a later push wipe it.
|
|
126
|
+
*/
|
|
127
|
+
export async function pullUpstashRedisKeys(
|
|
128
|
+
rawClient: UpstashRedisLikeClient,
|
|
129
|
+
handles: UpstashRedisKeyHandle[],
|
|
130
|
+
opts: { occurredAt?: string } & UpstashRedisBudgetedOptions = {},
|
|
131
|
+
): Promise<SyncResource[]> {
|
|
132
|
+
const client = guardUpstashRedisClient(rawClient, upstashBudgetOf(opts));
|
|
133
|
+
if (handles.length === 0) return [];
|
|
134
|
+
const at = Date.parse(opts.occurredAt ?? new Date().toISOString());
|
|
135
|
+
const out: SyncResource[] = [];
|
|
136
|
+
for (const handle of handles) {
|
|
137
|
+
const type = handle.type ?? (client.type ? await client.type(handle.key) : 'string');
|
|
138
|
+
if (type === 'none') continue; // the key does not exist on the vendor
|
|
139
|
+
let value: unknown;
|
|
140
|
+
if (type === 'hash') value = client.hgetall ? await client.hgetall(handle.key) : null;
|
|
141
|
+
else if (type === 'set') value = client.smembers ? await client.smembers(handle.key) : [];
|
|
142
|
+
else if (type === 'list') value = client.lrange ? await client.lrange(handle.key, 0, -1) : [];
|
|
143
|
+
else if (type === 'zset') value = client.zrange ? await client.zrange(handle.key, 0, -1, { withScores: true }) : [];
|
|
144
|
+
else value = client.get ? await client.get(handle.key) : null;
|
|
145
|
+
if (value === null || value === undefined) continue;
|
|
146
|
+
const ttl = client.ttl ? await client.ttl(handle.key) : -1;
|
|
147
|
+
out.push(mapKey({ key: handle.key, type, value: encodeValue(type, value), ttl }, at));
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Walk the real keyspace with `SCAN`, bounded by `limit`.
|
|
154
|
+
*
|
|
155
|
+
* The bound is the point: an unbounded walk of a production Redis is precisely the burst the rate
|
|
156
|
+
* budget exists to stop, and a connector that offered one would be inviting the caller to spend a
|
|
157
|
+
* whole allowance in a loop. Returns the discovered handles; feed them to `pullUpstashRedisKeys`.
|
|
158
|
+
*/
|
|
159
|
+
export async function pullUpstashRedisScan(
|
|
160
|
+
rawClient: UpstashRedisLikeClient,
|
|
161
|
+
opts: { match?: string; limit?: number; pageSize?: number } & UpstashRedisBudgetedOptions = {},
|
|
162
|
+
): Promise<UpstashRedisKeyHandle[]> {
|
|
163
|
+
const client = guardUpstashRedisClient(rawClient, upstashBudgetOf(opts));
|
|
164
|
+
if (!client.scan) return [];
|
|
165
|
+
const limit = opts.limit ?? 100;
|
|
166
|
+
const pageSize = opts.pageSize ?? 50;
|
|
167
|
+
const found: string[] = [];
|
|
168
|
+
let cursor = 0;
|
|
169
|
+
// A bounded loop, not a `while (cursor !== 0)`: a vendor that never returns cursor 0 must not be
|
|
170
|
+
// able to spin this forever, and the budget must not be the only thing that stops it.
|
|
171
|
+
for (let page = 0; page < 100 && found.length < limit; page++) {
|
|
172
|
+
const [next, keys] = await client.scan(cursor, { count: pageSize, ...(opts.match !== undefined ? { match: opts.match } : {}) });
|
|
173
|
+
found.push(...keys);
|
|
174
|
+
cursor = Number(next);
|
|
175
|
+
if (cursor === 0) break;
|
|
176
|
+
}
|
|
177
|
+
return found.slice(0, limit).map((key) => ({ key }));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* D7 entry point: pull the current real state of every named key and fold it into the twin via ONE
|
|
182
|
+
* one observation. Returns `{observed, deltasAppended}` — a re-pull of identical
|
|
183
|
+
* state appends ZERO deltas, which is what makes the pull idempotent.
|
|
184
|
+
*/
|
|
185
|
+
export async function syncUpstashRedisFromReal(
|
|
186
|
+
rawClient: UpstashRedisLikeClient,
|
|
187
|
+
opts: { root?: string; occurredAt?: string; keys?: UpstashRedisKeyHandle[] } & UpstashRedisBudgetedOptions = {},
|
|
188
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
189
|
+
// Guard ONCE here and hand the guarded client down: the per-handle loop is unbounded in handle
|
|
190
|
+
// count, so this is the entrypoint that must be unable to run unbudgeted.
|
|
191
|
+
const client = guardUpstashRedisClient(rawClient, upstashBudgetOf(opts));
|
|
192
|
+
const occurredAt = opts.occurredAt ?? new Date().toISOString();
|
|
193
|
+
const resources = await pullUpstashRedisKeys(client, opts.keys ?? [], { occurredAt });
|
|
194
|
+
const result = ((__at) => observeResources(SERVICE, resources, { ...(opts.root !== undefined ? { root: opts.root } : {}), at: __at, batch: `obs:${SERVICE}:${__at}` }))(occurredAt);
|
|
195
|
+
return { observed: resources.length, deltasAppended: result.appended };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* An `UpstashRedisLikeClient` over the kernel's executor. At a REAL boundary the kernel sets the
|
|
202
|
+
* sealed credential over these headers (executor.ts); at the twin's own wire any credential is one.
|
|
203
|
+
*
|
|
204
|
+
* Every call is `POST /` with the command as a JSON array — the REST protocol's canonical form, and
|
|
205
|
+
* what `@upstash/redis` sends. The reply is `{result: …}` and the SDK hands the caller the INNER
|
|
206
|
+
* value, so this does too: returning the envelope would present a shape the parsers below have
|
|
207
|
+
* never seen, and they would find nothing rather than fail. (The sibling upstashvector pack lost a
|
|
208
|
+
* whole refresh to exactly that, 2026-09-08.)
|
|
209
|
+
*/
|
|
210
|
+
export function upstashRedisClientOver(execute: RemoteExecute): UpstashRedisLikeClient {
|
|
211
|
+
const command = async (...args: unknown[]): Promise<any> => {
|
|
212
|
+
const res = await execute({
|
|
213
|
+
method: 'POST', path: '/',
|
|
214
|
+
headers: { accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer twin' },
|
|
215
|
+
body: JSON.stringify(args.map((a) => (typeof a === 'string' ? a : String(a)))),
|
|
216
|
+
});
|
|
217
|
+
let parsed: any = {};
|
|
218
|
+
try { parsed = JSON.parse(res.body || '{}'); } catch { parsed = {}; }
|
|
219
|
+
if (res.status < 200 || res.status >= 300 || parsed?.error !== undefined) {
|
|
220
|
+
throw new Error(`upstash redis ${String(args[0])} refused: HTTP ${res.status} ${JSON.stringify(parsed).slice(0, 200)}`);
|
|
221
|
+
}
|
|
222
|
+
return parsed?.result !== undefined ? parsed.result : parsed;
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
get: (key) => command('GET', key),
|
|
226
|
+
type: (key) => command('TYPE', key),
|
|
227
|
+
ttl: (key) => command('TTL', key),
|
|
228
|
+
hgetall: async (key) => {
|
|
229
|
+
// HGETALL over REST answers a FLAT array (field, value, field, value…), which is what the
|
|
230
|
+
// wire says; the SDK object-ifies it and this connector reads an object.
|
|
231
|
+
const flat = await command('HGETALL', key);
|
|
232
|
+
if (!Array.isArray(flat)) return (flat ?? null) as Record<string, unknown> | null;
|
|
233
|
+
const out: Record<string, unknown> = {};
|
|
234
|
+
for (let i = 0; i + 1 < flat.length; i += 2) out[String(flat[i])] = flat[i + 1];
|
|
235
|
+
return out;
|
|
236
|
+
},
|
|
237
|
+
smembers: async (key) => ((await command('SMEMBERS', key)) ?? []).map((m: unknown) => String(m)),
|
|
238
|
+
lrange: async (key, start, stop) => ((await command('LRANGE', key, start, stop)) ?? []).map((m: unknown) => String(m)),
|
|
239
|
+
zrange: async (key, start, stop, opts) => (await command('ZRANGE', key, start, stop, ...(opts?.withScores ? ['WITHSCORES'] : []))) ?? [],
|
|
240
|
+
scan: async (cursor, opts) => {
|
|
241
|
+
const args: unknown[] = ['SCAN', cursor];
|
|
242
|
+
if (opts?.match !== undefined) args.push('MATCH', opts.match);
|
|
243
|
+
if (opts?.count !== undefined) args.push('COUNT', opts.count);
|
|
244
|
+
const answered = await command(...args);
|
|
245
|
+
const next = Array.isArray(answered) ? String(answered[0] ?? '0') : '0';
|
|
246
|
+
const keys = Array.isArray(answered) && Array.isArray(answered[1]) ? answered[1].map((k: unknown) => String(k)) : [];
|
|
247
|
+
return [next, keys];
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The refresh adapter: Redis enumerates (`SCAN` walks the keyspace), so the whole database comes
|
|
253
|
+
* back without the world having to say which keys it holds. */
|
|
254
|
+
export async function syncUpstashRedisFromRemote(
|
|
255
|
+
execute: RemoteExecute,
|
|
256
|
+
opts: { root?: string; origin?: string; occurredAt?: string; limit?: number; match?: string } & UpstashRedisBudgetedOptions = {},
|
|
257
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
258
|
+
// Guarded here as well as inside the sync: the guard is idempotent, and stating it at every
|
|
259
|
+
// entrypoint is what this pack's source tooth holds the connector to.
|
|
260
|
+
const client = guardUpstashRedisClient(upstashRedisClientOver(execute), upstashBudgetOf(opts));
|
|
261
|
+
// `syncUpstashRedisFromReal` takes the keys as an argument, which is right for a caller who knows
|
|
262
|
+
// what it wants and wrong for "refresh this world", which means the database. Redis has an
|
|
263
|
+
// enumeration, so this uses it: SCAN discovers the keyspace (bounded — an unbounded walk of a
|
|
264
|
+
// production Redis is the burst the rate budget exists to stop) and the handles it finds are what
|
|
265
|
+
// gets pulled. Without this the refresh named no key and reported an empty database.
|
|
266
|
+
const keys = await pullUpstashRedisScan(client, {
|
|
267
|
+
...(opts.limit !== undefined ? { limit: opts.limit } : {}),
|
|
268
|
+
...(opts.match !== undefined ? { match: opts.match } : {}),
|
|
269
|
+
});
|
|
270
|
+
return syncUpstashRedisFromReal(client, {
|
|
271
|
+
keys,
|
|
272
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
273
|
+
occurredAt: opts.occurredAt ?? new Date().toISOString(),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The perform adapter. A key a world wrote crosses as the command that would recreate it, typed by
|
|
279
|
+
* what it is: a string is a SET (with its TTL when it has one), a hash an HSET, a set an SADD, a
|
|
280
|
+
* list an RPUSH, a sorted set a ZADD. A delete is a DEL. Anything else this world recorded about
|
|
281
|
+
* the database is a read at Upstash and has nothing to write.
|
|
282
|
+
*/
|
|
283
|
+
export async function performUpstashRedisAction(execute: RemoteExecute, action: TwinAction, _ctx: PerformContext): Promise<PushOutcome> {
|
|
284
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
285
|
+
const fields = (action.fields ?? {}) as Record<string, any>;
|
|
286
|
+
|
|
287
|
+
// THE STORE'S OWN FIELD NAMES. An entry is
|
|
288
|
+
// { name, kind, v, pexpire_at, last_id, gone }
|
|
289
|
+
// and the operations are `string.set` / `hash.set` / `set.add` / `list.push` / `zset.add` /
|
|
290
|
+
// `key.del`. The first version of this adapter read `key`, `type`, `value`, `ttl` and `deleted` —
|
|
291
|
+
// none of which exist — so it sent `SET '' ''` and the vendor answered 200. A wrong write that
|
|
292
|
+
// succeeds is the worst outcome available here, and no gate exercises a perform, so it passed
|
|
293
|
+
// every one of them. (Audit, 2026-09-08.)
|
|
294
|
+
const key = typeof fields.name === 'string' && fields.name ? fields.name : action.subject.id.replace(/^key:/, '');
|
|
295
|
+
const kind = String(fields.kind ?? 'string');
|
|
296
|
+
const value = fields.v;
|
|
297
|
+
const send = async (...args: unknown[]): Promise<Record<string, unknown>> => {
|
|
298
|
+
const res = await execute({
|
|
299
|
+
method: 'POST', path: '/',
|
|
300
|
+
headers: { accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer twin' },
|
|
301
|
+
body: JSON.stringify(args.map((a) => (typeof a === 'string' ? a : String(a)))),
|
|
302
|
+
});
|
|
303
|
+
if (res.status < 200 || res.status >= 300) throw new Error(`upstash redis ${String(args[0])} refused: HTTP ${res.status} ${res.body.slice(0, 200)}`);
|
|
304
|
+
try { return JSON.parse(res.body || '{}') as Record<string, unknown>; } catch { return {}; }
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
if (op === 'key.del' || fields.gone === true) {
|
|
308
|
+
return { externalId: key, data: await send('DEL', key) };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// `pexpire_at` is an ABSOLUTE epoch-ms instant, not a TTL in seconds — so it converges with
|
|
312
|
+
// PEXPIREAT, which takes exactly that. Sending it as an `EX` argument would have set an expiry
|
|
313
|
+
// fifty-six thousand years out.
|
|
314
|
+
const expireAt = typeof fields.pexpire_at === 'number' && fields.pexpire_at > 0 ? fields.pexpire_at : null;
|
|
315
|
+
const withExpiry = async (answered: Record<string, unknown>): Promise<PushOutcome> => {
|
|
316
|
+
if (expireAt !== null) await send('PEXPIREAT', key, expireAt);
|
|
317
|
+
return { externalId: key, data: answered };
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
if (kind === 'string') return withExpiry(await send('SET', key, String(value ?? '')));
|
|
321
|
+
if (kind === 'hash') {
|
|
322
|
+
// `v` is an array of [field, value] PAIRS, which HSET wants flattened.
|
|
323
|
+
const flat = (Array.isArray(value) ? value : []).flatMap((pair: unknown) => (Array.isArray(pair) ? [String(pair[0]), String(pair[1])] : []));
|
|
324
|
+
return flat.length === 0 ? { externalId: key, data: {} } : withExpiry(await send('HSET', key, ...flat));
|
|
325
|
+
}
|
|
326
|
+
if (kind === 'set') {
|
|
327
|
+
const members = (Array.isArray(value) ? value : []).map(String);
|
|
328
|
+
return members.length === 0 ? { externalId: key, data: {} } : withExpiry(await send('SADD', key, ...members));
|
|
329
|
+
}
|
|
330
|
+
if (kind === 'list') {
|
|
331
|
+
const items = (Array.isArray(value) ? value : []).map(String);
|
|
332
|
+
return items.length === 0 ? { externalId: key, data: {} } : withExpiry(await send('RPUSH', key, ...items));
|
|
333
|
+
}
|
|
334
|
+
if (kind === 'zset') {
|
|
335
|
+
// `v` is [[member, score], …]; ZADD takes them the other way round.
|
|
336
|
+
const pairs = (Array.isArray(value) ? value : []).flatMap((pair: unknown) => (Array.isArray(pair) ? [String(pair[1]), String(pair[0])] : []));
|
|
337
|
+
return pairs.length === 0 ? { externalId: key, data: {} } : withExpiry(await send('ZADD', key, ...pairs));
|
|
338
|
+
}
|
|
339
|
+
return { externalId: key, data: { performed: false, reason: `a ${kind} key has no command in this adapter that recreates it — this stays this world's own record` } };
|
|
340
|
+
}
|