@tthr/vue 0.0.65 → 0.0.67

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.
@@ -13,6 +13,9 @@ import { defineNitroPlugin } from 'nitropack/runtime';
13
13
  export { defineNitroPlugin };
14
14
  // Store for registered cron handlers
15
15
  const cronHandlers = new Map();
16
+ // Dynamic function registry - populated on first cron trigger
17
+ let functionRegistry = null;
18
+ let registryError = null;
16
19
  // WebSocket connection state
17
20
  let ws = null;
18
21
  let connectionId = null;
@@ -69,6 +72,203 @@ export function unregisterCronHandler(functionName) {
69
72
  export function getCronHandlers() {
70
73
  return Array.from(cronHandlers.keys());
71
74
  }
75
+ /**
76
+ * Load user's custom functions from ~/tether/functions
77
+ */
78
+ async function loadFunctionRegistry() {
79
+ if (functionRegistry !== null || registryError !== null) {
80
+ return;
81
+ }
82
+ try {
83
+ log.debug('Loading custom functions from ~~/tether/functions/index.ts');
84
+ const functions = await import('~~/tether/functions/index.ts').catch((err) => {
85
+ log.debug('Failed to import functions:', err.message);
86
+ return null;
87
+ });
88
+ if (functions) {
89
+ log.debug('Loaded function modules:', Object.keys(functions));
90
+ functionRegistry = functions;
91
+ }
92
+ else {
93
+ log.debug('No custom functions found');
94
+ functionRegistry = {};
95
+ }
96
+ }
97
+ catch (error) {
98
+ log.debug('Could not load custom functions:', error instanceof Error ? error.message : String(error));
99
+ registryError = error instanceof Error ? error : new Error(String(error));
100
+ functionRegistry = {};
101
+ }
102
+ }
103
+ /**
104
+ * Look up a function by name (e.g., "sync.syncClips")
105
+ */
106
+ function lookupFunction(name) {
107
+ if (!functionRegistry || !name)
108
+ return null;
109
+ const parts = name.split('.');
110
+ if (parts.length !== 2)
111
+ return null;
112
+ const [moduleName, fnName] = parts;
113
+ const module = functionRegistry[moduleName];
114
+ if (!module)
115
+ return null;
116
+ const fn = module[fnName];
117
+ // Check if it's a valid Tether function (has a handler)
118
+ if (fn && typeof fn === 'object' && typeof fn.handler === 'function') {
119
+ return fn;
120
+ }
121
+ return null;
122
+ }
123
+ /**
124
+ * Create a database proxy that routes calls to Tether's CRUD endpoints
125
+ */
126
+ function createDatabaseProxy(apiKey, url, projectId, environment) {
127
+ const base = url.replace(/\/$/, '');
128
+ let apiPath;
129
+ if (environment && environment !== 'production') {
130
+ apiPath = `${base}/api/v1/projects/${projectId}/env/${environment}`;
131
+ }
132
+ else {
133
+ apiPath = `${base}/api/v1/projects/${projectId}`;
134
+ }
135
+ return new Proxy({}, {
136
+ get(_target, tableName) {
137
+ const makeRequest = async (operation, args) => {
138
+ const response = await fetch(`${apiPath}/query`, {
139
+ method: 'POST',
140
+ headers: {
141
+ 'Content-Type': 'application/json',
142
+ 'Authorization': `Bearer ${apiKey}`,
143
+ },
144
+ body: JSON.stringify({
145
+ function: `${tableName}.${operation}`,
146
+ args,
147
+ }),
148
+ });
149
+ if (!response.ok) {
150
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
151
+ throw new Error(error.error || `Database operation failed: ${tableName}.${operation}`);
152
+ }
153
+ const result = await response.json();
154
+ return result.data;
155
+ };
156
+ const makeMutation = async (operation, args) => {
157
+ const response = await fetch(`${apiPath}/mutation`, {
158
+ method: 'POST',
159
+ headers: {
160
+ 'Content-Type': 'application/json',
161
+ 'Authorization': `Bearer ${apiKey}`,
162
+ },
163
+ body: JSON.stringify({
164
+ function: `${tableName}.${operation}`,
165
+ args,
166
+ }),
167
+ });
168
+ if (!response.ok) {
169
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
170
+ throw new Error(error.error || `Database operation failed: ${tableName}.${operation}`);
171
+ }
172
+ const result = await response.json();
173
+ return result.data;
174
+ };
175
+ return {
176
+ findMany: async (options) => {
177
+ const args = {};
178
+ if (options?.where)
179
+ args.where = options.where;
180
+ if (options?.limit)
181
+ args.limit = options.limit;
182
+ if (options?.offset)
183
+ args.offset = options.offset;
184
+ if (options?.orderBy) {
185
+ if (typeof options.orderBy === 'string') {
186
+ args.orderBy = options.orderBy;
187
+ }
188
+ else if (typeof options.orderBy === 'object') {
189
+ const entries = Object.entries(options.orderBy);
190
+ if (entries.length > 0) {
191
+ const [column, dir] = entries[0];
192
+ args.orderBy = column;
193
+ args.orderDir = dir?.toUpperCase?.() || 'ASC';
194
+ }
195
+ }
196
+ }
197
+ if (options?.orderDir)
198
+ args.orderDir = options.orderDir;
199
+ return makeRequest('list', args);
200
+ },
201
+ findFirst: async (options) => {
202
+ const args = { limit: 1 };
203
+ if (options?.where)
204
+ args.where = options.where;
205
+ const results = await makeRequest('list', args);
206
+ return Array.isArray(results) ? results[0] ?? null : null;
207
+ },
208
+ findUnique: async (options) => {
209
+ const args = { limit: 1 };
210
+ if (options?.where)
211
+ args.where = options.where;
212
+ const results = await makeRequest('list', args);
213
+ return Array.isArray(results) ? results[0] ?? null : null;
214
+ },
215
+ findById: async (id) => {
216
+ return makeRequest('get', { id });
217
+ },
218
+ count: async (options) => {
219
+ const args = {};
220
+ if (options?.where)
221
+ args.where = options.where;
222
+ const result = await makeRequest('count', args);
223
+ return result?.count ?? 0;
224
+ },
225
+ insert: async (data) => {
226
+ return makeMutation('create', { data });
227
+ },
228
+ insertMany: async (items) => {
229
+ const results = [];
230
+ for (const data of items) {
231
+ const result = await makeMutation('create', { data });
232
+ results.push(result);
233
+ }
234
+ return results;
235
+ },
236
+ create: async (options) => {
237
+ return makeMutation('create', { data: options.data });
238
+ },
239
+ update: async (options) => {
240
+ const id = options.where?.id;
241
+ if (!id)
242
+ throw new Error('Update requires an id in the where clause');
243
+ const result = await makeMutation('update', { id, data: options.data });
244
+ return result?.rowsAffected ?? 0;
245
+ },
246
+ upsert: async (options) => {
247
+ const id = options.where?.id;
248
+ const existing = id ? await makeRequest('get', { id }).catch(() => null) : null;
249
+ if (existing) {
250
+ await makeMutation('update', { id, data: options.update });
251
+ return { ...existing, ...options.update };
252
+ }
253
+ else {
254
+ return makeMutation('create', { data: options.create });
255
+ }
256
+ },
257
+ delete: async (options) => {
258
+ const id = options.where?.id;
259
+ if (!id)
260
+ throw new Error('Delete requires an id in the where clause');
261
+ const result = await makeMutation('delete', { id });
262
+ return result?.rowsAffected ?? 0;
263
+ },
264
+ deleteById: async (id) => {
265
+ const result = await makeMutation('delete', { id });
266
+ return (result?.rowsAffected ?? 0) > 0;
267
+ },
268
+ };
269
+ },
270
+ });
271
+ }
72
272
  function getWsUrl(config) {
73
273
  const base = config.url
74
274
  .replace('https://', 'wss://')
@@ -194,19 +394,54 @@ async function executeViaApi(config, functionName, functionType, args) {
194
394
  async function handleCronTrigger(config, trigger) {
195
395
  log.info(`Received trigger for ${trigger.functionName} (execution: ${trigger.executionId})`);
196
396
  const startTime = Date.now();
197
- const handler = cronHandlers.get(trigger.functionName);
198
397
  try {
199
398
  let result;
399
+ // 1. Check for manually registered handler
400
+ const handler = cronHandlers.get(trigger.functionName);
200
401
  if (handler) {
201
- // Use locally registered handler
202
- log.debug(`Using local handler for: ${trigger.functionName}`);
402
+ log.debug(`Using registered handler for: ${trigger.functionName}`);
203
403
  result = await handler(trigger.args);
204
404
  }
205
405
  else {
206
- // Execute via Tether API (default behaviour)
207
- // This allows cron to work without explicit handler registration
208
- log.debug(`Executing via API: ${trigger.functionName} (${trigger.functionType})`);
209
- result = await executeViaApi(config, trigger.functionName, trigger.functionType, trigger.args);
406
+ // 2. Try to load and execute from user's tether/functions
407
+ await loadFunctionRegistry();
408
+ const localFn = lookupFunction(trigger.functionName);
409
+ if (localFn) {
410
+ log.debug(`Executing local function: ${trigger.functionName}`);
411
+ // Create execution context with database proxy
412
+ const db = createDatabaseProxy(config.apiKey, config.url, config.projectId, config.environment);
413
+ // Create a simple logger for the function
414
+ const fnLog = {
415
+ log: (...args) => log.info(`[${trigger.functionName}]`, ...args),
416
+ debug: (...args) => log.debug(`[${trigger.functionName}]`, ...args),
417
+ info: (...args) => log.info(`[${trigger.functionName}]`, ...args),
418
+ warn: (...args) => log.warn(`[${trigger.functionName}]`, ...args),
419
+ error: (...args) => log.error(`[${trigger.functionName}]`, ...args),
420
+ };
421
+ // Create tether context with env vars from process.env
422
+ // Note: For cron execution, env vars should be set in the Nuxt app's environment
423
+ const tether = {
424
+ env: new Proxy({}, {
425
+ get(_target, key) {
426
+ return process.env[key];
427
+ },
428
+ }),
429
+ };
430
+ // Execute the function with context
431
+ result = await localFn.handler({
432
+ db,
433
+ args: trigger.args ?? {},
434
+ log: fnLog,
435
+ tether,
436
+ ctx: { auth: { userId: null, claims: {} }, userId: null },
437
+ auth: { getUserIdentity: async () => null },
438
+ });
439
+ }
440
+ else {
441
+ // 3. Fall back to API for generic CRUD operations (table.operation)
442
+ log.debug(`Executing via API: ${trigger.functionName} (${trigger.functionType})`);
443
+ result = await executeViaApi(config, trigger.functionName, trigger.functionType, trigger.args);
444
+ }
210
445
  }
211
446
  const durationMs = Date.now() - startTime;
212
447
  await reportExecution(config, trigger, {
@@ -336,14 +571,15 @@ export default defineNitroPlugin((nitro) => {
336
571
  const apiKey = process.env.NUXT_TETHER_API_KEY || process.env.TETHER_API_KEY;
337
572
  const url = process.env.NUXT_TETHER_URL || process.env.TETHER_URL || 'https://tether-api.strands.gg';
338
573
  const projectId = process.env.NUXT_TETHER_PROJECT_ID || process.env.TETHER_PROJECT_ID;
574
+ const environment = process.env.NUXT_TETHER_ENVIRONMENT || process.env.TETHER_ENVIRONMENT;
339
575
  verboseLogging = process.env.NUXT_TETHER_VERBOSE === 'true' || process.env.TETHER_VERBOSE === 'true';
340
576
  // Only connect if we have all required config
341
577
  if (!apiKey || !projectId) {
342
578
  log.debug('Missing config (apiKey or projectId) - cron connection disabled');
343
579
  return;
344
580
  }
345
- log.info('Initialising cron connection...');
346
- const config = { url, projectId, apiKey };
581
+ log.info(`Initialising cron connection...${environment ? ` (env: ${environment})` : ''}`);
582
+ const config = { url, projectId, apiKey, environment };
347
583
  // Connect on next tick to allow the server to fully initialise
348
584
  process.nextTick(() => {
349
585
  connect(config);
@@ -359,4 +595,4 @@ export default defineNitroPlugin((nitro) => {
359
595
  log.debug('Connection closed');
360
596
  });
361
597
  });
362
- //# sourceMappingURL=cron.js.map
598
+ //# sourceMappingURL=cron.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tthr/vue",
3
- "version": "0.0.65",
3
+ "version": "0.0.67",
4
4
  "description": "Tether Vue/Nuxt SDK",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",