@tinycloud/sdk-services 2.3.0 → 2.4.0-beta.10

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/dist/index.cjs CHANGED
@@ -52,6 +52,7 @@ __export(index_exports, {
52
52
  RetryPolicySchema: () => RetryPolicySchema,
53
53
  SECRET_NAME_RE: () => SECRET_NAME_RE,
54
54
  SQLAction: () => SQLAction,
55
+ SQLMigrations: () => SQLMigrations,
55
56
  SQLService: () => SQLService,
56
57
  SecretsService: () => SecretsService,
57
58
  ServiceContext: () => ServiceContext,
@@ -1113,6 +1114,31 @@ var KVService = class extends BaseService {
1113
1114
  }
1114
1115
  return void 0;
1115
1116
  }
1117
+ /**
1118
+ * Classify a KV 404 by reading the response body once.
1119
+ *
1120
+ * The server returns 404 both for a genuinely missing key AND for an
1121
+ * un-hosted space (body "Space not found"). Previously get/head/delete
1122
+ * collapsed every 404 to KV_NOT_FOUND before reading the body, so an
1123
+ * un-hosted-space read was indistinguishable from a missing key. We now
1124
+ * preserve status + the "Space not found" body for the un-hosted case (so the
1125
+ * CLI/SDK can normalize it to SPACE_NOT_HOSTED, matching put/list/sql), and
1126
+ * fall through to KV_NOT_FOUND for a real missing key.
1127
+ */
1128
+ async classifyNotFound(response, key) {
1129
+ const errorText = await response.text();
1130
+ if (/space not found/i.test(errorText)) {
1131
+ return err(
1132
+ serviceError(
1133
+ ErrorCodes.KV_NOT_FOUND,
1134
+ `KV ${response.status} - ${errorText}`,
1135
+ "kv",
1136
+ { meta: { status: response.status, statusText: response.statusText } }
1137
+ )
1138
+ );
1139
+ }
1140
+ return err(serviceError(ErrorCodes.KV_NOT_FOUND, `Key not found: ${key}`, "kv"));
1141
+ }
1116
1142
  /**
1117
1143
  * Get the full path with optional prefix.
1118
1144
  *
@@ -1163,6 +1189,38 @@ var KVService = class extends BaseService {
1163
1189
  signal: this.combineSignals(signal)
1164
1190
  });
1165
1191
  }
1192
+ /**
1193
+ * Serialize a single put value into a fetch body.
1194
+ *
1195
+ * Binary values (Blob/ArrayBuffer/typed-array, incl. Node Buffer) are sent as
1196
+ * raw bytes (as a Blob) so they round-trip byte-identically — without this a
1197
+ * Buffer would be JSON.stringify'd into `{"type":"Buffer","data":[...]}`.
1198
+ * Strings are returned unchanged (preserving prior behavior); other values are
1199
+ * JSON-encoded. `contentType` overrides the inferred type for binary values.
1200
+ */
1201
+ serializePutValue(value, contentType) {
1202
+ if (value instanceof Blob) {
1203
+ if (!contentType || value.type === contentType) {
1204
+ return value;
1205
+ }
1206
+ return new Blob([value], { type: contentType });
1207
+ }
1208
+ if (value instanceof ArrayBuffer) {
1209
+ return new Blob([value], {
1210
+ type: contentType ?? "application/octet-stream"
1211
+ });
1212
+ }
1213
+ if (ArrayBuffer.isView(value)) {
1214
+ const view = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
1215
+ return new Blob([view], {
1216
+ type: contentType ?? "application/octet-stream"
1217
+ });
1218
+ }
1219
+ if (typeof value === "string") {
1220
+ return contentType ? new Blob([value], { type: contentType }) : value;
1221
+ }
1222
+ return JSON.stringify(value);
1223
+ }
1166
1224
  serializeBatchPutValue(item) {
1167
1225
  const contentType = item.contentType;
1168
1226
  if (item.value instanceof Blob) {
@@ -1232,10 +1290,13 @@ var KVService = class extends BaseService {
1232
1290
  * @param raw - Whether to return raw text
1233
1291
  * @returns Parsed data
1234
1292
  */
1235
- async parseResponse(response, raw = false) {
1293
+ async parseResponse(response, raw = false, binary = false) {
1236
1294
  if (!response.ok) {
1237
1295
  return void 0;
1238
1296
  }
1297
+ if (binary) {
1298
+ return new Uint8Array(await response.arrayBuffer());
1299
+ }
1239
1300
  if (raw) {
1240
1301
  return await response.text();
1241
1302
  }
@@ -1324,13 +1385,7 @@ var KVService = class extends BaseService {
1324
1385
  }));
1325
1386
  }
1326
1387
  if (response.status === 404) {
1327
- return err(
1328
- serviceError(
1329
- ErrorCodes.KV_NOT_FOUND,
1330
- `Key not found: ${key}`,
1331
- "kv"
1332
- )
1333
- );
1388
+ return this.classifyNotFound(response, key);
1334
1389
  }
1335
1390
  const errorText = await response.text();
1336
1391
  return err(
@@ -1342,7 +1397,11 @@ var KVService = class extends BaseService {
1342
1397
  )
1343
1398
  );
1344
1399
  }
1345
- const data = await this.parseResponse(response, options?.raw);
1400
+ const data = await this.parseResponse(
1401
+ response,
1402
+ options?.raw,
1403
+ options?.binary
1404
+ );
1346
1405
  return ok({
1347
1406
  data,
1348
1407
  headers: this.createResponseHeaders(response.headers)
@@ -1361,12 +1420,7 @@ var KVService = class extends BaseService {
1361
1420
  return err(authRequiredError("kv"));
1362
1421
  }
1363
1422
  const path = this.getFullPath(key, options?.prefix);
1364
- let body;
1365
- if (typeof value === "string") {
1366
- body = value;
1367
- } else {
1368
- body = JSON.stringify(value);
1369
- }
1423
+ const body = this.serializePutValue(value, options?.contentType);
1370
1424
  try {
1371
1425
  const response = await this.invokeOperation(
1372
1426
  path,
@@ -1592,13 +1646,7 @@ var KVService = class extends BaseService {
1592
1646
  }));
1593
1647
  }
1594
1648
  if (response.status === 404) {
1595
- return err(
1596
- serviceError(
1597
- ErrorCodes.KV_NOT_FOUND,
1598
- `Key not found: ${key}`,
1599
- "kv"
1600
- )
1601
- );
1649
+ return this.classifyNotFound(response, key);
1602
1650
  }
1603
1651
  const errorText = await response.text();
1604
1652
  return err(
@@ -1643,13 +1691,7 @@ var KVService = class extends BaseService {
1643
1691
  }));
1644
1692
  }
1645
1693
  if (response.status === 404) {
1646
- return err(
1647
- serviceError(
1648
- ErrorCodes.KV_NOT_FOUND,
1649
- `Key not found: ${key}`,
1650
- "kv"
1651
- )
1652
- );
1694
+ return this.classifyNotFound(response, key);
1653
1695
  }
1654
1696
  const errorText = await response.text();
1655
1697
  return err(
@@ -1776,11 +1818,59 @@ var KVService = class extends BaseService {
1776
1818
  */
1777
1819
  KVService.serviceName = "kv";
1778
1820
 
1821
+ // src/responseErrors.ts
1822
+ function parseServiceErrorBody(errorText) {
1823
+ try {
1824
+ return JSON.parse(errorText);
1825
+ } catch {
1826
+ return {};
1827
+ }
1828
+ }
1829
+ function formatServiceResponseError(serviceLabel, operation, status, errorText, parsed) {
1830
+ if (parsed.message) {
1831
+ return compactMessage(parsed.message);
1832
+ }
1833
+ if (looksLikeHtml(errorText)) {
1834
+ if (status === 524 || /524\s*[:-]/i.test(errorText) || /a timeout occurred/i.test(errorText)) {
1835
+ return `${serviceLabel} ${operation} failed: upstream request timed out. Please retry.`;
1836
+ }
1837
+ return `${serviceLabel} ${operation} failed: upstream service returned an HTML error page (${status}).`;
1838
+ }
1839
+ return `${serviceLabel} ${operation} failed: ${status} - ${compactMessage(errorText)}`;
1840
+ }
1841
+ function responseErrorMeta(status, statusText, errorText) {
1842
+ const meta = { status, statusText };
1843
+ const snippet = compactMessage(errorText);
1844
+ if (snippet) {
1845
+ meta.responseSnippet = snippet.slice(0, 300);
1846
+ }
1847
+ return meta;
1848
+ }
1849
+ function looksLikeHtml(text) {
1850
+ return /<!doctype html/i.test(text) || /<html[\s>]/i.test(text);
1851
+ }
1852
+ function compactMessage(message) {
1853
+ const compact = message.replace(/\s+/g, " ").trim();
1854
+ return compact.length > 500 ? `${compact.slice(0, 500)}...` : compact;
1855
+ }
1856
+
1857
+ // src/sql/SQLMigrations.ts
1858
+ var SQLMigrations = class {
1859
+ constructor(service, dbName) {
1860
+ this.service = service;
1861
+ this.dbName = dbName;
1862
+ }
1863
+ apply(options) {
1864
+ return this.service.applyMigrationsOnDb(this.dbName, options);
1865
+ }
1866
+ };
1867
+
1779
1868
  // src/sql/DatabaseHandle.ts
1780
1869
  var DatabaseHandle = class {
1781
1870
  constructor(service, name) {
1782
1871
  this.service = service;
1783
1872
  this.name = name;
1873
+ this.migrations = new SQLMigrations(service, name);
1784
1874
  }
1785
1875
  async query(sql, params, options) {
1786
1876
  return this.service.queryOnDb(this.name, sql, params, options);
@@ -1803,6 +1893,7 @@ var DatabaseHandle = class {
1803
1893
  var SQLAction = {
1804
1894
  READ: "tinycloud.sql/read",
1805
1895
  WRITE: "tinycloud.sql/write",
1896
+ DDL: "tinycloud.sql/ddl",
1806
1897
  ADMIN: "tinycloud.sql/admin",
1807
1898
  SELECT: "tinycloud.sql/select",
1808
1899
  INSERT: "tinycloud.sql/insert",
@@ -1814,9 +1905,21 @@ var SQLAction = {
1814
1905
  };
1815
1906
 
1816
1907
  // src/sql/SQLService.ts
1908
+ var DDL_TOKENS = /* @__PURE__ */ new Set(["alter", "create", "drop"]);
1909
+ var MIGRATIONS_TABLE = "__tinycloud_sql_migrations";
1910
+ var MIGRATIONS_SCHEMA = `CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
1911
+ key TEXT PRIMARY KEY,
1912
+ namespace TEXT NOT NULL,
1913
+ id TEXT NOT NULL,
1914
+ applied_at TEXT NOT NULL,
1915
+ statement_count INTEGER NOT NULL
1916
+ )`;
1917
+ var MIGRATIONS_META_NAMESPACE = "tinycloud.sql.migrations";
1918
+ var MIGRATIONS_META_ID = "000_metadata";
1817
1919
  var SQLService = class extends BaseService {
1818
1920
  constructor(config = {}) {
1819
1921
  super();
1922
+ this.migrationLocks = /* @__PURE__ */ new Map();
1820
1923
  this._config = config;
1821
1924
  }
1822
1925
  get config() {
@@ -1852,6 +1955,24 @@ var SQLService = class extends BaseService {
1852
1955
  async batch(statements, options) {
1853
1956
  return this.batchOnDb(this.defaultDbName, statements, options);
1854
1957
  }
1958
+ async applyMigrationsOnDb(dbName, options) {
1959
+ const validationError = validateMigrationOptions(options);
1960
+ if (validationError) {
1961
+ return err(serviceError(ErrorCodes.INVALID_INPUT, validationError, "sql"));
1962
+ }
1963
+ const lockKey = `${dbName}\0${options.namespace}`;
1964
+ const existing = this.migrationLocks.get(lockKey);
1965
+ if (existing) {
1966
+ return existing;
1967
+ }
1968
+ const promise = this.applyMigrationsOnDbUnlocked(dbName, options);
1969
+ this.migrationLocks.set(lockKey, promise);
1970
+ try {
1971
+ return await promise;
1972
+ } finally {
1973
+ this.migrationLocks.delete(lockKey);
1974
+ }
1975
+ }
1855
1976
  // === Internal methods called by DatabaseHandle ===
1856
1977
  async queryOnDb(dbName, sql, params, options) {
1857
1978
  return this.withTelemetry("query", dbName, async () => {
@@ -1889,9 +2010,15 @@ var SQLService = class extends BaseService {
1889
2010
  if (options?.schema) {
1890
2011
  body.schema = options.schema;
1891
2012
  }
2013
+ const actions = [
2014
+ this.actionForSql(sql, SQLAction.WRITE),
2015
+ ...(options?.schema ?? []).map(
2016
+ (statement) => this.actionForSql(statement, SQLAction.DDL)
2017
+ )
2018
+ ];
1892
2019
  const response = await this.invokeSQL(
1893
2020
  dbName,
1894
- this.actionForSql(sql, SQLAction.WRITE),
2021
+ this.dedupeActions(actions),
1895
2022
  body,
1896
2023
  options?.signal
1897
2024
  );
@@ -1913,7 +2040,7 @@ var SQLService = class extends BaseService {
1913
2040
  try {
1914
2041
  const response = await this.invokeSQL(
1915
2042
  dbName,
1916
- this.actionForSqlBatch(statements),
2043
+ this.actionsForSqlBatch(statements),
1917
2044
  { action: "batch", statements },
1918
2045
  options?.signal
1919
2046
  );
@@ -1976,10 +2103,83 @@ var SQLService = class extends BaseService {
1976
2103
  }
1977
2104
  });
1978
2105
  }
2106
+ async applyMigrationsOnDbUnlocked(dbName, options) {
2107
+ return this.withTelemetry("migrations.apply", dbName, async () => {
2108
+ const created = await this.ensureMigrationsTable(dbName, options.signal);
2109
+ if (!created.ok) return created;
2110
+ const listed = await this.queryOnDb(
2111
+ dbName,
2112
+ `SELECT id FROM ${MIGRATIONS_TABLE} WHERE namespace = ? ORDER BY applied_at, id`,
2113
+ [options.namespace],
2114
+ { signal: options.signal }
2115
+ );
2116
+ if (!listed.ok) return listed;
2117
+ const appliedIds = new Set(
2118
+ listed.data.rows.map((row) => rowValue(row, 0)).filter((id) => typeof id === "string")
2119
+ );
2120
+ const skipped = options.migrations.filter((migration) => appliedIds.has(migration.id)).map((migration) => migration.id);
2121
+ const pending = options.migrations.filter((migration) => !appliedIds.has(migration.id));
2122
+ const applied = [];
2123
+ for (const migration of pending) {
2124
+ const result = await this.applyOneMigration(dbName, options.namespace, migration, options.signal);
2125
+ if (!result.ok) return result;
2126
+ applied.push(migration.id);
2127
+ }
2128
+ return ok({
2129
+ database: dbName,
2130
+ namespace: options.namespace,
2131
+ status: applied.length > 0 ? "applied" : "already_current",
2132
+ applied,
2133
+ skipped
2134
+ });
2135
+ });
2136
+ }
2137
+ async applyOneMigration(dbName, namespace, migration, signal) {
2138
+ const statements = [
2139
+ ...migration.sql.map(
2140
+ (statement) => typeof statement === "string" ? { sql: statement } : statement
2141
+ ),
2142
+ {
2143
+ sql: `INSERT OR REPLACE INTO ${MIGRATIONS_TABLE} (key, namespace, id, applied_at, statement_count) VALUES (?, ?, ?, ?, ?)`,
2144
+ params: [
2145
+ migrationKey(namespace, migration.id),
2146
+ namespace,
2147
+ migration.id,
2148
+ (/* @__PURE__ */ new Date()).toISOString(),
2149
+ migration.sql.length
2150
+ ]
2151
+ }
2152
+ ];
2153
+ const result = await this.batchOnDb(dbName, statements, { signal });
2154
+ if (!result.ok) return result;
2155
+ return ok(void 0);
2156
+ }
2157
+ async ensureMigrationsTable(dbName, signal) {
2158
+ const result = await this.batchOnDb(
2159
+ dbName,
2160
+ [
2161
+ { sql: MIGRATIONS_SCHEMA },
2162
+ {
2163
+ sql: `INSERT OR REPLACE INTO ${MIGRATIONS_TABLE} (key, namespace, id, applied_at, statement_count) VALUES (?, ?, ?, ?, ?)`,
2164
+ params: [
2165
+ migrationKey(MIGRATIONS_META_NAMESPACE, MIGRATIONS_META_ID),
2166
+ MIGRATIONS_META_NAMESPACE,
2167
+ MIGRATIONS_META_ID,
2168
+ (/* @__PURE__ */ new Date()).toISOString(),
2169
+ 1
2170
+ ]
2171
+ }
2172
+ ],
2173
+ { signal }
2174
+ );
2175
+ if (!result.ok) return result;
2176
+ return ok(void 0);
2177
+ }
1979
2178
  // === Private helpers ===
1980
- async invokeSQL(dbName, action, body, signal) {
2179
+ async invokeSQL(dbName, actions, body, signal) {
1981
2180
  const session = this.context.session;
1982
- const headers = this.context.invoke(session, "sql", dbName, action);
2181
+ const actionList = Array.isArray(actions) ? actions : [actions];
2182
+ const headers = actionList.length === 1 ? this.context.invoke(session, "sql", dbName, actionList[0]) : this.invokeSQLAny(session, dbName, actionList);
1983
2183
  return this.context.fetch(`${this.host}/invoke`, {
1984
2184
  method: "POST",
1985
2185
  headers: {
@@ -1991,26 +2191,50 @@ var SQLService = class extends BaseService {
1991
2191
  });
1992
2192
  }
1993
2193
  actionForSql(sql, fallback) {
1994
- return firstSqlToken(sql) === "pragma" ? SQLAction.ADMIN : fallback;
2194
+ const token = firstSqlToken(sql);
2195
+ if (token === "pragma") return SQLAction.ADMIN;
2196
+ if (token !== void 0 && DDL_TOKENS.has(token)) return SQLAction.DDL;
2197
+ return fallback;
2198
+ }
2199
+ actionsForSqlBatch(statements) {
2200
+ return this.dedupeActions(
2201
+ statements.map((statement) => this.actionForSql(statement.sql, SQLAction.WRITE))
2202
+ );
2203
+ }
2204
+ dedupeActions(actions) {
2205
+ return [...new Set(actions)];
1995
2206
  }
1996
- actionForSqlBatch(statements) {
1997
- return statements.some(
1998
- (statement) => this.actionForSql(statement.sql, SQLAction.WRITE) === SQLAction.ADMIN
1999
- ) ? SQLAction.ADMIN : SQLAction.WRITE;
2207
+ invokeSQLAny(session, dbName, actions) {
2208
+ if (!this.context.invokeAny) {
2209
+ throw new Error(
2210
+ `SQL operation requires multiple permissions (${actions.join(", ")}) but this SDK runtime does not support multi-resource invocations`
2211
+ );
2212
+ }
2213
+ return this.context.invokeAny(
2214
+ session,
2215
+ actions.map((action) => ({
2216
+ spaceId: session.spaceId,
2217
+ service: "sql",
2218
+ path: dbName,
2219
+ action
2220
+ }))
2221
+ );
2000
2222
  }
2001
2223
  async handleErrorResponse(response, operation) {
2002
2224
  const errorText = await response.text();
2003
- let errorBody = {};
2004
- try {
2005
- errorBody = JSON.parse(errorText);
2006
- } catch {
2007
- }
2225
+ const errorBody = parseServiceErrorBody(errorText);
2008
2226
  const errorCode = this.mapHttpStatusToErrorCode(
2009
2227
  response.status,
2010
2228
  errorBody.error
2011
2229
  );
2012
- const message = errorBody.message || `SQL ${operation} failed: ${response.status} - ${errorText}`;
2013
- const meta = { status: response.status, statusText: response.statusText };
2230
+ const message = formatServiceResponseError(
2231
+ "SQL",
2232
+ operation,
2233
+ response.status,
2234
+ errorText,
2235
+ errorBody
2236
+ );
2237
+ const meta = responseErrorMeta(response.status, response.statusText, errorText);
2014
2238
  if (response.status === 401) {
2015
2239
  const { resource, action } = parseAuthError(errorText);
2016
2240
  if (action) meta.requiredAction = action;
@@ -2070,6 +2294,53 @@ function firstSqlToken(sql) {
2070
2294
  const match = /^[A-Za-z_]+/.exec(sql.slice(index));
2071
2295
  return match?.[0].toLowerCase();
2072
2296
  }
2297
+ function validateMigrationOptions(options) {
2298
+ if (!options || typeof options !== "object") {
2299
+ return "SQL migrations require options";
2300
+ }
2301
+ if (!options.namespace || typeof options.namespace !== "string") {
2302
+ return "SQL migrations require a non-empty namespace";
2303
+ }
2304
+ if (!Array.isArray(options.migrations)) {
2305
+ return "SQL migrations require an ordered migrations array";
2306
+ }
2307
+ const ids = /* @__PURE__ */ new Set();
2308
+ for (const migration of options.migrations) {
2309
+ if (!migration.id || typeof migration.id !== "string") {
2310
+ return "SQL migrations require every migration to have a non-empty id";
2311
+ }
2312
+ if (ids.has(migration.id)) {
2313
+ return `Duplicate SQL migration id: ${migration.id}`;
2314
+ }
2315
+ ids.add(migration.id);
2316
+ if (!Array.isArray(migration.sql)) {
2317
+ return `SQL migration ${migration.id} requires a sql array`;
2318
+ }
2319
+ for (const statement of migration.sql) {
2320
+ if (typeof statement === "string") {
2321
+ if (statement.trim() === "") {
2322
+ return `SQL migration ${migration.id} contains an empty statement`;
2323
+ }
2324
+ continue;
2325
+ }
2326
+ if (!statement || typeof statement.sql !== "string" || statement.sql.trim() === "") {
2327
+ return `SQL migration ${migration.id} contains an invalid statement`;
2328
+ }
2329
+ }
2330
+ }
2331
+ return null;
2332
+ }
2333
+ function migrationKey(namespace, id) {
2334
+ return `${encodeURIComponent(namespace)}:${encodeURIComponent(id)}`;
2335
+ }
2336
+ function rowValue(row, index) {
2337
+ if (Array.isArray(row)) return row[index];
2338
+ if (row && typeof row === "object") {
2339
+ const values = Object.values(row);
2340
+ return values[index];
2341
+ }
2342
+ return void 0;
2343
+ }
2073
2344
 
2074
2345
  // src/duckdb/DuckDbDatabaseHandle.ts
2075
2346
  var DuckDbDatabaseHandle = class {
@@ -2379,17 +2650,19 @@ var DuckDbService = class extends BaseService {
2379
2650
  }
2380
2651
  async handleErrorResponse(response, operation) {
2381
2652
  const errorText = await response.text();
2382
- let errorBody = {};
2383
- try {
2384
- errorBody = JSON.parse(errorText);
2385
- } catch {
2386
- }
2653
+ const errorBody = parseServiceErrorBody(errorText);
2387
2654
  const errorCode = this.mapHttpStatusToErrorCode(
2388
2655
  response.status,
2389
2656
  errorBody.error
2390
2657
  );
2391
- const message = errorBody.message || `DuckDB ${operation} failed: ${response.status} - ${errorText}`;
2392
- const meta = { status: response.status, statusText: response.statusText };
2658
+ const message = formatServiceResponseError(
2659
+ "DuckDB",
2660
+ operation,
2661
+ response.status,
2662
+ errorText,
2663
+ errorBody
2664
+ );
2665
+ const meta = responseErrorMeta(response.status, response.statusText, errorText);
2393
2666
  if (response.status === 401) {
2394
2667
  const { resource, action } = parseAuthError(errorText);
2395
2668
  if (action) meta.requiredAction = action;
@@ -5738,6 +6011,7 @@ EncryptionService.serviceName = "encryption";
5738
6011
  RetryPolicySchema,
5739
6012
  SECRET_NAME_RE,
5740
6013
  SQLAction,
6014
+ SQLMigrations,
5741
6015
  SQLService,
5742
6016
  SecretsService,
5743
6017
  ServiceContext,