@blinkdotnew/sdk 0.13.2 → 0.14.1

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/README.md CHANGED
@@ -195,23 +195,31 @@ const unsubscribe = blink.auth.onAuthStateChanged((state) => {
195
195
 
196
196
  ### Database Operations
197
197
 
198
+ **🎉 NEW: Automatic Case Conversion!**
199
+ The SDK now automatically converts between JavaScript camelCase and SQL snake_case:
200
+ - **Write code in camelCase**: `userId`, `createdAt`, `isCompleted`
201
+ - **Stored as snake_case**: `user_id`, `created_at`, `is_completed`
202
+ - **No manual conversion needed!**
203
+
198
204
  ```typescript
199
205
  // Create (ID auto-generated if not provided)
200
206
  const todo = await blink.db.todos.create({
201
207
  id: 'todo_12345', // Optional - auto-generated if not provided
202
208
  title: 'Learn Blink SDK',
203
- user_id: user.id
209
+ userId: user.id, // camelCase in code
210
+ createdAt: new Date(), // camelCase in code
211
+ isCompleted: false // camelCase in code
204
212
  })
205
213
 
206
- // Read with filtering - returns a plain array of records
214
+ // Read with filtering - returns camelCase fields
207
215
  const todos = await blink.db.todos.list({
208
216
  where: {
209
217
  AND: [
210
- { user_id: user.id },
218
+ { userId: user.id }, // camelCase in filters
211
219
  { OR: [{ status: 'open' }, { priority: 'high' }] }
212
220
  ]
213
221
  },
214
- orderBy: { created_at: 'desc' },
222
+ orderBy: { createdAt: 'desc' }, // camelCase in orderBy
215
223
  limit: 20
216
224
  })
217
225
  // `todos` is a direct array: Todo[]
package/dist/index.js CHANGED
@@ -67,6 +67,28 @@ var BlinkNotificationsError = class extends BlinkError {
67
67
  };
68
68
 
69
69
  // ../core/src/query-builder.ts
70
+ function camelToSnake(str) {
71
+ return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
72
+ }
73
+ function convertFilterKeysToSnakeCase(condition) {
74
+ if (!condition) return condition;
75
+ if ("AND" in condition) {
76
+ return {
77
+ AND: condition.AND?.map(convertFilterKeysToSnakeCase)
78
+ };
79
+ }
80
+ if ("OR" in condition) {
81
+ return {
82
+ OR: condition.OR?.map(convertFilterKeysToSnakeCase)
83
+ };
84
+ }
85
+ const converted = {};
86
+ for (const [field, value] of Object.entries(condition)) {
87
+ const snakeField = camelToSnake(field);
88
+ converted[snakeField] = value;
89
+ }
90
+ return converted;
91
+ }
70
92
  function buildFilterQuery(condition) {
71
93
  if (!condition) return "";
72
94
  if ("AND" in condition) {
@@ -140,12 +162,14 @@ function encodeQueryValue(value) {
140
162
  function buildQuery(options = {}) {
141
163
  const params = {};
142
164
  if (options.select && options.select.length > 0) {
143
- params.select = options.select.join(",");
165
+ const snakeFields = options.select.map(camelToSnake);
166
+ params.select = snakeFields.join(",");
144
167
  } else {
145
168
  params.select = "*";
146
169
  }
147
170
  if (options.where) {
148
- const filterQuery = buildFilterQuery(options.where);
171
+ const convertedWhere = convertFilterKeysToSnakeCase(options.where);
172
+ const filterQuery = buildFilterQuery(convertedWhere);
149
173
  if (filterQuery) {
150
174
  const filterParams = filterQuery.split("&");
151
175
  for (const param of filterParams) {
@@ -160,7 +184,7 @@ function buildQuery(options = {}) {
160
184
  if (typeof options.orderBy === "string") {
161
185
  params.order = options.orderBy;
162
186
  } else {
163
- const orderClauses = Object.entries(options.orderBy).map(([field, direction]) => `${field}.${direction}`);
187
+ const orderClauses = Object.entries(options.orderBy).map(([field, direction]) => `${camelToSnake(field)}.${direction}`);
164
188
  params.order = orderClauses.join(",");
165
189
  }
166
190
  }
@@ -177,6 +201,34 @@ function buildQuery(options = {}) {
177
201
  }
178
202
 
179
203
  // ../core/src/http-client.ts
204
+ function camelToSnake2(str) {
205
+ return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
206
+ }
207
+ function snakeToCamel(str) {
208
+ return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
209
+ }
210
+ function convertKeysToSnakeCase(obj) {
211
+ if (obj === null || obj === void 0) return obj;
212
+ if (typeof obj !== "object") return obj;
213
+ if (Array.isArray(obj)) return obj.map(convertKeysToSnakeCase);
214
+ const converted = {};
215
+ for (const [key, value] of Object.entries(obj)) {
216
+ const snakeKey = camelToSnake2(key);
217
+ converted[snakeKey] = convertKeysToSnakeCase(value);
218
+ }
219
+ return converted;
220
+ }
221
+ function convertKeysToCamelCase(obj) {
222
+ if (obj === null || obj === void 0) return obj;
223
+ if (typeof obj !== "object") return obj;
224
+ if (Array.isArray(obj)) return obj.map(convertKeysToCamelCase);
225
+ const converted = {};
226
+ for (const [key, value] of Object.entries(obj)) {
227
+ const camelKey = snakeToCamel(key);
228
+ converted[camelKey] = convertKeysToCamelCase(value);
229
+ }
230
+ return converted;
231
+ }
180
232
  var HttpClient = class {
181
233
  authUrl = "https://blink.new";
182
234
  coreUrl = "https://core.blink.new";
@@ -260,45 +312,86 @@ var HttpClient = class {
260
312
  */
261
313
  // Table operations (PostgREST-compatible)
262
314
  async dbGet(table, searchParams) {
263
- return this.get(`/api/db/${this.projectId}/rest/v1/${table}`, searchParams);
315
+ const response = await this.get(`/api/db/${this.projectId}/rest/v1/${table}`, searchParams);
316
+ const convertedData = convertKeysToCamelCase(response.data);
317
+ return {
318
+ ...response,
319
+ data: convertedData
320
+ };
264
321
  }
265
322
  async dbPost(table, body, options = {}) {
266
323
  const headers = {};
267
324
  if (options.returning) {
268
325
  headers.Prefer = "return=representation";
269
326
  }
270
- return this.post(`/api/db/${this.projectId}/rest/v1/${table}`, body, headers);
327
+ const convertedBody = convertKeysToSnakeCase(body);
328
+ const response = await this.post(`/api/db/${this.projectId}/rest/v1/${table}`, convertedBody, headers);
329
+ const convertedData = convertKeysToCamelCase(response.data);
330
+ return {
331
+ ...response,
332
+ data: convertedData
333
+ };
271
334
  }
272
335
  async dbPatch(table, body, searchParams, options = {}) {
273
336
  const headers = {};
274
337
  if (options.returning) {
275
338
  headers.Prefer = "return=representation";
276
339
  }
277
- return this.request(`/api/db/${this.projectId}/rest/v1/${table}`, {
340
+ const convertedBody = convertKeysToSnakeCase(body);
341
+ const response = await this.request(`/api/db/${this.projectId}/rest/v1/${table}`, {
278
342
  method: "PATCH",
279
- body,
343
+ body: convertedBody,
280
344
  headers,
281
345
  searchParams
282
346
  });
347
+ const convertedData = convertKeysToCamelCase(response.data);
348
+ return {
349
+ ...response,
350
+ data: convertedData
351
+ };
283
352
  }
284
353
  async dbDelete(table, searchParams, options = {}) {
285
354
  const headers = {};
286
355
  if (options.returning) {
287
356
  headers.Prefer = "return=representation";
288
357
  }
289
- return this.request(`/api/db/${this.projectId}/rest/v1/${table}`, {
358
+ const response = await this.request(`/api/db/${this.projectId}/rest/v1/${table}`, {
290
359
  method: "DELETE",
291
360
  headers,
292
361
  searchParams
293
362
  });
363
+ const convertedData = convertKeysToCamelCase(response.data);
364
+ return {
365
+ ...response,
366
+ data: convertedData
367
+ };
294
368
  }
295
369
  // Raw SQL operations
296
370
  async dbSql(query, params) {
297
- return this.post(`/api/db/${this.projectId}/sql`, { query, params });
371
+ const response = await this.post(`/api/db/${this.projectId}/sql`, { query, params });
372
+ const convertedData = {
373
+ ...response.data,
374
+ rows: convertKeysToCamelCase(response.data.rows)
375
+ };
376
+ return {
377
+ ...response,
378
+ data: convertedData
379
+ };
298
380
  }
299
381
  // Batch SQL operations
300
382
  async dbBatch(statements, mode = "write") {
301
- return this.post(`/api/db/${this.projectId}/batch`, { statements, mode });
383
+ const response = await this.post(`/api/db/${this.projectId}/batch`, { statements, mode });
384
+ const convertedData = {
385
+ ...response.data,
386
+ results: response.data.results.map((result) => ({
387
+ ...result,
388
+ rows: convertKeysToCamelCase(result.rows)
389
+ }))
390
+ };
391
+ return {
392
+ ...response,
393
+ data: convertedData
394
+ };
302
395
  }
303
396
  /**
304
397
  * Upload file with progress tracking
@@ -837,7 +930,17 @@ var BlinkAuth = class {
837
930
  * Redirect to Blink auth page
838
931
  */
839
932
  login(nextUrl) {
840
- const redirectUrl = nextUrl || (typeof window !== "undefined" ? window.location.href : "");
933
+ let redirectUrl = nextUrl || (typeof window !== "undefined" ? window.location.href : "");
934
+ if (redirectUrl && typeof window !== "undefined") {
935
+ try {
936
+ const url = new URL(redirectUrl);
937
+ url.searchParams.delete("redirect_url");
938
+ url.searchParams.delete("redirect");
939
+ redirectUrl = url.toString();
940
+ } catch (e) {
941
+ console.warn("Failed to parse redirect URL:", e);
942
+ }
943
+ }
841
944
  const authUrl = new URL("/auth", this.authUrl);
842
945
  authUrl.searchParams.set("redirect_url", redirectUrl);
843
946
  if (this.config.projectId) {
@@ -1164,8 +1267,15 @@ var BlinkAuth = class {
1164
1267
  issuedAt: tokensWithTimestamp.issued_at
1165
1268
  });
1166
1269
  if (persist && typeof window !== "undefined") {
1167
- localStorage.setItem("blink_tokens", JSON.stringify(tokensWithTimestamp));
1168
- console.log("\u{1F4BE} Tokens persisted to localStorage");
1270
+ try {
1271
+ localStorage.setItem("blink_tokens", JSON.stringify(tokensWithTimestamp));
1272
+ console.log("\u{1F4BE} Tokens persisted to localStorage");
1273
+ } catch (error) {
1274
+ console.log("\u{1F4A5} Error persisting tokens to localStorage:", error);
1275
+ if (error instanceof DOMException && error.name === "SecurityError") {
1276
+ console.log("\u{1F6AB} localStorage access blocked - running in cross-origin iframe");
1277
+ }
1278
+ }
1169
1279
  }
1170
1280
  let user = null;
1171
1281
  try {
@@ -1208,7 +1318,11 @@ var BlinkAuth = class {
1208
1318
  }
1209
1319
  clearTokens() {
1210
1320
  if (typeof window !== "undefined") {
1211
- localStorage.removeItem("blink_tokens");
1321
+ try {
1322
+ localStorage.removeItem("blink_tokens");
1323
+ } catch (error) {
1324
+ console.log("\u{1F4A5} Error clearing tokens from localStorage:", error);
1325
+ }
1212
1326
  }
1213
1327
  this.updateAuthState({
1214
1328
  user: null,
@@ -1223,7 +1337,9 @@ var BlinkAuth = class {
1223
1337
  const stored = localStorage.getItem("blink_tokens");
1224
1338
  console.log("\u{1F50D} Checking localStorage for tokens:", {
1225
1339
  hasStoredData: !!stored,
1226
- storedLength: stored?.length || 0
1340
+ storedLength: stored?.length || 0,
1341
+ origin: window.location.origin,
1342
+ isIframe: window.self !== window.top
1227
1343
  });
1228
1344
  if (stored) {
1229
1345
  const tokens = JSON.parse(stored);
@@ -1237,8 +1353,10 @@ var BlinkAuth = class {
1237
1353
  }
1238
1354
  return null;
1239
1355
  } catch (error) {
1240
- console.log("\u{1F4A5} Error parsing stored tokens:", error);
1241
- localStorage.removeItem("blink_tokens");
1356
+ console.log("\u{1F4A5} Error accessing localStorage:", error);
1357
+ if (error instanceof DOMException && error.name === "SecurityError") {
1358
+ console.log("\u{1F6AB} localStorage access blocked - likely due to cross-origin iframe restrictions");
1359
+ }
1242
1360
  return null;
1243
1361
  }
1244
1362
  }
package/dist/index.mjs CHANGED
@@ -65,6 +65,28 @@ var BlinkNotificationsError = class extends BlinkError {
65
65
  };
66
66
 
67
67
  // ../core/src/query-builder.ts
68
+ function camelToSnake(str) {
69
+ return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
70
+ }
71
+ function convertFilterKeysToSnakeCase(condition) {
72
+ if (!condition) return condition;
73
+ if ("AND" in condition) {
74
+ return {
75
+ AND: condition.AND?.map(convertFilterKeysToSnakeCase)
76
+ };
77
+ }
78
+ if ("OR" in condition) {
79
+ return {
80
+ OR: condition.OR?.map(convertFilterKeysToSnakeCase)
81
+ };
82
+ }
83
+ const converted = {};
84
+ for (const [field, value] of Object.entries(condition)) {
85
+ const snakeField = camelToSnake(field);
86
+ converted[snakeField] = value;
87
+ }
88
+ return converted;
89
+ }
68
90
  function buildFilterQuery(condition) {
69
91
  if (!condition) return "";
70
92
  if ("AND" in condition) {
@@ -138,12 +160,14 @@ function encodeQueryValue(value) {
138
160
  function buildQuery(options = {}) {
139
161
  const params = {};
140
162
  if (options.select && options.select.length > 0) {
141
- params.select = options.select.join(",");
163
+ const snakeFields = options.select.map(camelToSnake);
164
+ params.select = snakeFields.join(",");
142
165
  } else {
143
166
  params.select = "*";
144
167
  }
145
168
  if (options.where) {
146
- const filterQuery = buildFilterQuery(options.where);
169
+ const convertedWhere = convertFilterKeysToSnakeCase(options.where);
170
+ const filterQuery = buildFilterQuery(convertedWhere);
147
171
  if (filterQuery) {
148
172
  const filterParams = filterQuery.split("&");
149
173
  for (const param of filterParams) {
@@ -158,7 +182,7 @@ function buildQuery(options = {}) {
158
182
  if (typeof options.orderBy === "string") {
159
183
  params.order = options.orderBy;
160
184
  } else {
161
- const orderClauses = Object.entries(options.orderBy).map(([field, direction]) => `${field}.${direction}`);
185
+ const orderClauses = Object.entries(options.orderBy).map(([field, direction]) => `${camelToSnake(field)}.${direction}`);
162
186
  params.order = orderClauses.join(",");
163
187
  }
164
188
  }
@@ -175,6 +199,34 @@ function buildQuery(options = {}) {
175
199
  }
176
200
 
177
201
  // ../core/src/http-client.ts
202
+ function camelToSnake2(str) {
203
+ return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
204
+ }
205
+ function snakeToCamel(str) {
206
+ return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
207
+ }
208
+ function convertKeysToSnakeCase(obj) {
209
+ if (obj === null || obj === void 0) return obj;
210
+ if (typeof obj !== "object") return obj;
211
+ if (Array.isArray(obj)) return obj.map(convertKeysToSnakeCase);
212
+ const converted = {};
213
+ for (const [key, value] of Object.entries(obj)) {
214
+ const snakeKey = camelToSnake2(key);
215
+ converted[snakeKey] = convertKeysToSnakeCase(value);
216
+ }
217
+ return converted;
218
+ }
219
+ function convertKeysToCamelCase(obj) {
220
+ if (obj === null || obj === void 0) return obj;
221
+ if (typeof obj !== "object") return obj;
222
+ if (Array.isArray(obj)) return obj.map(convertKeysToCamelCase);
223
+ const converted = {};
224
+ for (const [key, value] of Object.entries(obj)) {
225
+ const camelKey = snakeToCamel(key);
226
+ converted[camelKey] = convertKeysToCamelCase(value);
227
+ }
228
+ return converted;
229
+ }
178
230
  var HttpClient = class {
179
231
  authUrl = "https://blink.new";
180
232
  coreUrl = "https://core.blink.new";
@@ -258,45 +310,86 @@ var HttpClient = class {
258
310
  */
259
311
  // Table operations (PostgREST-compatible)
260
312
  async dbGet(table, searchParams) {
261
- return this.get(`/api/db/${this.projectId}/rest/v1/${table}`, searchParams);
313
+ const response = await this.get(`/api/db/${this.projectId}/rest/v1/${table}`, searchParams);
314
+ const convertedData = convertKeysToCamelCase(response.data);
315
+ return {
316
+ ...response,
317
+ data: convertedData
318
+ };
262
319
  }
263
320
  async dbPost(table, body, options = {}) {
264
321
  const headers = {};
265
322
  if (options.returning) {
266
323
  headers.Prefer = "return=representation";
267
324
  }
268
- return this.post(`/api/db/${this.projectId}/rest/v1/${table}`, body, headers);
325
+ const convertedBody = convertKeysToSnakeCase(body);
326
+ const response = await this.post(`/api/db/${this.projectId}/rest/v1/${table}`, convertedBody, headers);
327
+ const convertedData = convertKeysToCamelCase(response.data);
328
+ return {
329
+ ...response,
330
+ data: convertedData
331
+ };
269
332
  }
270
333
  async dbPatch(table, body, searchParams, options = {}) {
271
334
  const headers = {};
272
335
  if (options.returning) {
273
336
  headers.Prefer = "return=representation";
274
337
  }
275
- return this.request(`/api/db/${this.projectId}/rest/v1/${table}`, {
338
+ const convertedBody = convertKeysToSnakeCase(body);
339
+ const response = await this.request(`/api/db/${this.projectId}/rest/v1/${table}`, {
276
340
  method: "PATCH",
277
- body,
341
+ body: convertedBody,
278
342
  headers,
279
343
  searchParams
280
344
  });
345
+ const convertedData = convertKeysToCamelCase(response.data);
346
+ return {
347
+ ...response,
348
+ data: convertedData
349
+ };
281
350
  }
282
351
  async dbDelete(table, searchParams, options = {}) {
283
352
  const headers = {};
284
353
  if (options.returning) {
285
354
  headers.Prefer = "return=representation";
286
355
  }
287
- return this.request(`/api/db/${this.projectId}/rest/v1/${table}`, {
356
+ const response = await this.request(`/api/db/${this.projectId}/rest/v1/${table}`, {
288
357
  method: "DELETE",
289
358
  headers,
290
359
  searchParams
291
360
  });
361
+ const convertedData = convertKeysToCamelCase(response.data);
362
+ return {
363
+ ...response,
364
+ data: convertedData
365
+ };
292
366
  }
293
367
  // Raw SQL operations
294
368
  async dbSql(query, params) {
295
- return this.post(`/api/db/${this.projectId}/sql`, { query, params });
369
+ const response = await this.post(`/api/db/${this.projectId}/sql`, { query, params });
370
+ const convertedData = {
371
+ ...response.data,
372
+ rows: convertKeysToCamelCase(response.data.rows)
373
+ };
374
+ return {
375
+ ...response,
376
+ data: convertedData
377
+ };
296
378
  }
297
379
  // Batch SQL operations
298
380
  async dbBatch(statements, mode = "write") {
299
- return this.post(`/api/db/${this.projectId}/batch`, { statements, mode });
381
+ const response = await this.post(`/api/db/${this.projectId}/batch`, { statements, mode });
382
+ const convertedData = {
383
+ ...response.data,
384
+ results: response.data.results.map((result) => ({
385
+ ...result,
386
+ rows: convertKeysToCamelCase(result.rows)
387
+ }))
388
+ };
389
+ return {
390
+ ...response,
391
+ data: convertedData
392
+ };
300
393
  }
301
394
  /**
302
395
  * Upload file with progress tracking
@@ -835,7 +928,17 @@ var BlinkAuth = class {
835
928
  * Redirect to Blink auth page
836
929
  */
837
930
  login(nextUrl) {
838
- const redirectUrl = nextUrl || (typeof window !== "undefined" ? window.location.href : "");
931
+ let redirectUrl = nextUrl || (typeof window !== "undefined" ? window.location.href : "");
932
+ if (redirectUrl && typeof window !== "undefined") {
933
+ try {
934
+ const url = new URL(redirectUrl);
935
+ url.searchParams.delete("redirect_url");
936
+ url.searchParams.delete("redirect");
937
+ redirectUrl = url.toString();
938
+ } catch (e) {
939
+ console.warn("Failed to parse redirect URL:", e);
940
+ }
941
+ }
839
942
  const authUrl = new URL("/auth", this.authUrl);
840
943
  authUrl.searchParams.set("redirect_url", redirectUrl);
841
944
  if (this.config.projectId) {
@@ -1162,8 +1265,15 @@ var BlinkAuth = class {
1162
1265
  issuedAt: tokensWithTimestamp.issued_at
1163
1266
  });
1164
1267
  if (persist && typeof window !== "undefined") {
1165
- localStorage.setItem("blink_tokens", JSON.stringify(tokensWithTimestamp));
1166
- console.log("\u{1F4BE} Tokens persisted to localStorage");
1268
+ try {
1269
+ localStorage.setItem("blink_tokens", JSON.stringify(tokensWithTimestamp));
1270
+ console.log("\u{1F4BE} Tokens persisted to localStorage");
1271
+ } catch (error) {
1272
+ console.log("\u{1F4A5} Error persisting tokens to localStorage:", error);
1273
+ if (error instanceof DOMException && error.name === "SecurityError") {
1274
+ console.log("\u{1F6AB} localStorage access blocked - running in cross-origin iframe");
1275
+ }
1276
+ }
1167
1277
  }
1168
1278
  let user = null;
1169
1279
  try {
@@ -1206,7 +1316,11 @@ var BlinkAuth = class {
1206
1316
  }
1207
1317
  clearTokens() {
1208
1318
  if (typeof window !== "undefined") {
1209
- localStorage.removeItem("blink_tokens");
1319
+ try {
1320
+ localStorage.removeItem("blink_tokens");
1321
+ } catch (error) {
1322
+ console.log("\u{1F4A5} Error clearing tokens from localStorage:", error);
1323
+ }
1210
1324
  }
1211
1325
  this.updateAuthState({
1212
1326
  user: null,
@@ -1221,7 +1335,9 @@ var BlinkAuth = class {
1221
1335
  const stored = localStorage.getItem("blink_tokens");
1222
1336
  console.log("\u{1F50D} Checking localStorage for tokens:", {
1223
1337
  hasStoredData: !!stored,
1224
- storedLength: stored?.length || 0
1338
+ storedLength: stored?.length || 0,
1339
+ origin: window.location.origin,
1340
+ isIframe: window.self !== window.top
1225
1341
  });
1226
1342
  if (stored) {
1227
1343
  const tokens = JSON.parse(stored);
@@ -1235,8 +1351,10 @@ var BlinkAuth = class {
1235
1351
  }
1236
1352
  return null;
1237
1353
  } catch (error) {
1238
- console.log("\u{1F4A5} Error parsing stored tokens:", error);
1239
- localStorage.removeItem("blink_tokens");
1354
+ console.log("\u{1F4A5} Error accessing localStorage:", error);
1355
+ if (error instanceof DOMException && error.name === "SecurityError") {
1356
+ console.log("\u{1F6AB} localStorage access blocked - likely due to cross-origin iframe restrictions");
1357
+ }
1240
1358
  return null;
1241
1359
  }
1242
1360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blinkdotnew/sdk",
3
- "version": "0.13.2",
3
+ "version": "0.14.1",
4
4
  "description": "Blink TypeScript SDK for client-side applications - Zero-boilerplate CRUD + auth + AI + analytics + notifications for modern SaaS/AI apps",
5
5
  "keywords": [
6
6
  "blink",