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