@tinycloud/sdk-services 2.4.0-beta.1 → 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
  *
@@ -1359,13 +1385,7 @@ var KVService = class extends BaseService {
1359
1385
  }));
1360
1386
  }
1361
1387
  if (response.status === 404) {
1362
- return err(
1363
- serviceError(
1364
- ErrorCodes.KV_NOT_FOUND,
1365
- `Key not found: ${key}`,
1366
- "kv"
1367
- )
1368
- );
1388
+ return this.classifyNotFound(response, key);
1369
1389
  }
1370
1390
  const errorText = await response.text();
1371
1391
  return err(
@@ -1626,13 +1646,7 @@ var KVService = class extends BaseService {
1626
1646
  }));
1627
1647
  }
1628
1648
  if (response.status === 404) {
1629
- return err(
1630
- serviceError(
1631
- ErrorCodes.KV_NOT_FOUND,
1632
- `Key not found: ${key}`,
1633
- "kv"
1634
- )
1635
- );
1649
+ return this.classifyNotFound(response, key);
1636
1650
  }
1637
1651
  const errorText = await response.text();
1638
1652
  return err(
@@ -1677,13 +1691,7 @@ var KVService = class extends BaseService {
1677
1691
  }));
1678
1692
  }
1679
1693
  if (response.status === 404) {
1680
- return err(
1681
- serviceError(
1682
- ErrorCodes.KV_NOT_FOUND,
1683
- `Key not found: ${key}`,
1684
- "kv"
1685
- )
1686
- );
1694
+ return this.classifyNotFound(response, key);
1687
1695
  }
1688
1696
  const errorText = await response.text();
1689
1697
  return err(
@@ -1810,11 +1818,59 @@ var KVService = class extends BaseService {
1810
1818
  */
1811
1819
  KVService.serviceName = "kv";
1812
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
+
1813
1868
  // src/sql/DatabaseHandle.ts
1814
1869
  var DatabaseHandle = class {
1815
1870
  constructor(service, name) {
1816
1871
  this.service = service;
1817
1872
  this.name = name;
1873
+ this.migrations = new SQLMigrations(service, name);
1818
1874
  }
1819
1875
  async query(sql, params, options) {
1820
1876
  return this.service.queryOnDb(this.name, sql, params, options);
@@ -1837,6 +1893,7 @@ var DatabaseHandle = class {
1837
1893
  var SQLAction = {
1838
1894
  READ: "tinycloud.sql/read",
1839
1895
  WRITE: "tinycloud.sql/write",
1896
+ DDL: "tinycloud.sql/ddl",
1840
1897
  ADMIN: "tinycloud.sql/admin",
1841
1898
  SELECT: "tinycloud.sql/select",
1842
1899
  INSERT: "tinycloud.sql/insert",
@@ -1848,9 +1905,21 @@ var SQLAction = {
1848
1905
  };
1849
1906
 
1850
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";
1851
1919
  var SQLService = class extends BaseService {
1852
1920
  constructor(config = {}) {
1853
1921
  super();
1922
+ this.migrationLocks = /* @__PURE__ */ new Map();
1854
1923
  this._config = config;
1855
1924
  }
1856
1925
  get config() {
@@ -1886,6 +1955,24 @@ var SQLService = class extends BaseService {
1886
1955
  async batch(statements, options) {
1887
1956
  return this.batchOnDb(this.defaultDbName, statements, options);
1888
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
+ }
1889
1976
  // === Internal methods called by DatabaseHandle ===
1890
1977
  async queryOnDb(dbName, sql, params, options) {
1891
1978
  return this.withTelemetry("query", dbName, async () => {
@@ -1923,9 +2010,15 @@ var SQLService = class extends BaseService {
1923
2010
  if (options?.schema) {
1924
2011
  body.schema = options.schema;
1925
2012
  }
2013
+ const actions = [
2014
+ this.actionForSql(sql, SQLAction.WRITE),
2015
+ ...(options?.schema ?? []).map(
2016
+ (statement) => this.actionForSql(statement, SQLAction.DDL)
2017
+ )
2018
+ ];
1926
2019
  const response = await this.invokeSQL(
1927
2020
  dbName,
1928
- this.actionForSql(sql, SQLAction.WRITE),
2021
+ this.dedupeActions(actions),
1929
2022
  body,
1930
2023
  options?.signal
1931
2024
  );
@@ -1947,7 +2040,7 @@ var SQLService = class extends BaseService {
1947
2040
  try {
1948
2041
  const response = await this.invokeSQL(
1949
2042
  dbName,
1950
- this.actionForSqlBatch(statements),
2043
+ this.actionsForSqlBatch(statements),
1951
2044
  { action: "batch", statements },
1952
2045
  options?.signal
1953
2046
  );
@@ -2010,10 +2103,83 @@ var SQLService = class extends BaseService {
2010
2103
  }
2011
2104
  });
2012
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
+ }
2013
2178
  // === Private helpers ===
2014
- async invokeSQL(dbName, action, body, signal) {
2179
+ async invokeSQL(dbName, actions, body, signal) {
2015
2180
  const session = this.context.session;
2016
- 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);
2017
2183
  return this.context.fetch(`${this.host}/invoke`, {
2018
2184
  method: "POST",
2019
2185
  headers: {
@@ -2025,26 +2191,50 @@ var SQLService = class extends BaseService {
2025
2191
  });
2026
2192
  }
2027
2193
  actionForSql(sql, fallback) {
2028
- 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)];
2029
2206
  }
2030
- actionForSqlBatch(statements) {
2031
- return statements.some(
2032
- (statement) => this.actionForSql(statement.sql, SQLAction.WRITE) === SQLAction.ADMIN
2033
- ) ? 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
+ );
2034
2222
  }
2035
2223
  async handleErrorResponse(response, operation) {
2036
2224
  const errorText = await response.text();
2037
- let errorBody = {};
2038
- try {
2039
- errorBody = JSON.parse(errorText);
2040
- } catch {
2041
- }
2225
+ const errorBody = parseServiceErrorBody(errorText);
2042
2226
  const errorCode = this.mapHttpStatusToErrorCode(
2043
2227
  response.status,
2044
2228
  errorBody.error
2045
2229
  );
2046
- const message = errorBody.message || `SQL ${operation} failed: ${response.status} - ${errorText}`;
2047
- 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);
2048
2238
  if (response.status === 401) {
2049
2239
  const { resource, action } = parseAuthError(errorText);
2050
2240
  if (action) meta.requiredAction = action;
@@ -2104,6 +2294,53 @@ function firstSqlToken(sql) {
2104
2294
  const match = /^[A-Za-z_]+/.exec(sql.slice(index));
2105
2295
  return match?.[0].toLowerCase();
2106
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
+ }
2107
2344
 
2108
2345
  // src/duckdb/DuckDbDatabaseHandle.ts
2109
2346
  var DuckDbDatabaseHandle = class {
@@ -2413,17 +2650,19 @@ var DuckDbService = class extends BaseService {
2413
2650
  }
2414
2651
  async handleErrorResponse(response, operation) {
2415
2652
  const errorText = await response.text();
2416
- let errorBody = {};
2417
- try {
2418
- errorBody = JSON.parse(errorText);
2419
- } catch {
2420
- }
2653
+ const errorBody = parseServiceErrorBody(errorText);
2421
2654
  const errorCode = this.mapHttpStatusToErrorCode(
2422
2655
  response.status,
2423
2656
  errorBody.error
2424
2657
  );
2425
- const message = errorBody.message || `DuckDB ${operation} failed: ${response.status} - ${errorText}`;
2426
- 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);
2427
2666
  if (response.status === 401) {
2428
2667
  const { resource, action } = parseAuthError(errorText);
2429
2668
  if (action) meta.requiredAction = action;
@@ -5772,6 +6011,7 @@ EncryptionService.serviceName = "encryption";
5772
6011
  RetryPolicySchema,
5773
6012
  SECRET_NAME_RE,
5774
6013
  SQLAction,
6014
+ SQLMigrations,
5775
6015
  SQLService,
5776
6016
  SecretsService,
5777
6017
  ServiceContext,