@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,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
+ }