@mcp-b/do-runtime 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bef6bd8: `storage.put()` now reads its arguments the way JSG does, instead of silently dropping writes.
8
+
9
+ A non-string, non-object key previously fell into the multi-key overload, where `Object.entries` on a primitive is `[]` — so the call resolved having written nothing. It now stringifies the key as the `kj::String` alternative does: `put(123, v)` writes `"123"`, `put(null, v)` writes `"null"`, `put(undefined, v)` writes `"undefined"`, `put([["a", 1]], v)` writes `"a,1"`, and a symbol key throws V8's own conversion error.
10
+
11
+ Once the key does unwrap as a dictionary, a second argument that is a non-null primitive is now refused with upstream's own message rather than spread into the options — `put({a: 1}, "v")` wrote key `a` with `{0: "v"}` for options. The refusal models workerd's all-optional options struct exactly: `null`, arrays and functions all unwrap to default options and the write proceeds, as measured on real workerd. Functions take the dictionary alternative in the KEY position too, so `put(function f(){}, v)` is refused rather than writing a row keyed on the function's source text.
12
+
13
+ Measured on real workerd and pinned by a conformance row that runs on all three lanes.
14
+
15
+ - bef6bd8: `sql.exec()` and `sql.ingest()` now refuse the SQL forms workerd's authorizer refuses but its regulator never sees. SQL that uses them throws where it previously ran.
16
+
17
+ `ATTACH`, `DETACH`, the temp-schema creations, and virtual-table modules outside upstream's four reach SQLite action codes rather than `SqlStorageRegulator` callbacks, so porting the regulator whole left them unguarded. Each refuses with workerd's own message, `not authorized: SQLITE_AUTH`, except `VACUUM`, which carries SQLite's `cannot VACUUM from within a transaction: SQLITE_ERROR` because upstream refuses it by the transaction a Durable Object always has open.
18
+
19
+ `ATTACH` was an isolation boundary and not only a fidelity gap: both backends open a real database file, so application SQL could attach another actor's database and read every application table in it — the reserved-name scan sees only the submitted statement — and `VACUUM INTO` could write any file the process can. The temp schema is refused by both of its spellings, `CREATE TEMP TABLE` and `CREATE TABLE temp.t`; the second was the live gap, where the table was created, written and read back. Of the virtual-table modules, `dbstat` is the one that mattered: it reports a row per table, so it enumerated the runtime's own `_cf_` tables and their sizes without the statement ever naming them. The table name and the module accept every quoting SQLite does — double quotes, backticks, brackets, and the misquoting feature's single-quoted string — with the module resolved before the allowlist, exactly as workerd's authorizer sees it; a `CREATE VIRTUAL TABLE` whose module cannot be read is refused outright.
20
+
21
+ A leading `;` now counts as trivia for every leading-keyword refusal in the file, including the pre-existing transaction-control and pragma checks. `node:sqlite` reports an empty first statement as part of the span it compiled, so `;ATTACH …` read as a statement whose keyword was `;` and passed all three.
22
+
23
+ `PRAGMA page_size` is now allowed with no argument, as upstream's allowlist has it for the R\*Tree module's own internal read; `PRAGMA page_size = N` is still refused. That was a false refusal — code that runs on Cloudflare failed here.
24
+
25
+ Every form, refused and allowed alike, was measured on real workerd and is pinned by a conformance row that runs on all three lanes.
26
+
3
27
  ## 0.4.0
4
28
 
5
29
  ### Minor Changes
package/README.md CHANGED
@@ -238,7 +238,9 @@ The browser cannot reproduce every workerd facility. Where it cannot, the runtim
238
238
  | `DurableObjectState.abort()` | Breaks later storage and re-entry; cannot synchronously terminate the calling JavaScript slice. |
239
239
  | Stored value wire bytes | Browser-safe versioned structured-clone encoding rather than V8's private format; public value types align and legacy JSON rows remain readable. |
240
240
  | SQL row counters | Local `rowsRead`/`rowsWritten`, including `sql.ingest()`, use returned rows and SQLite changes; workerd uses unavailable libsql billing counters. |
241
- | Reserved SQL names | `_cf_` detected from tokenized SQL text, which can reject more than workerd's authorizer. |
241
+ | Reserved SQL names | `_cf_` detected from tokenized SQL text, which can reject more than workerd's authorizer. `ANALYZE` on a reserved table is refused where workerd allows it. |
242
+ | Authorizer-only SQL forms | `ATTACH`, `DETACH`, the temp-schema creations (both `CREATE TEMP …` and the `temp.` qualifier), `VACUUM`, and virtual-table modules outside upstream's four (`fts5`, `fts5vocab`, `rtree`, `rtree_i32`) are refused from the leading keyword, with workerd's own messages. These reach the authorizer's own decisions — action codes and its temp-schema rule — rather than `SqlStorageRegulator` callbacks, so porting the regulator did not carry them. The refused and allowed forms are matched through SQLite's identifier quoting, whitespace or none. `EXPLAIN` in front of a refused form still compiles here, where workerd's authorizer refuses it. |
243
+ | SQL function allowlist | Not enforced. Workerd's authorizer denies any function outside its 138-name `ALLOWED_SQLITE_FUNCTIONS` list; this runtime allows every function the backend compiled, including build-detail readers such as `sqlite_version()` and `sqlite_source_id()`. |
242
244
  | PRAGMA allowlist | Workerd's allowlist enforced from tokenized SQL text. A `pragma_*` table-valued function with a string or bound argument is authorized by pragma name only, where workerd's authorizer also sees the resolved argument; the pinned conformance row is the contract. |
243
245
  | Node SQLite length limit | Bound and returned strings and blobs are capped at 4 MiB; `node:sqlite` cannot cap an unreturned SQL-computed value. The browser backend uses SQLite's native limit. |
244
246
  | Response BYOB readers | Refused; their continuation cannot be re-gated. Use a default reader or `arrayBuffer()`. |
package/dist/index.js CHANGED
@@ -999,14 +999,24 @@ function notSerializableType(value) {
999
999
  * `abstract` in the types — JSG nested types have no JS constructor — so the
1000
1000
  * faithful shape is a constructor that refuses. `sql.Cursor` exists for
1001
1001
  * `instanceof`, which is all upstream exposes it for.
1002
- * 3. **The regulator is ported whole; what is missing is the authorizer that
1003
- * calls it.** All three callbacks are here and none of them needed the
1002
+ * 3. **The regulator is ported whole, and that is not the whole authorizer.**
1003
+ * Four of its five members are here as callbacks, and none needed the
1004
1004
  * authorizer to compute anything — `isAllowedName` is a prefix test,
1005
- * `isAllowedTrigger` is `return true`, `allowTransactions` throws. What the
1006
- * authorizer supplied was the *identifiers*, not the decisions. With no
1007
- * authorizer the statement text is the only source, so `exec` tokenizes it
1005
+ * `isAllowedTrigger` is `return true`, `allowTransactions` throws,
1006
+ * `shouldAddQueryStats` is a constant. The fifth, `onError`, is not a
1007
+ * callback at all in this port: it is every `throw new Error(message)`
1008
+ * below, which is all its upstream body does with a refusal message.
1009
+ * For those, what the authorizer supplied was the *identifiers*:
1010
+ * with none, the statement text is the only source, so `exec` tokenizes it
1008
1011
  * and runs `isAllowedName` over every identifier-shaped token. That is
1009
1012
  * deliberately STRICTER than upstream — see `SQL_RESERVED_PREFIX_MESSAGE`.
1013
+ *
1014
+ * But the authorizer also makes decisions no callback ever sees, and those
1015
+ * do NOT arrive with the regulator: `SQLITE_ATTACH` / `SQLITE_DETACH`, the
1016
+ * `SQLITE_CREATE_TEMP_*` family and the `temp` schema, `SQLITE_PRAGMA`, and
1017
+ * the `SQLITE_CREATE_VTABLE` module list. Each is refused from the text in
1018
+ * `refuseUnauthorizedForms` and `requireAllowedPragmas`. `SQLITE_FUNCTION`
1019
+ * is the one still unported — see the README divergence row.
1010
1020
  * 4. **`ingest` stays at upstream's SQLite seam.** `SqliteDatabase.ingest()`
1011
1021
  * executes every complete statement and returns the partial tail, using the
1012
1022
  * same compiled boundaries and regulator as `exec`.
@@ -1039,8 +1049,15 @@ var SQL_RESERVED_PREFIX_MESSAGE = "not authorized: a SQL statement may not name
1039
1049
  * `SQLITE_SAVEPOINT`. The same set `util/sqlite.ts` classifies, read here from
1040
1050
  * the leading keyword because the untrusted path has to refuse them before the
1041
1051
  * trusted one applies them.
1052
+ *
1053
+ * `;` counts as leading trivia here and in every other leading-keyword refusal
1054
+ * below. A statement boundary comes from the backend, and `node:sqlite` reports
1055
+ * an empty leading statement as part of the span it compiled: the `sourceSQL`
1056
+ * for `;ATTACH ...` is the whole string, so an anchor of `^\s*` would read the
1057
+ * keyword as `;` and let the compiled ATTACH through. The browser backend cuts
1058
+ * the same input at the first `;` and refuses the empty statement instead.
1042
1059
  */
1043
- var TRANSACTION_CONTROL = /^\s*(?:BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i;
1060
+ var TRANSACTION_CONTROL = /^[\s;]*(?:BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i;
1044
1061
  /** Cheap pre-test, so the tokenizer below runs only on a statement that could fail it. */
1045
1062
  var RESERVED_PREFIX_HINT = /_cf_/i;
1046
1063
  /** A SQL identifier. Double-quoted and bracketed forms are still identifiers, so only the
@@ -1057,7 +1074,15 @@ var IDENTIFIER = /[A-Za-z_][A-Za-z0-9_$]*/g;
1057
1074
  */
1058
1075
  var NOT_CODE = /'(?:[^']|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g;
1059
1076
  /**
1060
- * `SqlStorageRegulator` (`sql.h:15-22`, `sql.c++:143-173`), whole.
1077
+ * Comments are never code. String literals STAY, for both of this regex's callers: pragma
1078
+ * arguments may be quoted, and SQLite's misquoting feature reads a single-quoted string as an
1079
+ * identifier — `CREATE TABLE 'temp'.t(x)` really creates a temp-schema table — so the checks
1080
+ * that read identifier positions must still see it. `NOT_CODE` blanks literals, which is right
1081
+ * for the token scans and would be a bypass for these callers.
1082
+ */
1083
+ var NOT_COMMENT = /--[^\n]*|\/\*[\s\S]*?\*\//g;
1084
+ /**
1085
+ * ← `SqlStorageRegulator` (`sql.h:15-22`, `sql.c++:141-165`), whole.
1061
1086
  *
1062
1087
  * Upstream reaches these through the SQLite authorizer while a statement is
1063
1088
  * being compiled. `exec` calls them from the statement text instead, which is
@@ -1123,20 +1148,108 @@ function refuseTransactionControl(statement) {
1123
1148
  * byte-identical so a caller matching on it ports unchanged.
1124
1149
  */
1125
1150
  var SQL_NOT_AUTHORIZED_MESSAGE = "not authorized: SQLITE_AUTH";
1126
- /** Comments are never code. String literals STAY: pragma arguments may be quoted. */
1127
- var NOT_COMMENT = /--[^\n]*|\/\*[\s\S]*?\*\//g;
1151
+ /**
1152
+ * The forms neither the regulator nor any callback sees: the authorizer's own
1153
+ * action codes, plus `VACUUM`, which SQLite itself refuses by precondition.
1154
+ *
1155
+ * Porting `SqlStorageRegulator` whole (point 3 in the file header) carried over its members, but
1156
+ * not the authorizer's own action codes: `SQLITE_ATTACH`, `SQLITE_DETACH`, `SQLITE_CREATE_TEMP_*`
1157
+ * and `SQLITE_CREATE_VTABLE` consult no callback, so nothing here refused them. Every form below
1158
+ * was measured on real workerd through the conformance oracle, not inferred.
1159
+ *
1160
+ * `ATTACH` and `DETACH` are also the isolation boundary rather than a fidelity detail: both
1161
+ * backends open a real file, so on `node:sqlite` an `ATTACH` reads another actor's database and
1162
+ * a `VACUUM INTO` writes anywhere the process can. The reserved-name scan does not cover it,
1163
+ * because that scan tokenizes the SUBMITTED statement — `other._cf_KV` is caught, and every
1164
+ * application table in the same attached database is not.
1165
+ */
1166
+ var DATABASE_ATTACHMENT = /^[\s;]*(?:ATTACH|DETACH)\b/i;
1167
+ /**
1168
+ * ← the `SQLITE_CREATE_TEMP_*` denials (`sqlite.c++:1323`) and the `dbName == temp` rule
1169
+ * (`sqlite.c++:1073`), which permits a temp-schema database name only `READ` and `UPDATE`.
1170
+ * Upstream's own reason to deny them applies here unchanged: a temporary table makes SQLite
1171
+ * open a separate temporary file that the storage engine knows nothing about.
1172
+ *
1173
+ * Two spellings, one refusal each by its own upstream path: `CREATE TEMP TABLE t(x)` is the
1174
+ * keyword and hits the action codes; `CREATE TABLE temp.t(x)` is the schema qualifier and hits
1175
+ * the `dbName` rule — and the qualifier was the live gap, where the table was created, written
1176
+ * and read back here while workerd refused it outright. The qualifier accepts every quoting
1177
+ * SQLite does, single quotes included, with or without whitespace after the keyword (measured:
1178
+ * workerd refuses `CREATE TABLE 'temp'.t(x)` and `CREATE TABLE"temp".t(x)` the same way), and
1179
+ * is matched only in the object-name position, so an application table merely NAMED `tempest`
1180
+ * or `temporary_log` is untouched. The keyword spelling needs no qualifier arm of its own here:
1181
+ * `TEMP_SCHEMA` already refuses every `CREATE TEMP…` before this pattern is consulted.
1182
+ *
1183
+ * The rest of the `dbName` rule goes unported on purpose: with no way to create a temp-schema
1184
+ * object, `INSERT`/`DELETE`/`DROP` against one die in SQLite as `no such table`, and the one
1185
+ * silent form measures identically — workerd allows `DROP TABLE IF EXISTS temp.ghost` too.
1186
+ */
1187
+ var TEMP_SCHEMA = /^[\s;]*CREATE\s+(?:TEMP|TEMPORARY)\b/i;
1188
+ var TEMP_QUALIFIED = /^[\s;]*CREATE\s+(?:UNIQUE\s+|VIRTUAL\s+)?(?:TABLE|VIEW|TRIGGER|INDEX)\s*(?:IF\s+NOT\s+EXISTS\s*)?(?:"temp"|'temp'|`temp`|\[temp\]|temp)\s*\./i;
1189
+ /**
1190
+ * ← `SQLITE_CREATE_VTABLE` (`sqlite.c++:1298-1316`): a virtual table is native-code callbacks, so
1191
+ * upstream allows exactly four modules — FTS5 and its `fts5vocab` companion, R*Tree and its
1192
+ * `rtree_i32` variant — and denies every other module SQLite was compiled with.
1193
+ *
1194
+ * `dbstat` is why this is not only fidelity: it reports a row per table with page counts and
1195
+ * byte sizes, so `SELECT name FROM d` enumerates `_cf_KV` and the rest of the runtime's own
1196
+ * tables without the statement ever naming them — around `requireAllowedNames`, which can only
1197
+ * see the text it was given.
1198
+ *
1199
+ * The table name and the module accept every quoting SQLite does — double quotes, backticks,
1200
+ * brackets, and the misquoting feature's single-quoted string, with or without whitespace
1201
+ * before them — because the module has to be read from PAST the name, and a guessed name
1202
+ * boundary is a bypass in both directions: an unparseable name skipped the check, and
1203
+ * `"a USING fts5 b" USING dbstat` read its module out of the quoted name. Measured: workerd
1204
+ * refuses both, refuses `CREATE VIRTUAL TABLE"d"USING dbstat`, resolves `USING "dbstat"` to
1205
+ * the same denial, and allows `USING 'fts5'`. A `CREATE VIRTUAL TABLE` whose module the
1206
+ * pattern cannot read is refused outright — the deliberately stricter direction the
1207
+ * unparseable-PRAGMA fallback below already takes.
1208
+ */
1209
+ var SQL_IDENTIFIER_SOURCE = /"(?:[^"]|"")*"|'(?:[^']|'')*'|`(?:[^`]|``)*`|\[[^\]]*\]|[A-Za-z_\u0080-\uffff][A-Za-z0-9_$\u0080-\uffff]*/.source;
1210
+ var VIRTUAL_TABLE = /^[\s;]*CREATE\s+VIRTUAL\s+TABLE\b/i;
1211
+ var VIRTUAL_TABLE_MODULE = new RegExp(String.raw`^[\s;]*CREATE\s+VIRTUAL\s+TABLE\s*(?:IF\s+NOT\s+EXISTS\s*)?(?:${SQL_IDENTIFIER_SOURCE})\s*(?:\.\s*(?:${SQL_IDENTIFIER_SOURCE})\s*)?USING\s*(${SQL_IDENTIFIER_SOURCE})`, "i");
1212
+ var ALLOWED_VIRTUAL_TABLE_MODULES = /* @__PURE__ */ new Set([
1213
+ "fts5",
1214
+ "fts5vocab",
1215
+ "rtree",
1216
+ "rtree_i32"
1217
+ ]);
1218
+ /**
1219
+ * `VACUUM` has no action code of its own, so the authorizer never sees it. What refuses it
1220
+ * upstream is SQLite's own `cannot VACUUM from within a transaction` precondition, against the
1221
+ * transaction a Durable Object always has open — and that message is what the oracle returned,
1222
+ * for `VACUUM`, `VACUUM main` and `VACUUM INTO` alike. Byte-identical for the same reason
1223
+ * `SQL_NOT_AUTHORIZED_MESSAGE` is: a caller matching on it ports unchanged.
1224
+ *
1225
+ * Refused here unconditionally rather than by transaction state, which is a divergence only in
1226
+ * mechanism: this runtime never runs a statement where upstream would have allowed it.
1227
+ */
1228
+ var VACUUM_STATEMENT = /^[\s;]*VACUUM\b/i;
1229
+ var SQL_VACUUM_REFUSED_MESSAGE = "cannot VACUUM from within a transaction: SQLITE_ERROR";
1230
+ /** Refuse the authorizer-only forms against one SQLite-decided statement boundary. */
1231
+ function refuseUnauthorizedForms(statement) {
1232
+ const code = statement.replace(NOT_COMMENT, " ");
1233
+ if (VACUUM_STATEMENT.test(code)) throw new Error(SQL_VACUUM_REFUSED_MESSAGE);
1234
+ if (DATABASE_ATTACHMENT.test(code) || TEMP_SCHEMA.test(code) || TEMP_QUALIFIED.test(code)) throw new Error(SQL_NOT_AUTHORIZED_MESSAGE);
1235
+ if (VIRTUAL_TABLE.test(code)) {
1236
+ const moduleName = VIRTUAL_TABLE_MODULE.exec(code)?.[1];
1237
+ if (moduleName === void 0 || !ALLOWED_VIRTUAL_TABLE_MODULES.has(unquoted(moduleName).toLowerCase())) throw new Error(SQL_NOT_AUTHORIZED_MESSAGE);
1238
+ }
1239
+ }
1128
1240
  /** Cheap pre-test; `PRAGMA` and the `pragma_` functions both contain it. */
1129
1241
  var PRAGMA_HINT = /pragma/i;
1130
1242
  /** `PRAGMA [schema.]name`, then `= value`, `(argument)`, or nothing. */
1131
- var PRAGMA_STATEMENT = /^\s*PRAGMA\s+(?:[A-Za-z_][A-Za-z0-9_$]*\s*\.\s*)?([A-Za-z_][A-Za-z0-9_$]*)(?:\s*=\s*([\s\S]+?)|\s*\(\s*([\s\S]*?)\s*\))?\s*;?\s*$/i;
1243
+ var PRAGMA_STATEMENT = /^[\s;]*PRAGMA\s+(?:[A-Za-z_][A-Za-z0-9_$]*\s*\.\s*)?([A-Za-z_][A-Za-z0-9_$]*)(?:\s*=\s*([\s\S]+?)|\s*\(\s*([\s\S]*?)\s*\))?\s*;?\s*$/i;
1132
1244
  /**
1133
- * ← `ALLOWED_PRAGMAS` and `PragmaSignature` (`util/sqlite.c++:525-563`),
1245
+ * ← `ALLOWED_PRAGMAS` (`util/sqlite.c++:543-571`) and `PragmaSignature` (`:528-535`),
1134
1246
  * verbatim. `table_list`, `table_info`, and `table_xinfo` are special-cased
1135
1247
  * ahead of the table in the authorizer, exactly as upstream's `SQLITE_PRAGMA`
1136
1248
  * case does (`util/sqlite.c++:1194-1273`).
1137
1249
  */
1138
1250
  var ALLOWED_PRAGMAS = /* @__PURE__ */ new Map([
1139
1251
  ["data_version", "NO_ARG"],
1252
+ ["page_size", "NO_ARG"],
1140
1253
  ["case_sensitive_like", "BOOLEAN"],
1141
1254
  ["foreign_keys", "BOOLEAN"],
1142
1255
  ["defer_foreign_keys", "BOOLEAN"],
@@ -1228,7 +1341,11 @@ var SQLITE_PRAGMA_NAMES = /* @__PURE__ */ new Set([
1228
1341
  ]);
1229
1342
  /** kj's `tryParseAs` is decimal; keep the same acceptance. */
1230
1343
  var DECIMAL = /^[+-]?\d+$/;
1231
- /** One layer of SQL quoting off a pragma argument, any of the four forms. */
1344
+ /**
1345
+ * One layer of SQL quoting, any of the four forms — a pragma argument, or the module token
1346
+ * `VIRTUAL_TABLE_MODULE` captured. Doubled inner quotes stay doubled, which cannot change a
1347
+ * verdict here: no allowlisted comparison target contains a quote character.
1348
+ */
1232
1349
  function unquoted(argument) {
1233
1350
  const first = argument[0];
1234
1351
  const last = argument[argument.length - 1];
@@ -1282,7 +1399,7 @@ function requireAllowedPragmas(statement) {
1282
1399
  if (!isAllowedPragma(name, argument === "" ? void 0 : argument)) throw new Error(SQL_NOT_AUTHORIZED_MESSAGE);
1283
1400
  return;
1284
1401
  }
1285
- if (/^\s*PRAGMA\b/i.test(code)) throw new Error(SQL_NOT_AUTHORIZED_MESSAGE);
1402
+ if (/^[\s;]*PRAGMA\b/i.test(code)) throw new Error(SQL_NOT_AUTHORIZED_MESSAGE);
1286
1403
  const literalFree = code.replace(NOT_CODE, " ");
1287
1404
  for (const [token] of literalFree.matchAll(IDENTIFIER)) {
1288
1405
  if (token.length <= 7 || token.slice(0, 7).toLowerCase() !== "pragma_") continue;
@@ -1294,6 +1411,7 @@ function requireAllowedPragmas(statement) {
1294
1411
  /** Everything the untrusted path refuses at one statement boundary. */
1295
1412
  function regulateUntrustedStatement(statement) {
1296
1413
  refuseTransactionControl(statement);
1414
+ refuseUnauthorizedForms(statement);
1297
1415
  requireAllowedPragmas(statement);
1298
1416
  }
1299
1417
  /** ← `JSG_INHERIT_INTRINSIC(v8::kIteratorPrototype)` (`jsg/iterator.h:1044`). */
@@ -1686,6 +1804,26 @@ var OP_PUT_ALARM = "setAlarm()";
1686
1804
  var OP_DELETE = "delete()";
1687
1805
  var OP_DELETE_ALARM = "deleteAlarm()";
1688
1806
  var OP_ROLLBACK = "rollback()";
1807
+ /** ← `actor-state.c++:455`, verbatim: the one message both overloads' misuse produces. */
1808
+ var PUT_OVERLOAD_MESSAGE = "put() may only be called with a single key-value pair and optional options as put(key, value, options) or with multiple key-value pairs and optional options as put(entries, options)";
1809
+ /**
1810
+ * ← the `kj::OneOf<kj::String, jsg::Dict<…>>` unwrap on put()'s first parameter: `jsg::Dict`
1811
+ * takes any JS object except an Array — functions and Maps included — and `kj::String` takes
1812
+ * everything else by coercion. A type predicate, so the overload split narrows without a cast.
1813
+ */
1814
+ function isEntriesArgument(value) {
1815
+ return (typeof value === "object" || typeof value === "function") && value !== null && !Array.isArray(value);
1816
+ }
1817
+ /**
1818
+ * ← the struct wrapper (`jsg/struct.h:246-258`), which is NOT the Dict wrapper: `PutOptions` is
1819
+ * all-optional fields, so `null` unwraps to default options, and any object does — arrays and
1820
+ * functions included, because the wrapper checks `IsObject()` with no Array exclusion. Only a
1821
+ * non-null primitive fails to unwrap. Measured on real workerd: `put({k: 1}, null)`, `…, [])`
1822
+ * and `…, function () {})` all write, and `put({k: 1}, "v")` alone is the overload error.
1823
+ */
1824
+ function isPutOptions(value) {
1825
+ return value === null || typeof value === "object" || typeof value === "function";
1826
+ }
1689
1827
  /** The key immediately after `k` in byte order is `k` plus this. */
1690
1828
  var NULL_CHARACTER = "\0";
1691
1829
  /** ← the `0xff` upstream strips from the tail of a prefix, in UTF-16 code units. */
@@ -1889,10 +2027,11 @@ var DurableObjectStorageOperations = class {
1889
2027
  }
1890
2028
  put(keyOrEntries, valueOrOptions, maybeOptions) {
1891
2029
  requireInputLock(this.ctx, OP_PUT);
1892
- if (typeof keyOrEntries === "string") {
2030
+ if (!isEntriesArgument(keyOrEntries)) {
1893
2031
  if (valueOrOptions === void 0) throw new TypeError("put() called with undefined value.");
1894
- return this.#putOne(keyOrEntries, valueOrOptions, { ...maybeOptions });
2032
+ return this.#putOne(`${keyOrEntries}`, valueOrOptions, { ...maybeOptions });
1895
2033
  }
2034
+ if (valueOrOptions !== void 0 && !isPutOptions(valueOrOptions)) throw new TypeError(PUT_OVERLOAD_MESSAGE);
1896
2035
  return this.#putMultiple(keyOrEntries, { ...valueOrOptions });
1897
2036
  }
1898
2037
  delete(keyOrKeys, maybeOptions) {