@volter/world-core 3.0.146 → 3.0.147

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.
@@ -1084,10 +1084,12 @@ export type SemanticsContext = {
1084
1084
  /** A vendor's Redis (the kernel's Redis library, redis/engine.ts): a request's commands run in order over this call's
1085
1085
  * tree and clock, in the pack's own service, under its wire's dialect, in one of its databases (`database`: its keys
1086
1086
  * and scripts apart from the others'); each answered `{result}` or `{error}`, and whether any wrote. A read-only
1087
- * World raises ReadOnlyError at the first write. The wire around it (Upstash's REST envelope) is the pack's. */
1087
+ * World raises ReadOnlyError at the first write. `transaction` distinguishes a wire's queued transaction from
1088
+ * a pipeline for script restrictions. The wire around it (Upstash's REST envelope) is the pack's. */
1088
1089
  redis(commands: string[][], options: {
1089
1090
  dialect: RedisDialect;
1090
1091
  database?: string;
1092
+ transaction?: boolean;
1091
1093
  }): Promise<{
1092
1094
  items: RunItem[];
1093
1095
  wrote: boolean;
@@ -1536,6 +1536,7 @@ export async function contextFor(m, call, scope) {
1536
1536
  redis: (commands, options) => execRedisRun(commands, {
1537
1537
  root, occurredAt: at, service: m.service, readOnly: scope.readOnly === true || isReadOnlyRequest(call.request), dialect: options.dialect,
1538
1538
  ...(options.database === undefined ? {} : { database: options.database }),
1539
+ ...(options.transaction === undefined ? {} : { transaction: options.transaction }),
1539
1540
  }),
1540
1541
  blobs: {
1541
1542
  put: (key, bytes) => writeResourceBlob(m.service, key, bytes, root),
@@ -85,6 +85,8 @@ export type RedisContext = {
85
85
  occurredAt: string;
86
86
  /** When true, any write command raises `ReadOnlyError` (the wire answers its read-only refusal). */
87
87
  readOnly?: boolean;
88
+ /** Commands queued in a wire transaction, rather than an ordinary pipeline. */
89
+ transaction?: boolean;
88
90
  /** A database other than the service's first (Redis's SELECT n, a vendor's database id), whose keys and scripts
89
91
  * are its own: subjects `db:<id>:key:<name>` and `db:<id>:script:<sha1>`. Absent: `key:<name>`, `script:<sha1>`. */
90
92
  database?: string;
@@ -116,6 +118,9 @@ export type RedisDialect = {
116
118
  lua: Required<Pick<LuaHost, 'extend' | 'numberArg'>>;
117
119
  /** Whether a script that fails part-way keeps what it wrote (Redis) or leaves nothing (Upstash). */
118
120
  scriptErrorsKeepWrites: boolean;
121
+ /** Wire-specific Lua flags. A flag installs a restriction before each command; the core owns `no-writes`.
122
+ * Flags absent here are refused, never ignored. A transaction is passed explicitly by the wire. */
123
+ scriptFlags?: Record<string, (keys: readonly string[], transaction: boolean) => (argv: string[]) => void>;
119
124
  /** How the dialect's keys are laid out in the tree, when not one subject per key (`keyspaceImageOf`, `flushKeySpace`). */
120
125
  storage?: {
121
126
  image: (ctx: RedisContext) => KeyspaceImage;
@@ -137,6 +142,11 @@ export declare function wrongArity(name: string): string;
137
142
  * a connection's (Redis) or a URL's (Upstash).
138
143
  */
139
144
  export declare const SERVED_COMMANDS: Record<string, [number, number]>;
145
+ /** Redis hash tag: the first nonempty {...} pair, otherwise the entire key (an empty first pair is invalid). */
146
+ export declare function redisHashTag(key: string): string;
147
+ /** Key positions of the shared core's commands. Undefined means database-wide access, a script, or an unknown
148
+ * command; callers must refuse an unsupported restriction rather than guess that the command has no keys. */
149
+ export declare function redisCommandKeys(argv: readonly string[]): string[] | undefined;
140
150
  /**
141
151
  * Does this command MUTATE? Takes the whole argv, not just the name, because two commands are
142
152
  * only writes for some of their subcommands. A read-only twin and `EVAL_RO` both key off this,
@@ -49,6 +49,7 @@ import { treeChangesSince, treeStamp } from "../log.js";
49
49
  import { applyTwinWrite } from "../serve.js";
50
50
  import { getActiveWorldStore } from "../world-store.js";
51
51
  import { LUA_NULL, luaScriptSha1, LuaError, LuaTable, runLua } from "./lua.js";
52
+ import { inlineCommand } from "./frames.js";
52
53
  /**
53
54
  * A Redis SIMPLE STATUS reply, kept distinct from a bulk string.
54
55
  *
@@ -138,6 +139,60 @@ export const SERVED_COMMANDS = {
138
139
  XADD: [4, -1], XLEN: [1, 1], XRANGE: [3, -1], XREVRANGE: [3, -1], XDEL: [2, -1],
139
140
  SCRIPT: [1, -1], EVAL: [2, -1], EVAL_RO: [2, -1], EVALSHA: [2, -1], EVALSHA_RO: [2, -1],
140
141
  };
142
+ /** Redis hash tag: the first nonempty {...} pair, otherwise the entire key (an empty first pair is invalid). */
143
+ export function redisHashTag(key) {
144
+ const start = key.indexOf('{');
145
+ const end = start < 0 ? -1 : key.indexOf('}', start + 1);
146
+ return end > start + 1 && start >= 0 ? key.slice(start + 1, end) : key;
147
+ }
148
+ /** Key positions of the shared core's commands. Undefined means database-wide access, a script, or an unknown
149
+ * command; callers must refuse an unsupported restriction rather than guess that the command has no keys. */
150
+ export function redisCommandKeys(argv) {
151
+ const name = (argv[0] ?? '').toUpperCase();
152
+ if (['PING', 'ECHO', 'SCRIPT'].includes(name))
153
+ return [];
154
+ if (['EXISTS', 'TOUCH', 'DEL', 'UNLINK', 'MGET'].includes(name))
155
+ return argv.slice(1);
156
+ if (name === 'MSET' || name === 'MSETNX')
157
+ return argv.slice(1).filter((_, i) => i % 2 === 0);
158
+ if (['RENAME', 'RENAMENX', 'RPOPLPUSH'].includes(name))
159
+ return argv.slice(1, 3);
160
+ if (['DBSIZE', 'FLUSHDB', 'FLUSHALL', 'KEYS', 'RANDOMKEY', 'SCAN', 'EVAL', 'EVALSHA', 'EVAL_RO', 'EVALSHA_RO'].includes(name))
161
+ return undefined;
162
+ return SERVED_COMMANDS[name] && argv[1] !== undefined ? [argv[1]] : undefined;
163
+ }
164
+ /** EVAL shebang metadata: keep the newline for Lua line numbers and the original script for cache identity.
165
+ * source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/eval.c (evalExtractShebangFlags). */
166
+ function redisScript(script, dialect) {
167
+ const flags = new Set();
168
+ if (!script.startsWith('#!'))
169
+ return { body: script, flags };
170
+ const newline = script.indexOf('\n');
171
+ if (newline < 0)
172
+ throw new RedisCommandError('ERR Invalid script shebang');
173
+ let parts;
174
+ try {
175
+ parts = inlineCommand(script.slice(0, newline));
176
+ }
177
+ catch {
178
+ throw new RedisCommandError('ERR Invalid engine in script shebang');
179
+ }
180
+ if (parts[0] !== '#!lua')
181
+ throw new RedisCommandError(`ERR Unexpected engine in script shebang: ${parts[0] ?? ''}`);
182
+ for (const option of parts.slice(1)) {
183
+ if (!option.startsWith('flags='))
184
+ throw new RedisCommandError(`ERR Unknown lua shebang option: ${option}`);
185
+ for (const flag of option.slice(6).split(',')) {
186
+ if (!flag && option === 'flags=')
187
+ continue;
188
+ if (flag !== 'no-writes' && !Object.hasOwn(dialect.scriptFlags ?? {}, flag)) {
189
+ throw new RedisCommandError(dialect.unmodeled(`Lua script flag ${flag}`));
190
+ }
191
+ flags.add(flag);
192
+ }
193
+ }
194
+ return { body: script.slice(newline), flags };
195
+ }
141
196
  /** Commands that mutate state — refused by a read-only twin and inside `EVAL_RO`/`EVALSHA_RO`. */
142
197
  const WRITE_COMMANDS = new Set([
143
198
  'SET', 'SETEX', 'PSETEX', 'SETNX', 'MSET', 'MSETNX', 'GETSET', 'GETDEL', 'GETEX', 'APPEND',
@@ -1366,6 +1421,7 @@ export function execOne(space, argv) {
1366
1421
  if (sub === 'LOAD') {
1367
1422
  if (a.length !== 2)
1368
1423
  throw new RedisCommandError(wrongArity('script'));
1424
+ redisScript(a[1], dialect);
1369
1425
  return storeScript(space, a[1]);
1370
1426
  }
1371
1427
  if (sub === 'EXISTS')
@@ -1400,6 +1456,7 @@ export function execOne(space, argv) {
1400
1456
  }
1401
1457
  else {
1402
1458
  script = a[0];
1459
+ redisScript(script, dialect); // an invalid/unsupported header must not enter the script cache
1403
1460
  // Caching is a WRITE, so a read-only twin must not attempt it — otherwise `EVAL_RO`, which
1404
1461
  // is a read by definition, would 405 on the cache rather than on anything it does (§9
1405
1462
  // round 1). The cost is that a read-only twin cannot serve a later EVALSHA for this
@@ -1797,15 +1854,23 @@ function luaToRedis(v) {
1797
1854
  function evalScript(space, script, keys, args, readOnly) {
1798
1855
  const before = space.snapshot();
1799
1856
  try {
1800
- const result = runLua(script, {
1857
+ const { body, flags } = redisScript(script, space.ctx.dialect);
1858
+ const guards = [...flags].filter(flag => flag !== 'no-writes')
1859
+ .map(flag => space.ctx.dialect.scriptFlags[flag](keys, space.ctx.transaction === true));
1860
+ const result = runLua(body, {
1801
1861
  keys,
1802
1862
  argv: args,
1803
1863
  call: (callArgs, line) => {
1804
1864
  const cmd = String(callArgs[0] ?? '').toUpperCase();
1805
1865
  // Redis's refusal, as EVAL_RO's page prints it (redis.io/docs/latest/commands/eval_ro/): the script by its SHA1
1806
1866
  // and the line of the call
1807
- if (readOnly && isWriteCommand(cmd, callArgs.slice(1), space.ctx.dialect))
1867
+ if ((readOnly || flags.has('no-writes')) && isWriteCommand(cmd, callArgs.slice(1), space.ctx.dialect))
1808
1868
  throw new RedisCommandError(`ERR Error running script (call to ${luaScriptSha1(script)}): @user_script:${line}: @user_script: ${line}: Write commands are not allowed from read-only scripts.`);
1869
+ const shape = commandShapeError(callArgs, space.ctx.dialect);
1870
+ if (shape !== null)
1871
+ throw new RedisCommandError(shape);
1872
+ for (const guard of guards)
1873
+ guard(callArgs);
1809
1874
  return redisToLua(execOne(space, callArgs));
1810
1875
  },
1811
1876
  ...space.ctx.dialect.lua,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.146",
3
+ "version": "3.0.147",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
@@ -1873,8 +1873,9 @@ export type SemanticsContext = {
1873
1873
  /** A vendor's Redis (the kernel's Redis library, redis/engine.ts): a request's commands run in order over this call's
1874
1874
  * tree and clock, in the pack's own service, under its wire's dialect, in one of its databases (`database`: its keys
1875
1875
  * and scripts apart from the others'); each answered `{result}` or `{error}`, and whether any wrote. A read-only
1876
- * World raises ReadOnlyError at the first write. The wire around it (Upstash's REST envelope) is the pack's. */
1877
- redis(commands: string[][], options: { dialect: RedisDialect; database?: string }): Promise<{ items: RunItem[]; wrote: boolean }>;
1876
+ * World raises ReadOnlyError at the first write. `transaction` distinguishes a wire's queued transaction from
1877
+ * a pipeline for script restrictions. The wire around it (Upstash's REST envelope) is the pack's. */
1878
+ redis(commands: string[][], options: { dialect: RedisDialect; database?: string; transaction?: boolean }): Promise<{ items: RunItem[]; wrote: boolean }>;
1878
1879
  /** Another vendor's own URL answered by the World's twin of it (vendor-call.ts): a vendor's call to another as part of
1879
1880
  * its behaviour (Clerk exchanging a Google sign-in's code). A vendor the World runs no twin of is refused
1880
1881
  * (VendorUnreachableError); nothing leaves the World. */
@@ -2136,6 +2137,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
2136
2137
  redis: (commands, options) => execRedisRun(commands, {
2137
2138
  root, occurredAt: at, service: m.service, readOnly: scope.readOnly === true || isReadOnlyRequest(call.request), dialect: options.dialect,
2138
2139
  ...(options.database === undefined ? {} : { database: options.database }),
2140
+ ...(options.transaction === undefined ? {} : { transaction: options.transaction }),
2139
2141
  }),
2140
2142
  blobs: {
2141
2143
  put: (key, bytes) => writeResourceBlob(m.service, key, bytes, root),
@@ -49,6 +49,7 @@ import { treeChangesSince, treeStamp } from '../log.ts';
49
49
  import { applyTwinWrite, type TwinResource } from '../serve.ts';
50
50
  import { getActiveWorldStore } from '../world-store.ts';
51
51
  import { LUA_NULL, luaScriptSha1, LuaError, LuaTable, runLua, type LuaHost, type LuaValue } from './lua.ts';
52
+ import { inlineCommand } from './frames.ts';
52
53
 
53
54
  /**
54
55
  * A Redis SIMPLE STATUS reply, kept distinct from a bulk string.
@@ -147,6 +148,8 @@ export type RedisContext = {
147
148
  occurredAt: string;
148
149
  /** When true, any write command raises `ReadOnlyError` (the wire answers its read-only refusal). */
149
150
  readOnly?: boolean;
151
+ /** Commands queued in a wire transaction, rather than an ordinary pipeline. */
152
+ transaction?: boolean;
150
153
  /** A database other than the service's first (Redis's SELECT n, a vendor's database id), whose keys and scripts
151
154
  * are its own: subjects `db:<id>:key:<name>` and `db:<id>:script:<sha1>`. Absent: `key:<name>`, `script:<sha1>`. */
152
155
  database?: string;
@@ -179,6 +182,9 @@ export type RedisDialect = {
179
182
  lua: Required<Pick<LuaHost, 'extend' | 'numberArg'>>;
180
183
  /** Whether a script that fails part-way keeps what it wrote (Redis) or leaves nothing (Upstash). */
181
184
  scriptErrorsKeepWrites: boolean;
185
+ /** Wire-specific Lua flags. A flag installs a restriction before each command; the core owns `no-writes`.
186
+ * Flags absent here are refused, never ignored. A transaction is passed explicitly by the wire. */
187
+ scriptFlags?: Record<string, (keys: readonly string[], transaction: boolean) => (argv: string[]) => void>;
182
188
  /** How the dialect's keys are laid out in the tree, when not one subject per key (`keyspaceImageOf`, `flushKeySpace`). */
183
189
  storage?: { image: (ctx: RedisContext) => KeyspaceImage; flush: (space: KeySpace) => Promise<void> };
184
190
  };
@@ -233,6 +239,49 @@ export const SERVED_COMMANDS: Record<string, [number, number]> = {
233
239
  SCRIPT: [1, -1], EVAL: [2, -1], EVAL_RO: [2, -1], EVALSHA: [2, -1], EVALSHA_RO: [2, -1],
234
240
  };
235
241
 
242
+ /** Redis hash tag: the first nonempty {...} pair, otherwise the entire key (an empty first pair is invalid). */
243
+ export function redisHashTag(key: string): string {
244
+ const start = key.indexOf('{');
245
+ const end = start < 0 ? -1 : key.indexOf('}', start + 1);
246
+ return end > start + 1 && start >= 0 ? key.slice(start + 1, end) : key;
247
+ }
248
+
249
+ /** Key positions of the shared core's commands. Undefined means database-wide access, a script, or an unknown
250
+ * command; callers must refuse an unsupported restriction rather than guess that the command has no keys. */
251
+ export function redisCommandKeys(argv: readonly string[]): string[] | undefined {
252
+ const name = (argv[0] ?? '').toUpperCase();
253
+ if (['PING', 'ECHO', 'SCRIPT'].includes(name)) return [];
254
+ if (['EXISTS', 'TOUCH', 'DEL', 'UNLINK', 'MGET'].includes(name)) return argv.slice(1);
255
+ if (name === 'MSET' || name === 'MSETNX') return argv.slice(1).filter((_, i) => i % 2 === 0);
256
+ if (['RENAME', 'RENAMENX', 'RPOPLPUSH'].includes(name)) return argv.slice(1, 3);
257
+ if (['DBSIZE', 'FLUSHDB', 'FLUSHALL', 'KEYS', 'RANDOMKEY', 'SCAN', 'EVAL', 'EVALSHA', 'EVAL_RO', 'EVALSHA_RO'].includes(name)) return undefined;
258
+ return SERVED_COMMANDS[name] && argv[1] !== undefined ? [argv[1]] : undefined;
259
+ }
260
+
261
+ /** EVAL shebang metadata: keep the newline for Lua line numbers and the original script for cache identity.
262
+ * source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/eval.c (evalExtractShebangFlags). */
263
+ function redisScript(script: string, dialect: RedisDialect): { body: string; flags: Set<string> } {
264
+ const flags = new Set<string>();
265
+ if (!script.startsWith('#!')) return { body: script, flags };
266
+ const newline = script.indexOf('\n');
267
+ if (newline < 0) throw new RedisCommandError('ERR Invalid script shebang');
268
+ let parts: string[];
269
+ try { parts = inlineCommand(script.slice(0, newline)); }
270
+ catch { throw new RedisCommandError('ERR Invalid engine in script shebang'); }
271
+ if (parts[0] !== '#!lua') throw new RedisCommandError(`ERR Unexpected engine in script shebang: ${parts[0] ?? ''}`);
272
+ for (const option of parts.slice(1)) {
273
+ if (!option.startsWith('flags=')) throw new RedisCommandError(`ERR Unknown lua shebang option: ${option}`);
274
+ for (const flag of option.slice(6).split(',')) {
275
+ if (!flag && option === 'flags=') continue;
276
+ if (flag !== 'no-writes' && !Object.hasOwn(dialect.scriptFlags ?? {}, flag)) {
277
+ throw new RedisCommandError(dialect.unmodeled(`Lua script flag ${flag}`));
278
+ }
279
+ flags.add(flag);
280
+ }
281
+ }
282
+ return { body: script.slice(newline), flags };
283
+ }
284
+
236
285
  /** Commands that mutate state — refused by a read-only twin and inside `EVAL_RO`/`EVALSHA_RO`. */
237
286
  const WRITE_COMMANDS = new Set([
238
287
  'SET', 'SETEX', 'PSETEX', 'SETNX', 'MSET', 'MSETNX', 'GETSET', 'GETDEL', 'GETEX', 'APPEND',
@@ -1185,7 +1234,7 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
1185
1234
  // ── scripting ────────────────────────────────────────────────────────────────────────────
1186
1235
  case 'SCRIPT': {
1187
1236
  const sub = a[0]!.toUpperCase();
1188
- if (sub === 'LOAD') { if (a.length !== 2) throw new RedisCommandError(wrongArity('script')); return storeScript(space, a[1]!); }
1237
+ if (sub === 'LOAD') { if (a.length !== 2) throw new RedisCommandError(wrongArity('script')); redisScript(a[1]!, dialect); return storeScript(space, a[1]!); }
1189
1238
  if (sub === 'EXISTS') return a.slice(1).map((sha) => (space.hasScript(sha.toLowerCase()) ? 1 : 0));
1190
1239
  if (sub === 'FLUSH') { space.flushScripts(); return OK; }
1191
1240
  throw new RedisCommandError(`ERR Unknown SCRIPT subcommand or wrong number of arguments for '${a[0]}'`);
@@ -1207,6 +1256,7 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
1207
1256
  script = found;
1208
1257
  } else {
1209
1258
  script = a[0]!;
1259
+ redisScript(script, dialect); // an invalid/unsupported header must not enter the script cache
1210
1260
  // Caching is a WRITE, so a read-only twin must not attempt it — otherwise `EVAL_RO`, which
1211
1261
  // is a read by definition, would 405 on the cache rather than on anything it does (§9
1212
1262
  // round 1). The cost is that a read-only twin cannot serve a later EVALSHA for this
@@ -1511,14 +1561,20 @@ function luaToRedis(v: LuaValue): RedisValue {
1511
1561
  function evalScript(space: KeySpace, script: string, keys: string[], args: string[], readOnly: boolean): RedisValue {
1512
1562
  const before = space.snapshot();
1513
1563
  try {
1514
- const result = runLua(script, {
1564
+ const { body, flags } = redisScript(script, space.ctx.dialect);
1565
+ const guards = [...flags].filter(flag => flag !== 'no-writes')
1566
+ .map(flag => space.ctx.dialect.scriptFlags![flag]!(keys, space.ctx.transaction === true));
1567
+ const result = runLua(body, {
1515
1568
  keys,
1516
1569
  argv: args,
1517
1570
  call: (callArgs, line) => {
1518
1571
  const cmd = String(callArgs[0] ?? '').toUpperCase();
1519
1572
  // Redis's refusal, as EVAL_RO's page prints it (redis.io/docs/latest/commands/eval_ro/): the script by its SHA1
1520
1573
  // and the line of the call
1521
- if (readOnly && isWriteCommand(cmd, callArgs.slice(1), space.ctx.dialect)) throw new RedisCommandError(`ERR Error running script (call to ${luaScriptSha1(script)}): @user_script:${line}: @user_script: ${line}: Write commands are not allowed from read-only scripts.`);
1574
+ if ((readOnly || flags.has('no-writes')) && isWriteCommand(cmd, callArgs.slice(1), space.ctx.dialect)) throw new RedisCommandError(`ERR Error running script (call to ${luaScriptSha1(script)}): @user_script:${line}: @user_script: ${line}: Write commands are not allowed from read-only scripts.`);
1575
+ const shape = commandShapeError(callArgs, space.ctx.dialect);
1576
+ if (shape !== null) throw new RedisCommandError(shape);
1577
+ for (const guard of guards) guard(callArgs);
1522
1578
  return redisToLua(execOne(space, callArgs));
1523
1579
  },
1524
1580
  ...space.ctx.dialect.lua,
@@ -1570,4 +1626,3 @@ export async function execRedisRun(commands: string[][], ctx: RedisContext): Pro
1570
1626
  await (space.ctx.dialect.storage?.flush(space) ?? flushKeySpace(space));
1571
1627
  return { items, wrote, instant: nowMs };
1572
1628
  }
1573
-