@pooflabs/core 0.0.32 → 0.0.33

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.
@@ -31,6 +31,79 @@ export type RunQueryOptions = {
31
31
  headers?: Record<string, string>;
32
32
  };
33
33
  };
34
+ /**
35
+ * Supported aggregate operations for count/aggregate queries.
36
+ */
37
+ export type AggregateOperation = 'count' | 'uniqueCount' | 'sum' | 'avg' | 'min' | 'max';
38
+ /**
39
+ * Result of a count or aggregate query — always a single numeric value.
40
+ */
41
+ export type AggregateResult = {
42
+ value: number;
43
+ };
44
+ /**
45
+ * Options for the count function.
46
+ */
47
+ export type CountOptions = {
48
+ /** Natural language filter prompt (e.g., "posts created in the last 7 days") */
49
+ prompt?: string;
50
+ /** Internal overrides for headers */
51
+ _overrides?: {
52
+ headers?: Record<string, string>;
53
+ };
54
+ };
55
+ /**
56
+ * Options for the aggregate function.
57
+ */
58
+ export type AggregateOptions = {
59
+ /** Natural language filter prompt */
60
+ prompt?: string;
61
+ /** Field name to aggregate on (required for sum, avg, min, max) */
62
+ field?: string;
63
+ /** Internal overrides for headers */
64
+ _overrides?: {
65
+ headers?: Record<string, string>;
66
+ };
67
+ };
68
+ /**
69
+ * Count items in a collection path. Returns a numeric result.
70
+ *
71
+ * This uses the AI query engine with a count-specific prompt prefix,
72
+ * so TaroBase will generate a $count aggregation pipeline and return
73
+ * just the count rather than full documents.
74
+ *
75
+ * IMPORTANT: This only works for collections where the read policy is "true".
76
+ * If the read policy requires per-document checks, the server will return
77
+ * an error because aggregate counts cannot be performed without pulling all
78
+ * documents for access control evaluation.
79
+ *
80
+ * @param path - Collection path (e.g., "posts", "users/abc/comments")
81
+ * @param opts - Optional filter prompt and overrides
82
+ * @returns AggregateResult with the count value
83
+ */
84
+ export declare function count(path: string, opts?: CountOptions): Promise<AggregateResult>;
85
+ /**
86
+ * Run an aggregate operation on a collection path. Returns a numeric result.
87
+ *
88
+ * Supported operations:
89
+ * - count: Total number of documents
90
+ * - uniqueCount: Number of distinct values for a field
91
+ * - sum: Sum of a numeric field
92
+ * - avg: Average of a numeric field
93
+ * - min: Minimum value of a numeric field
94
+ * - max: Maximum value of a numeric field
95
+ *
96
+ * IMPORTANT: This only works for collections where the read policy is "true".
97
+ * If the read policy requires per-document checks, the server will return
98
+ * an error because aggregate operations cannot be performed without pulling
99
+ * all documents for access control evaluation.
100
+ *
101
+ * @param path - Collection path (e.g., "posts", "users/abc/comments")
102
+ * @param operation - The aggregate operation to perform
103
+ * @param opts - Options including optional filter prompt and field name
104
+ * @returns AggregateResult with the computed numeric value
105
+ */
106
+ export declare function aggregate(path: string, operation: AggregateOperation, opts?: AggregateOptions): Promise<AggregateResult>;
34
107
  export declare function get(path: string, opts?: GetOptions): Promise<any>;
35
108
  export type RunExpressionOptions = {
36
109
  returnType?: 'Bool' | 'String' | 'Int' | 'UInt';
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { init } from './client/config';
2
2
  export { getConfig, ClientConfig } from './client/config';
3
- export { get, set, setMany, setFile, getFiles, runQuery, runQueryMany, runExpression, runExpressionMany, signMessage, signTransaction, signAndSubmitTransaction, SetOptions, GetOptions, RunExpressionOptions, RunExpressionResult } from './client/operations';
3
+ export { get, set, setMany, setFile, getFiles, runQuery, runQueryMany, runExpression, runExpressionMany, signMessage, signTransaction, signAndSubmitTransaction, count, aggregate, SetOptions, GetOptions, CountOptions, AggregateOptions, AggregateOperation, AggregateResult, RunExpressionOptions, RunExpressionResult } from './client/operations';
4
4
  export { subscribe, closeAllSubscriptions, clearCache, getCachedData, reconnectWithNewAuth } from './client/subscription';
5
5
  export * from './types';
6
6
  export { getIdToken } from './utils/utils';
package/dist/index.js CHANGED
@@ -139,10 +139,14 @@ class WebSessionManager {
139
139
  const newObj = JSON.parse(newSession);
140
140
  return { address: newObj.address, session: newObj };
141
141
  }
142
+ // Refresh failed — clear stale session to prevent retry loops
143
+ this.clearSession();
142
144
  return null;
143
145
  }
144
146
  }
145
147
  catch (err) {
148
+ // Token decode or refresh failed — clear stale session to prevent retry loops
149
+ this.clearSession();
146
150
  return null;
147
151
  }
148
152
  return { address: sessionObj.address, session: sessionObj };
@@ -3066,15 +3070,30 @@ class ServerSessionManager {
3066
3070
  constructor() {
3067
3071
  /* Private cache (lives for the life of the process) */
3068
3072
  this.session = null;
3073
+ /* Coalesce concurrent getSession() calls into a single in-flight request */
3074
+ this.pendingSession = null;
3069
3075
  }
3070
3076
  /* ---------------------------------------------- *
3071
3077
  * GET (lazy-fetch)
3072
3078
  * ---------------------------------------------- */
3073
3079
  async getSession() {
3074
- if (this.session === null) {
3075
- this.session = await createSession();
3080
+ if (this.session !== null) {
3081
+ return this.session;
3082
+ }
3083
+ // If a session creation is already in-flight, reuse that promise
3084
+ // instead of firing a second concurrent request.
3085
+ if (this.pendingSession !== null) {
3086
+ return this.pendingSession;
3076
3087
  }
3077
- return this.session;
3088
+ this.pendingSession = createSession()
3089
+ .then((session) => {
3090
+ this.session = session;
3091
+ return session;
3092
+ })
3093
+ .finally(() => {
3094
+ this.pendingSession = null;
3095
+ });
3096
+ return this.pendingSession;
3078
3097
  }
3079
3098
  /* ---------------------------------------------- *
3080
3099
  * STORE (overwrites the cached value)
@@ -3087,6 +3106,7 @@ class ServerSessionManager {
3087
3106
  * ---------------------------------------------- */
3088
3107
  clearSession() {
3089
3108
  this.session = null;
3109
+ this.pendingSession = null;
3090
3110
  }
3091
3111
  /* ---------------------------------------------- *
3092
3112
  * QUICK helpers
@@ -3389,6 +3409,150 @@ function hashForKey$1(value) {
3389
3409
  }
3390
3410
  return h.toString(36);
3391
3411
  }
3412
+ /**
3413
+ * Validates that a field name is a safe identifier (alphanumeric, underscores, dots for nested paths).
3414
+ * Prevents prompt injection via crafted field names.
3415
+ */
3416
+ function validateFieldName(field) {
3417
+ if (!/^[a-zA-Z0-9_.]+$/.test(field)) {
3418
+ throw new Error(`Invalid field name "${field}". Field names must only contain letters, numbers, underscores, and dots.`);
3419
+ }
3420
+ }
3421
+ /**
3422
+ * Parses a raw aggregation result (e.g. [{ count: 42 }] or [{ _id: null, total: 100 }])
3423
+ * into a single numeric value.
3424
+ */
3425
+ function parseAggregateValue(result) {
3426
+ if (typeof result === 'number')
3427
+ return result;
3428
+ if (Array.isArray(result)) {
3429
+ if (result.length === 0)
3430
+ return 0;
3431
+ // Multiple elements — not a collapsed aggregate result
3432
+ if (result.length > 1) {
3433
+ throw new Error(`Unexpected aggregate result: got array with ${result.length} elements. The AI may have returned full documents instead of an aggregation.`);
3434
+ }
3435
+ const first = result[0];
3436
+ if (typeof first === 'number')
3437
+ return first;
3438
+ if (first && typeof first === 'object') {
3439
+ // $count stage returns { count: N }
3440
+ if (typeof first.count === 'number')
3441
+ return first.count;
3442
+ // $group stage returns { _id: null, result: N } — expect _id + one numeric field
3443
+ const numericEntries = Object.entries(first).filter(([key, val]) => key !== '_id' && typeof val === 'number');
3444
+ if (numericEntries.length === 1)
3445
+ return numericEntries[0][1];
3446
+ }
3447
+ // Avoid leaking document contents into error messages
3448
+ const shape = first && typeof first === 'object' ? `{${Object.keys(first).join(', ')}}` : String(first);
3449
+ throw new Error(`Unexpected aggregate result shape: ${shape}. Expected {count: N} or {_id: null, <field>: N}.`);
3450
+ }
3451
+ if (result && typeof result === 'object' && typeof result.count === 'number') {
3452
+ return result.count;
3453
+ }
3454
+ throw new Error(`Unexpected aggregate result type: ${typeof result}. Expected a number, array, or object with a count field.`);
3455
+ }
3456
+ /**
3457
+ * Count items in a collection path. Returns a numeric result.
3458
+ *
3459
+ * This uses the AI query engine with a count-specific prompt prefix,
3460
+ * so TaroBase will generate a $count aggregation pipeline and return
3461
+ * just the count rather than full documents.
3462
+ *
3463
+ * IMPORTANT: This only works for collections where the read policy is "true".
3464
+ * If the read policy requires per-document checks, the server will return
3465
+ * an error because aggregate counts cannot be performed without pulling all
3466
+ * documents for access control evaluation.
3467
+ *
3468
+ * @param path - Collection path (e.g., "posts", "users/abc/comments")
3469
+ * @param opts - Optional filter prompt and overrides
3470
+ * @returns AggregateResult with the count value
3471
+ */
3472
+ async function count(path, opts = {}) {
3473
+ const prefix = 'Return ONLY the total count of matching documents. Use the $count stage to produce a field named "count". Do NOT return the documents themselves.';
3474
+ const fullPrompt = opts.prompt
3475
+ ? `${prefix} Filter: ${opts.prompt}`
3476
+ : prefix;
3477
+ const result = await get(path, { prompt: fullPrompt, bypassCache: true, _overrides: opts._overrides });
3478
+ return { value: parseAggregateValue(result) };
3479
+ }
3480
+ /**
3481
+ * Run an aggregate operation on a collection path. Returns a numeric result.
3482
+ *
3483
+ * Supported operations:
3484
+ * - count: Total number of documents
3485
+ * - uniqueCount: Number of distinct values for a field
3486
+ * - sum: Sum of a numeric field
3487
+ * - avg: Average of a numeric field
3488
+ * - min: Minimum value of a numeric field
3489
+ * - max: Maximum value of a numeric field
3490
+ *
3491
+ * IMPORTANT: This only works for collections where the read policy is "true".
3492
+ * If the read policy requires per-document checks, the server will return
3493
+ * an error because aggregate operations cannot be performed without pulling
3494
+ * all documents for access control evaluation.
3495
+ *
3496
+ * @param path - Collection path (e.g., "posts", "users/abc/comments")
3497
+ * @param operation - The aggregate operation to perform
3498
+ * @param opts - Options including optional filter prompt and field name
3499
+ * @returns AggregateResult with the computed numeric value
3500
+ */
3501
+ async function aggregate(path, operation, opts = {}) {
3502
+ let prefix;
3503
+ switch (operation) {
3504
+ case 'count':
3505
+ prefix = 'Return ONLY the total count of matching documents. Use the $count stage to produce a field named "count". Do NOT return the documents themselves.';
3506
+ break;
3507
+ case 'uniqueCount':
3508
+ if (!opts.field)
3509
+ throw new Error('aggregate "uniqueCount" requires a field option');
3510
+ validateFieldName(opts.field);
3511
+ prefix = `Return ONLY the count of unique/distinct values of the "${opts.field}" field. Use $group with _id set to "$${opts.field}" then $count to produce a field named "count". Do NOT return the documents themselves.`;
3512
+ break;
3513
+ case 'sum':
3514
+ if (!opts.field)
3515
+ throw new Error('aggregate "sum" requires a field option');
3516
+ validateFieldName(opts.field);
3517
+ prefix = `Return ONLY the sum of the "${opts.field}" field across all matching documents. Use $group with _id: null and result: { $sum: "$${opts.field}" }. Do NOT return the documents themselves.`;
3518
+ break;
3519
+ case 'avg':
3520
+ if (!opts.field)
3521
+ throw new Error('aggregate "avg" requires a field option');
3522
+ validateFieldName(opts.field);
3523
+ prefix = `Return ONLY the average of the "${opts.field}" field across all matching documents. Use $group with _id: null and result: { $avg: "$${opts.field}" }. Do NOT return the documents themselves.`;
3524
+ break;
3525
+ case 'min':
3526
+ if (!opts.field)
3527
+ throw new Error('aggregate "min" requires a field option');
3528
+ validateFieldName(opts.field);
3529
+ prefix = `Return ONLY the minimum value of the "${opts.field}" field across all matching documents. Use $group with _id: null and result: { $min: "$${opts.field}" }. Do NOT return the documents themselves.`;
3530
+ break;
3531
+ case 'max':
3532
+ if (!opts.field)
3533
+ throw new Error('aggregate "max" requires a field option');
3534
+ validateFieldName(opts.field);
3535
+ prefix = `Return ONLY the maximum value of the "${opts.field}" field across all matching documents. Use $group with _id: null and result: { $max: "$${opts.field}" }. Do NOT return the documents themselves.`;
3536
+ break;
3537
+ default:
3538
+ throw new Error(`Unsupported aggregate operation: ${operation}`);
3539
+ }
3540
+ const fullPrompt = opts.prompt
3541
+ ? `${prefix} Filter: ${opts.prompt}`
3542
+ : prefix;
3543
+ const result = await get(path, { prompt: fullPrompt, bypassCache: true, _overrides: opts._overrides });
3544
+ // For uniqueCount, the AI may return $group results without the final $count
3545
+ // stage, producing [{_id: "val1"}, {_id: "val2"}, ...]. Verify elements look
3546
+ // like $group output (only _id key) before using array length as the count.
3547
+ if (operation === 'uniqueCount' && Array.isArray(result) && result.length > 1) {
3548
+ const looksLikeGroupOutput = result.every((el) => el && typeof el === 'object' && Object.keys(el).length === 1 && '_id' in el);
3549
+ if (looksLikeGroupOutput) {
3550
+ return { value: result.length };
3551
+ }
3552
+ throw new Error(`Unexpected uniqueCount result: got ${result.length} elements that don't match $group output shape.`);
3553
+ }
3554
+ return { value: parseAggregateValue(result) };
3555
+ }
3392
3556
  async function get(path, opts = {}) {
3393
3557
  try {
3394
3558
  let normalizedPath = path.startsWith("/") ? path.slice(1) : path;
@@ -3855,7 +4019,7 @@ const BASE_MIN_RECONNECT_DELAY_MS = 1000;
3855
4019
  const MIN_RECONNECT_DELAY_JITTER_MS = 1000;
3856
4020
  const MAX_RECONNECT_DELAY_MS = 300000;
3857
4021
  const RECONNECT_DELAY_GROW_FACTOR = 1.8;
3858
- const MIN_BROWSER_RECONNECT_INTERVAL_MS = 30000;
4022
+ const MIN_BROWSER_RECONNECT_INTERVAL_MS = 5000;
3859
4023
  const WS_CONFIG = {
3860
4024
  // Keep retrying indefinitely so long outages recover without page refresh.
3861
4025
  maxRetries: Infinity,
@@ -4020,6 +4184,8 @@ async function getOrCreateConnection(appId, isServer) {
4020
4184
  isConnected: false,
4021
4185
  appId,
4022
4186
  tokenRefreshTimer: null,
4187
+ lastMessageAt: Date.now(),
4188
+ keepaliveTimer: null,
4023
4189
  };
4024
4190
  connections.set(appId, connection);
4025
4191
  // URL provider for reconnection with fresh tokens
@@ -4056,6 +4222,7 @@ async function getOrCreateConnection(appId, isServer) {
4056
4222
  ws.addEventListener('open', () => {
4057
4223
  connection.isConnecting = false;
4058
4224
  connection.isConnected = true;
4225
+ connection.lastMessageAt = Date.now();
4059
4226
  // Schedule token refresh before expiry
4060
4227
  (async () => {
4061
4228
  const token = await getIdToken(isServer);
@@ -4063,11 +4230,28 @@ async function getOrCreateConnection(appId, isServer) {
4063
4230
  })();
4064
4231
  // Re-subscribe to all existing subscriptions after reconnect
4065
4232
  for (const sub of connection.subscriptions.values()) {
4233
+ sub.lastData = undefined;
4066
4234
  sendSubscribe(connection, sub);
4067
4235
  }
4236
+ // Start keepalive detection — if no messages for 90s, force reconnect
4237
+ if (connection.keepaliveTimer) {
4238
+ clearInterval(connection.keepaliveTimer);
4239
+ }
4240
+ connection.keepaliveTimer = setInterval(() => {
4241
+ var _a;
4242
+ if (Date.now() - connection.lastMessageAt > 90000) {
4243
+ console.warn('[WS v2] No messages received for 90s, forcing reconnect');
4244
+ if (connection.keepaliveTimer) {
4245
+ clearInterval(connection.keepaliveTimer);
4246
+ connection.keepaliveTimer = null;
4247
+ }
4248
+ (_a = connection.ws) === null || _a === void 0 ? void 0 : _a.reconnect();
4249
+ }
4250
+ }, 30000);
4068
4251
  });
4069
4252
  // Handle incoming messages
4070
4253
  ws.addEventListener('message', (event) => {
4254
+ connection.lastMessageAt = Date.now();
4071
4255
  try {
4072
4256
  const message = JSON.parse(event.data);
4073
4257
  handleServerMessage(connection, message);
@@ -4079,20 +4263,22 @@ async function getOrCreateConnection(appId, isServer) {
4079
4263
  // Handle errors
4080
4264
  ws.addEventListener('error', (event) => {
4081
4265
  console.error('[WS v2] WebSocket error:', event);
4082
- // Reject all pending subscriptions
4083
- for (const [id, pending] of connection.pendingSubscriptions) {
4266
+ for (const [, pending] of connection.pendingSubscriptions) {
4084
4267
  pending.reject(new Error('WebSocket error'));
4085
- connection.pendingSubscriptions.delete(id);
4086
4268
  }
4269
+ connection.pendingSubscriptions.clear();
4087
4270
  });
4088
4271
  // Handle close
4089
4272
  ws.addEventListener('close', () => {
4090
4273
  connection.isConnected = false;
4091
- // Clear token refresh timer
4092
4274
  if (connection.tokenRefreshTimer) {
4093
4275
  clearTimeout(connection.tokenRefreshTimer);
4094
4276
  connection.tokenRefreshTimer = null;
4095
4277
  }
4278
+ if (connection.keepaliveTimer) {
4279
+ clearInterval(connection.keepaliveTimer);
4280
+ connection.keepaliveTimer = null;
4281
+ }
4096
4282
  });
4097
4283
  return connection;
4098
4284
  }
@@ -4292,9 +4478,7 @@ async function subscribeV2(path, subscriptionOptions) {
4292
4478
  await subscriptionPromise;
4293
4479
  }
4294
4480
  catch (error) {
4295
- // Remove subscription on error
4296
- connection.subscriptions.delete(subscriptionId);
4297
- throw error;
4481
+ console.warn('[WS v2] Subscription confirmation failed, keeping for reconnect recovery:', error);
4298
4482
  }
4299
4483
  }
4300
4484
  // Return unsubscribe function
@@ -4410,8 +4594,6 @@ async function reconnectWithNewAuthV2() {
4410
4594
  if (!connection.ws) {
4411
4595
  continue;
4412
4596
  }
4413
- // Reject any pending subscriptions - they'll need to be retried after reconnect
4414
- // This prevents hanging promises during auth transitions
4415
4597
  for (const [, pending] of connection.pendingSubscriptions) {
4416
4598
  pending.reject(new Error('Connection reconnecting due to auth change'));
4417
4599
  }
@@ -4505,10 +4687,12 @@ async function reconnectWithNewAuth() {
4505
4687
 
4506
4688
  exports.ServerSessionManager = ServerSessionManager;
4507
4689
  exports.WebSessionManager = WebSessionManager;
4690
+ exports.aggregate = aggregate;
4508
4691
  exports.buildSetDocumentsTransaction = buildSetDocumentsTransaction;
4509
4692
  exports.clearCache = clearCache;
4510
4693
  exports.closeAllSubscriptions = closeAllSubscriptions;
4511
4694
  exports.convertRemainingAccounts = convertRemainingAccounts;
4695
+ exports.count = count;
4512
4696
  exports.createSessionWithPrivy = createSessionWithPrivy;
4513
4697
  exports.createSessionWithSignature = createSessionWithSignature;
4514
4698
  exports.genAuthNonce = genAuthNonce;