@portel/photon 1.14.0 → 1.16.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.
Files changed (125) hide show
  1. package/dist/auto-ui/beam/photon-management.d.ts +1 -1
  2. package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
  3. package/dist/auto-ui/beam/photon-management.js +5 -1
  4. package/dist/auto-ui/beam/photon-management.js.map +1 -1
  5. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  6. package/dist/auto-ui/beam/routes/api-config.js +31 -9
  7. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  8. package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
  9. package/dist/auto-ui/beam/routes/api-marketplace.js +3 -0
  10. package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
  11. package/dist/auto-ui/beam.d.ts.map +1 -1
  12. package/dist/auto-ui/beam.js +205 -56
  13. package/dist/auto-ui/beam.js.map +1 -1
  14. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  15. package/dist/auto-ui/bridge/index.js +578 -0
  16. package/dist/auto-ui/bridge/index.js.map +1 -1
  17. package/dist/auto-ui/bridge/renderers.d.ts.map +1 -1
  18. package/dist/auto-ui/bridge/renderers.js +7 -3
  19. package/dist/auto-ui/bridge/renderers.js.map +1 -1
  20. package/dist/auto-ui/bridge/types.d.ts +6 -0
  21. package/dist/auto-ui/bridge/types.d.ts.map +1 -1
  22. package/dist/auto-ui/frontend/pure-view.html +289 -0
  23. package/dist/auto-ui/photon-bridge.d.ts +11 -0
  24. package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
  25. package/dist/auto-ui/photon-bridge.js +75 -1
  26. package/dist/auto-ui/photon-bridge.js.map +1 -1
  27. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  28. package/dist/auto-ui/streamable-http-transport.js +29 -3
  29. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  30. package/dist/beam-form.bundle.js +5707 -0
  31. package/dist/beam-form.bundle.js.map +7 -0
  32. package/dist/beam.bundle.js +2863 -895
  33. package/dist/beam.bundle.js.map +4 -4
  34. package/dist/claude-code-plugin.js +11 -3
  35. package/dist/claude-code-plugin.js.map +1 -1
  36. package/dist/cli/commands/build.js +1 -1
  37. package/dist/cli/commands/build.js.map +1 -1
  38. package/dist/cli/commands/doctor.d.ts.map +1 -1
  39. package/dist/cli/commands/doctor.js +7 -5
  40. package/dist/cli/commands/doctor.js.map +1 -1
  41. package/dist/cli/commands/info.d.ts.map +1 -1
  42. package/dist/cli/commands/info.js +18 -4
  43. package/dist/cli/commands/info.js.map +1 -1
  44. package/dist/cli/commands/mcp.js +2 -2
  45. package/dist/cli/commands/mcp.js.map +1 -1
  46. package/dist/cli/commands/update.d.ts.map +1 -1
  47. package/dist/cli/commands/update.js +6 -2
  48. package/dist/cli/commands/update.js.map +1 -1
  49. package/dist/cli-alias.d.ts.map +1 -1
  50. package/dist/cli-alias.js +3 -2
  51. package/dist/cli-alias.js.map +1 -1
  52. package/dist/daemon/client.d.ts +5 -0
  53. package/dist/daemon/client.d.ts.map +1 -1
  54. package/dist/daemon/client.js +50 -0
  55. package/dist/daemon/client.js.map +1 -1
  56. package/dist/daemon/manager.d.ts +15 -0
  57. package/dist/daemon/manager.d.ts.map +1 -1
  58. package/dist/daemon/manager.js +142 -11
  59. package/dist/daemon/manager.js.map +1 -1
  60. package/dist/daemon/worker-manager.js +1 -1
  61. package/dist/daemon/worker-manager.js.map +1 -1
  62. package/dist/deploy/cloudflare.d.ts.map +1 -1
  63. package/dist/deploy/cloudflare.js +12 -10
  64. package/dist/deploy/cloudflare.js.map +1 -1
  65. package/dist/loader.d.ts.map +1 -1
  66. package/dist/loader.js +37 -2
  67. package/dist/loader.js.map +1 -1
  68. package/dist/marketplace-manager.d.ts +9 -0
  69. package/dist/marketplace-manager.d.ts.map +1 -1
  70. package/dist/marketplace-manager.js +115 -42
  71. package/dist/marketplace-manager.js.map +1 -1
  72. package/dist/meta.d.ts +51 -0
  73. package/dist/meta.d.ts.map +1 -0
  74. package/dist/meta.js +320 -0
  75. package/dist/meta.js.map +1 -0
  76. package/dist/photon-cli-runner.d.ts.map +1 -1
  77. package/dist/photon-cli-runner.js +30 -5
  78. package/dist/photon-cli-runner.js.map +1 -1
  79. package/dist/photon-doc-extractor.d.ts +1 -0
  80. package/dist/photon-doc-extractor.d.ts.map +1 -1
  81. package/dist/photon-doc-extractor.js +33 -21
  82. package/dist/photon-doc-extractor.js.map +1 -1
  83. package/dist/photons/builder-compass.photon.d.ts +167 -0
  84. package/dist/photons/builder-compass.photon.d.ts.map +1 -0
  85. package/dist/photons/builder-compass.photon.js +816 -0
  86. package/dist/photons/builder-compass.photon.js.map +1 -0
  87. package/dist/photons/builder-compass.photon.ts +1129 -0
  88. package/dist/photons/docs/ui/docs.html +441 -0
  89. package/dist/photons/docs.photon.d.ts +237 -0
  90. package/dist/photons/docs.photon.d.ts.map +1 -0
  91. package/dist/photons/docs.photon.js +483 -0
  92. package/dist/photons/docs.photon.js.map +1 -0
  93. package/dist/photons/docs.photon.ts +536 -0
  94. package/dist/photons/maker.photon.d.ts.map +1 -1
  95. package/dist/photons/maker.photon.js +19 -2
  96. package/dist/photons/maker.photon.js.map +1 -1
  97. package/dist/photons/maker.photon.ts +18 -2
  98. package/dist/photons/slides.photon.d.ts +212 -0
  99. package/dist/photons/slides.photon.d.ts.map +1 -0
  100. package/dist/photons/slides.photon.js +355 -0
  101. package/dist/photons/slides.photon.js.map +1 -0
  102. package/dist/photons/slides.photon.ts +370 -0
  103. package/dist/photons/spreadsheet/ui/spreadsheet.html +779 -0
  104. package/dist/photons/spreadsheet.photon.d.ts +554 -0
  105. package/dist/photons/spreadsheet.photon.d.ts.map +1 -0
  106. package/dist/photons/spreadsheet.photon.js +1050 -0
  107. package/dist/photons/spreadsheet.photon.js.map +1 -0
  108. package/dist/photons/spreadsheet.photon.ts +1239 -0
  109. package/dist/photons/ui/builder-compass.html +1199 -0
  110. package/dist/photons/ui/builder-compass.photon.html +380 -0
  111. package/dist/server.d.ts.map +1 -1
  112. package/dist/server.js +33 -59
  113. package/dist/server.js.map +1 -1
  114. package/dist/shared/error-handler.d.ts +8 -0
  115. package/dist/shared/error-handler.d.ts.map +1 -1
  116. package/dist/shared/error-handler.js +50 -0
  117. package/dist/shared/error-handler.js.map +1 -1
  118. package/dist/shared-utils.d.ts +16 -2
  119. package/dist/shared-utils.d.ts.map +1 -1
  120. package/dist/shared-utils.js +37 -3
  121. package/dist/shared-utils.js.map +1 -1
  122. package/dist/template-manager.d.ts.map +1 -1
  123. package/dist/template-manager.js +2 -1
  124. package/dist/template-manager.js.map +1 -1
  125. package/package.json +8 -3
@@ -0,0 +1,1239 @@
1
+ /**
2
+ * Spreadsheet — CSV-backed spreadsheet with formulas
3
+ *
4
+ * A spreadsheet engine that works on plain CSV files. Formulas (=SUM, =AVG, etc.)
5
+ * are stored directly in CSV cells and evaluated at runtime. Named instances map
6
+ * to CSV files: `_use('budget')` → `budget.csv` in your spreadsheets folder.
7
+ * Pass a full path to open any CSV: `_use('/path/to/data.csv')`.
8
+ *
9
+ * @version 1.1.0
10
+ * @runtime ^1.14.0
11
+ * @tags spreadsheet, csv, formulas, data
12
+ * @icon 📊
13
+ * @stateful
14
+ * @dependencies @portel/csv@^1.0.0, alasql@^4.0.0
15
+ * @ui dashboard ./ui/spreadsheet.html
16
+ */
17
+
18
+ import * as fs from 'fs/promises';
19
+ import {
20
+ createReadStream,
21
+ existsSync,
22
+ mkdirSync,
23
+ statSync,
24
+ watch as fsWatch,
25
+ type FSWatcher,
26
+ } from 'fs';
27
+ import * as path from 'path';
28
+ import * as os from 'os';
29
+ import * as readline from 'readline';
30
+
31
+ // Lazy-loaded: installed by @dependencies at runtime
32
+ let CsvEngine: any;
33
+ let cellToIndex: any;
34
+
35
+ interface WatchDef {
36
+ name: string;
37
+ query: string;
38
+ action?: string;
39
+ actionParams?: Record<string, any>;
40
+ once: boolean;
41
+ lastTriggered?: string;
42
+ triggerCount: number;
43
+ }
44
+
45
+ class ConcurrencyLock {
46
+ private promise: Promise<void> = Promise.resolve();
47
+ async acquire(): Promise<() => void> {
48
+ let release: () => void;
49
+ const next = new Promise<void>((resolve) => {
50
+ release = resolve;
51
+ });
52
+ const current = this.promise;
53
+ this.promise = next;
54
+ await current;
55
+ return release!;
56
+ }
57
+ }
58
+
59
+ export default class Spreadsheet {
60
+ private engine: any;
61
+ private loaded = false;
62
+ private _lastLoadSize = -1;
63
+ private _lock = new ConcurrencyLock();
64
+ private _isLargeFileThreshold = 5 * 1024 * 1024; // 5MB
65
+ private _engines: Map<string, any> = new Map();
66
+
67
+ // File watcher state
68
+ private _watcher: FSWatcher | null = null;
69
+ private _lastFileSize = 0;
70
+ private _watchDebounce: ReturnType<typeof setTimeout> | null = null;
71
+
72
+ declare emit: (data: any) => void;
73
+ declare call: (
74
+ target: string,
75
+ params?: Record<string, any>,
76
+ options?: { instance?: string }
77
+ ) => Promise<any>;
78
+ declare instanceName: string;
79
+
80
+ // SQL watch state
81
+ private _watches: Map<string, WatchDef> = new Map();
82
+ private _watcherAutoStarted = false;
83
+
84
+ protected settings = {
85
+ /** @property Directory where spreadsheet CSV files are stored */
86
+ folder: path.join(os.homedir(), 'Documents', 'Spreadsheets'),
87
+ /** @property Save format row: true (always), false (never), auto (if original had one or formatting was customized) */
88
+ formatting: 'auto' as 'auto' | 'true' | 'false',
89
+ /** @property Row count for paging in view() */
90
+ pageSize: 100,
91
+ };
92
+
93
+ async onInitialize() {
94
+ const csvId = '@portel/csv';
95
+ const csvModule = await import(/* webpackIgnore: true */ csvId);
96
+ CsvEngine = csvModule.CsvEngine || csvModule.default?.CsvEngine;
97
+ cellToIndex = csvModule.cellToIndex || csvModule.default?.cellToIndex;
98
+ this.engine = new CsvEngine();
99
+ }
100
+
101
+ private get defaultFolder(): string {
102
+ return this.settings?.folder || path.join(os.homedir(), 'Documents', 'Spreadsheets');
103
+ }
104
+
105
+ private get csvPath(): string {
106
+ const name = this.instanceName || 'default';
107
+
108
+ if (path.isAbsolute(name)) {
109
+ return name.endsWith('.csv') ? name : name + '.csv';
110
+ }
111
+
112
+ if (name.includes('/') || name.includes('\\')) {
113
+ const resolved = path.resolve(name);
114
+ return resolved.endsWith('.csv') ? resolved : resolved + '.csv';
115
+ }
116
+
117
+ const dir = this.defaultFolder;
118
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
119
+ return path.join(dir, name.endsWith('.csv') ? name : name + '.csv');
120
+ }
121
+
122
+ // --- Optimized Reading (Generator Pattern) ---
123
+
124
+ /**
125
+ * Memory-efficient line-by-line generator
126
+ */
127
+ private async *getLineGenerator(): AsyncGenerator<string> {
128
+ const csvPath = this.csvPath;
129
+ if (!existsSync(csvPath)) return;
130
+
131
+ const fileStream = createReadStream(csvPath);
132
+ const rl = readline.createInterface({
133
+ input: fileStream,
134
+ crlfDelay: Infinity,
135
+ });
136
+
137
+ for await (const line of rl) {
138
+ yield line;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Helper to get or load an engine for a specific table name
144
+ */
145
+ private async _getCsvEngine(name: string): Promise<any> {
146
+ if (name === this.instanceName || (name === 'default' && !this.instanceName)) {
147
+ await this.load();
148
+ return this.engine;
149
+ }
150
+
151
+ if (this._engines.has(name)) return this._engines.get(name)!;
152
+
153
+ const csvPath = path.join(this.defaultFolder, name.endsWith('.csv') ? name : name + '.csv');
154
+ if (existsSync(csvPath)) {
155
+ const csv = await fs.readFile(csvPath, 'utf-8');
156
+ const engine = CsvEngine.fromCSV(csv);
157
+ this._engines.set(name, engine);
158
+ return engine;
159
+ }
160
+ throw new Error(`Table "${name}" not found in ${this.defaultFolder}`);
161
+ }
162
+
163
+ /**
164
+ * Pre-process Airtable-style formulas ({Field} -> "Field")
165
+ */
166
+ private _processAirtableFormula(formula: string): string {
167
+ // Replace {Field Name} with "Field Name" for SQL/Expression compatibility
168
+ return formula.replace(/\{([^}]+)\}/g, '"$1"');
169
+ }
170
+
171
+ // --- Persistence ---
172
+
173
+ private async load(): Promise<void> {
174
+ const csvPath = this.csvPath;
175
+ const exists = existsSync(csvPath);
176
+ const currentSize = exists ? statSync(csvPath).size : -1;
177
+
178
+ if (this.loaded && currentSize === this._lastLoadSize) return;
179
+
180
+ // For very large files, we only load headers and formatting into the engine
181
+ // and use streaming for data operations.
182
+ if (exists && currentSize > this._isLargeFileThreshold) {
183
+ await this.loadHeadersOnly();
184
+ return;
185
+ }
186
+
187
+ const release = await this._lock.acquire();
188
+ try {
189
+ if (exists) {
190
+ const csv = await fs.readFile(csvPath, 'utf-8');
191
+ if (csv.trim().length > 0) {
192
+ this.engine = CsvEngine.fromCSV(csv);
193
+ this.loaded = true;
194
+ this._lastLoadSize = currentSize;
195
+ this._lastFileSize = currentSize;
196
+ return;
197
+ }
198
+ }
199
+
200
+ this.engine = new CsvEngine();
201
+ this.loaded = true;
202
+ this._lastLoadSize = currentSize;
203
+ this._lastFileSize = exists ? currentSize : 0;
204
+ } finally {
205
+ release();
206
+ }
207
+ }
208
+
209
+ private async loadHeadersOnly(): Promise<void> {
210
+ const release = await this._lock.acquire();
211
+ try {
212
+ const generator = this.getLineGenerator();
213
+ const headersLine = await generator.next();
214
+ const formatLine = await generator.next();
215
+
216
+ let csv = (headersLine.value || '') + '\n';
217
+ if (formatLine.value?.startsWith('#fmt:')) {
218
+ csv += formatLine.value + '\n';
219
+ }
220
+
221
+ this.engine = CsvEngine.fromCSV(csv);
222
+ this.loaded = true;
223
+ this._lastLoadSize = statSync(this.csvPath).size;
224
+ this._lastFileSize = this._lastLoadSize;
225
+ } finally {
226
+ release();
227
+ }
228
+ }
229
+
230
+ private async save(): Promise<void> {
231
+ const release = await this._lock.acquire();
232
+ try {
233
+ const dir = path.dirname(this.csvPath);
234
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
235
+
236
+ const formatRow = this.shouldWriteFormatRow();
237
+ const csv = this.engine.toCSV({ formatRow });
238
+ await fs.writeFile(this.csvPath, csv, 'utf-8');
239
+
240
+ // Update size to prevent immediate reload
241
+ this._lastLoadSize = statSync(this.csvPath).size;
242
+ this._lastFileSize = this._lastLoadSize;
243
+ } finally {
244
+ release();
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Optimized append: writes directly to end of file without rewriting
250
+ */
251
+ private async appendRows(rows: string[][]): Promise<void> {
252
+ const release = await this._lock.acquire();
253
+ try {
254
+ const csvPath = this.csvPath;
255
+ const csvData = rows.map((r) => r.join(',')).join('\n') + '\n';
256
+
257
+ // Ensure file ends with newline before appending
258
+ const stats = statSync(csvPath);
259
+ if (stats.size > 0) {
260
+ const fd = await fs.open(csvPath, 'r+');
261
+ const buffer = Buffer.alloc(1);
262
+ await fd.read(buffer, 0, 1, stats.size - 1);
263
+ if (buffer.toString() !== '\n') {
264
+ await fd.write('\n', stats.size);
265
+ }
266
+ await fd.close();
267
+ }
268
+
269
+ await fs.appendFile(csvPath, csvData, 'utf-8');
270
+ this._lastLoadSize = statSync(csvPath).size;
271
+ this._lastFileSize = this._lastLoadSize;
272
+ } finally {
273
+ release();
274
+ }
275
+ }
276
+
277
+ private shouldWriteFormatRow(): boolean {
278
+ const fmt = this.settings?.formatting || 'auto';
279
+ if (fmt === 'true') return true;
280
+ if (fmt === 'false') return false;
281
+ return this.engine.getHasFormatRow() || this.engine.getMetaCustomized();
282
+ }
283
+
284
+ // --- Response formatting ---
285
+
286
+ private buildResponse(message: string): Record<string, any> {
287
+ const snap = this.engine.snapshot(message);
288
+ return { ...snap, file: this.csvPath };
289
+ }
290
+
291
+ // --- Tool methods ---
292
+
293
+ /**
294
+ * Open spreadsheet UI
295
+ *
296
+ * @ui spreadsheet
297
+ * @autorun
298
+ */
299
+ async main() {
300
+ await this.load();
301
+ return this.buildResponse(`Opened ${path.basename(this.csvPath)}`);
302
+ }
303
+
304
+ /**
305
+ * List all tables (CSV files) in the spreadsheets folder
306
+ * @title List Tables
307
+ */
308
+ async list_tables() {
309
+ const dir = this.defaultFolder;
310
+ if (!existsSync(dir)) return { tables: [], message: 'No tables found' };
311
+
312
+ const files = await fs.readdir(dir);
313
+ const tables = files
314
+ .filter((f) => f.endsWith('.csv'))
315
+ .map((f) => ({
316
+ name: f.replace('.csv', ''),
317
+ file: f,
318
+ size: statSync(path.join(dir, f)).size,
319
+ modified: statSync(path.join(dir, f)).mtime.toISOString(),
320
+ }));
321
+
322
+ return {
323
+ tables,
324
+ count: tables.length,
325
+ message: `Found ${tables.length} table(s) in ${dir}`,
326
+ };
327
+ }
328
+
329
+ /**
330
+ * View records (Airtable-compatible)
331
+ *
332
+ * @param maxRecords Max records to return (default 100)
333
+ * @param offset Row offset for pagination
334
+ * @param filterByFormula Optional formula to filter records (e.g. "{Age} > 25")
335
+ * @title List Records
336
+ * @format table
337
+ */
338
+ async list_records(params: { maxRecords?: number; offset?: number; filterByFormula?: string }) {
339
+ await this.load();
340
+
341
+ if (params.filterByFormula) {
342
+ const sqlWhere = this._processAirtableFormula(params.filterByFormula);
343
+ // Try to use the engine's query capability with the processed formula
344
+ try {
345
+ const results = await this.engine.query(sqlWhere, params.maxRecords);
346
+ return results;
347
+ } catch (err: any) {
348
+ console.warn('Filter formula failed, returning all records:', err.message);
349
+ }
350
+ }
351
+
352
+ return this.view({
353
+ limit: params.maxRecords,
354
+ offset: params.offset,
355
+ });
356
+ }
357
+
358
+ /**
359
+ * Lookup a value from another table
360
+ *
361
+ * Mimics the relational "Lookup" field in Airtable/Google Sheets.
362
+ *
363
+ * @param table Other table name (e.g. "Products")
364
+ * @param matchField Field in the other table to match against (e.g. "SKU")
365
+ * @param matchValue Value to search for (e.g. "A101")
366
+ * @param resultField Field to return from the matching row (e.g. "Price")
367
+ * @example lookup({ table: 'Products', matchField: 'SKU', matchValue: 'A101', resultField: 'Price' })
368
+ */
369
+ async lookup(params: {
370
+ table: string;
371
+ matchField: string;
372
+ matchValue: string;
373
+ resultField: string;
374
+ }) {
375
+ const otherEngine = await this._getCsvEngine(params.table);
376
+ const where = `${params.matchField} = "${params.matchValue}"`;
377
+ const result = await otherEngine.query(where, 1);
378
+
379
+ if (result && result.data && result.data.length > 0) {
380
+ const headers = otherEngine.getHeaders();
381
+ const resIdx = headers.indexOf(params.resultField);
382
+ if (resIdx !== -1) {
383
+ return { value: result.data[0][resIdx], message: 'Found match' };
384
+ }
385
+ }
386
+
387
+ return { value: null, message: 'No match found' };
388
+ }
389
+
390
+ /**
391
+ * Get a single record by its row number
392
+ * @param recordId Row number (1-indexed)
393
+ * @title Get Record
394
+ */
395
+ async get_record(params: { recordId: number }) {
396
+ await this.load();
397
+ const row = this.engine.getRow(params.recordId);
398
+ if (!row) throw new Error(`Record ${params.recordId} not found`);
399
+ return { id: params.recordId, fields: row };
400
+ }
401
+
402
+ /**
403
+ * Create a new record (Airtable-compatible)
404
+ * @param fields Key-value pairs for the new record
405
+ * @title Create Record
406
+ */
407
+ async create_record(params: { fields: Record<string, string> }) {
408
+ return this.add({ values: params.fields });
409
+ }
410
+
411
+ /**
412
+ * Update an existing record (Airtable-compatible)
413
+ * @param recordId Row number (1-indexed)
414
+ * @param fields Key-value pairs to update
415
+ * @title Update Record
416
+ */
417
+ async update_record(params: { recordId: number; fields: Record<string, string> }) {
418
+ return this.update({ row: params.recordId, values: params.fields });
419
+ }
420
+
421
+ /**
422
+ * Delete a record (Airtable-compatible)
423
+ * @param recordId Row number (1-indexed)
424
+ * @title Delete Record
425
+ */
426
+ async delete_record(params: { recordId: number }) {
427
+ return this.remove({ row: params.recordId });
428
+ }
429
+
430
+ /**
431
+ * Search for records (Airtable-compatible)
432
+ * @param query Text to search for
433
+ * @title Search Records
434
+ */
435
+ async search_records(params: { query: string }) {
436
+ return this.search({ query: params.query });
437
+ }
438
+
439
+ /**
440
+ * View the spreadsheet grid
441
+ *
442
+ * Returns the full spreadsheet or a specific range as a formatted table.
443
+ *
444
+ * @param range Optional cell range to view (e.g., "A1:D10")
445
+ * @param offset Row offset for pagination
446
+ * @param limit Max rows to return (defaults to settings.pageSize)
447
+ * @format table
448
+ */
449
+ async view(params?: { range?: string; offset?: number; limit?: number }) {
450
+ await this.load();
451
+
452
+ if (params?.range) {
453
+ return {
454
+ ...this.buildResponse(`Viewing range ${params.range}`),
455
+ table: this.engine.toTable(params.range),
456
+ };
457
+ }
458
+
459
+ const limit = params?.limit || this.settings.pageSize;
460
+ const offset = params?.offset || 0;
461
+
462
+ // For the UI, we still return the snapshot, but we can respect the pagination hints
463
+ return {
464
+ ...this.buildResponse(path.basename(this.csvPath)),
465
+ offset,
466
+ limit,
467
+ total: this.engine.rowCount,
468
+ };
469
+ }
470
+
471
+ /**
472
+ * Get a cell value
473
+ *
474
+ * Returns the evaluated value and raw content (formula if any) for a single cell.
475
+ *
476
+ * @param cell Cell reference in A1 notation (e.g., "B3")
477
+ */
478
+ async get(params: { cell: string }) {
479
+ await this.load();
480
+
481
+ const { row, col } = cellToIndex(params.cell);
482
+
483
+ if (row >= this.engine.rowCount || col >= this.engine.colCount) {
484
+ return { cell: params.cell, value: '', raw: '', message: 'Cell is empty' };
485
+ }
486
+
487
+ const raw = this.engine.getRawCell(row, col);
488
+ const value = this.engine.evaluate(row, col);
489
+
490
+ return {
491
+ cell: params.cell,
492
+ value,
493
+ raw,
494
+ message: raw.startsWith('=')
495
+ ? `${params.cell} = ${value} (${raw})`
496
+ : `${params.cell} = ${value || '(empty)'}`,
497
+ };
498
+ }
499
+
500
+ /**
501
+ * Set a cell value or formula
502
+ *
503
+ * Set a cell to a plain value or a formula starting with "=".
504
+ * Formulas support: SUM, AVG, MAX, MIN, COUNT, IF, LEN, ABS, ROUND, CONCAT.
505
+ * Cell references use A1 notation. Ranges use A1:B2 notation.
506
+ *
507
+ * @param cell Cell reference in A1 notation (e.g., "B3")
508
+ * @param value Value or formula (e.g., "42" or "=SUM(A1:A5)")
509
+ * @example set({ cell: 'B3', value: '=SUM(B1:B2)' })
510
+ */
511
+ async set(params: { cell: string; value: string }) {
512
+ await this.load();
513
+
514
+ const { row, col } = cellToIndex(params.cell);
515
+
516
+ const oldRaw = this.engine.getRawCell(row, col);
517
+ const oldDisplay = oldRaw.startsWith('=') ? this.engine.evaluate(row, col) : oldRaw;
518
+
519
+ this.engine.set(params.cell, String(params.value));
520
+ await this.save();
521
+
522
+ const evaluated = this.engine.evaluate(row, col);
523
+ const msg = oldRaw
524
+ ? `Set ${params.cell} = ${evaluated} (was: ${oldDisplay})`
525
+ : `Set ${params.cell} = ${evaluated}`;
526
+
527
+ await this._emitAndWatch(msg);
528
+ return this.buildResponse(msg);
529
+ }
530
+
531
+ /**
532
+ * Add a row of data
533
+ *
534
+ * Add a new row to the bottom of the spreadsheet. Pass column values by header name.
535
+ *
536
+ * @param values Key-value pairs mapping column names to values (e.g., {"Name": "Alice", "Age": "30"})
537
+ * @example add({ values: { Name: 'Alice', Age: '30' } })
538
+ */
539
+ async add(params: { values: Record<string, string> }) {
540
+ await this.load();
541
+
542
+ // If file is large and we are just adding a simple row, use optimized append
543
+ const isLarge = statSync(this.csvPath).size > this._isLargeFileThreshold;
544
+ const canAppend = !this.engine.getMetaCustomized(); // Simple append if no formula-heavy meta
545
+
546
+ if (isLarge && canAppend) {
547
+ const rowValues = this.engine.getHeaders().map((h: any) => params.values[h] || '');
548
+ await this.appendRows([rowValues]);
549
+ // Update engine state in memory without full reload if possible
550
+ this.engine.add(params.values);
551
+ const msg = `Appended row to large CSV`;
552
+ await this._emitAndWatch(msg);
553
+ return this.buildResponse(msg);
554
+ }
555
+
556
+ const rowNum = this.engine.add(params.values);
557
+ await this.save();
558
+
559
+ const msg = `Added row ${rowNum}`;
560
+ await this._emitAndWatch(msg);
561
+ return this.buildResponse(msg);
562
+ }
563
+
564
+ /**
565
+ * Remove a row
566
+ *
567
+ * @param row Row number to remove (1-indexed)
568
+ */
569
+ async remove(params: { row: number }) {
570
+ await this.load();
571
+
572
+ this.engine.remove(params.row);
573
+ await this.save();
574
+
575
+ const msg = `Removed row ${params.row}`;
576
+ await this._emitAndWatch(msg);
577
+ return this.buildResponse(msg);
578
+ }
579
+
580
+ /**
581
+ * Remove a column
582
+ *
583
+ * @param column Column letter or header name to remove
584
+ */
585
+ async removeColumn(params: { column: string }) {
586
+ await this.load();
587
+
588
+ const oldName = this.engine.removeColumn(params.column);
589
+ await this.save();
590
+
591
+ const msg = `Removed column "${oldName}"`;
592
+ await this._emitAndWatch(msg);
593
+ return this.buildResponse(msg);
594
+ }
595
+
596
+ /**
597
+ * Insert a row at a specific position
598
+ *
599
+ * @param row Row number to insert before (1-indexed)
600
+ * @param values Optional: Key-value pairs for the new row
601
+ */
602
+ async insertRow(params: { row: number; values?: Record<string, string> }) {
603
+ await this.load();
604
+
605
+ this.engine.insertRow(params.row, params.values);
606
+ await this.save();
607
+
608
+ const msg = `Inserted row at ${params.row}`;
609
+ await this._emitAndWatch(msg);
610
+ return this.buildResponse(msg);
611
+ }
612
+
613
+ /**
614
+ * Insert a column at a specific position
615
+ *
616
+ * @param column Column letter or header name to insert before
617
+ * @param name Name for the new column
618
+ */
619
+ async insertColumn(params: { column: string; name: string }) {
620
+ await this.load();
621
+
622
+ this.engine.insertColumn(params.column, params.name);
623
+ await this.save();
624
+
625
+ const msg = `Inserted column "${params.name}" before ${params.column}`;
626
+ await this._emitAndWatch(msg);
627
+ return this.buildResponse(msg);
628
+ }
629
+
630
+ /**
631
+ * Update or insert a row (Database Upsert)
632
+ *
633
+ * Finds a row matching the `search` criteria. If found, updates it with `values`.
634
+ * If not found, adds a new row with `values`.
635
+ *
636
+ * @param search Key-value pairs to match an existing row (e.g., {"ID": "123"})
637
+ * @param values Key-value pairs to set in the row
638
+ * @example upsert({ search: { ID: '101' }, values: { Name: 'Alice', Status: 'Active' } })
639
+ */
640
+ async upsert(params: { search: Record<string, string>; values: Record<string, string> }) {
641
+ await this.load();
642
+
643
+ const result = this.engine.upsert(params.search, params.values);
644
+ await this.save();
645
+
646
+ const msg =
647
+ result.type === 'update'
648
+ ? `Updated matching row ${result.row}`
649
+ : `Added new row ${result.row}`;
650
+
651
+ await this._emitAndWatch(msg);
652
+ return { ...this.buildResponse(msg), ...result };
653
+ }
654
+
655
+ /**
656
+ * Fuzzy search across columns
657
+ *
658
+ * Finds rows containing the query string in any of the specified columns.
659
+ * Uses streaming generator for memory efficiency on large files.
660
+ *
661
+ * @param query Search string
662
+ * @param columns Optional: array of column names to search (searches all if omitted)
663
+ * @param limit Max results to return
664
+ * @format table
665
+ */
666
+ async search(params: { query: string; columns?: string[]; limit?: number }) {
667
+ const csvPath = this.csvPath;
668
+ const isLarge = existsSync(csvPath) && statSync(csvPath).size > this._isLargeFileThreshold;
669
+ const limit = params.limit || 50;
670
+
671
+ if (isLarge) {
672
+ const results: any[] = [];
673
+ const query = params.query.toLowerCase();
674
+
675
+ const generator = this.getLineGenerator();
676
+ const headersResult = await generator.next();
677
+ const headers = headersResult.value?.split(',') || [];
678
+
679
+ const searchIndices = params.columns
680
+ ? params.columns.map((c: string) => headers.indexOf(c)).filter((i: number) => i !== -1)
681
+ : headers.map((_: any, i: number) => i);
682
+
683
+ for await (const line of generator) {
684
+ if (line.startsWith('#fmt:')) continue;
685
+ const cells = line.split(',');
686
+ const match = searchIndices.some((i: number) => cells[i]?.toLowerCase().includes(query));
687
+
688
+ if (match) {
689
+ const row: any = {};
690
+ headers.forEach((h: any, i: any) => (row[h] = cells[i]));
691
+ results.push(row);
692
+ if (results.length >= limit) break;
693
+ }
694
+ }
695
+ return results;
696
+ }
697
+
698
+ await this.load();
699
+ return this.engine.search(params.query, params.columns, params.limit);
700
+ }
701
+
702
+ /**
703
+ * Update fields in a row
704
+ *
705
+ * @param row Row number to update (1-indexed)
706
+ * @param values Key-value pairs mapping column names to new values
707
+ * @example update({ row: 3, values: { Age: '42' } })
708
+ */
709
+ async update(params: { row: number; values: Record<string, string> }) {
710
+ await this.load();
711
+
712
+ const changes = this.engine.update(params.row, params.values);
713
+ await this.save();
714
+
715
+ const msg = `Updated row ${params.row}: ${changes.join(', ')}`;
716
+ await this._emitAndWatch(msg);
717
+ return this.buildResponse(msg);
718
+ }
719
+
720
+ /**
721
+ * Query rows by condition
722
+ *
723
+ * Filter rows where a column matches a condition. Supports: =, !=, >, <, >=, <=, contains.
724
+ *
725
+ * @param where Condition string (e.g., "Age > 25", "Name contains Ali")
726
+ * @param limit Max rows to return
727
+ * @example query({ where: 'Age > 25' })
728
+ */
729
+ async query(params: { where: string; limit?: number }) {
730
+ await this.load();
731
+ return this.engine.query(params.where, params.limit);
732
+ }
733
+
734
+ /**
735
+ * Sort by column
736
+ *
737
+ * Sorts all data rows by the specified column.
738
+ *
739
+ * @param column Column name or letter to sort by
740
+ * @param order Sort order: "asc" or "desc" (default: "asc")
741
+ * @example sort({ column: 'Age', order: 'desc' })
742
+ */
743
+ async sort(params: { column: string; order?: string }) {
744
+ await this.load();
745
+
746
+ this.engine.sort(params.column, params.order);
747
+ await this.save();
748
+
749
+ const msg = `Sorted by ${params.column} (${params.order || 'asc'})`;
750
+ await this._emitAndWatch(msg);
751
+ return this.buildResponse(msg);
752
+ }
753
+
754
+ /**
755
+ * Fill a range with values or a pattern
756
+ *
757
+ * @param range Cell range (e.g., "A1:A10")
758
+ * @param pattern Comma-separated values to repeat (e.g., "1,2,3")
759
+ * @example fill({ range: 'A1:A10', pattern: '1,2,3' })
760
+ */
761
+ async fill(params: { range: string; pattern: string }) {
762
+ await this.load();
763
+
764
+ this.engine.fill(params.range, params.pattern);
765
+ await this.save();
766
+
767
+ const msg = `Filled ${params.range} with pattern [${params.pattern}]`;
768
+ await this._emitAndWatch(msg);
769
+ return this.buildResponse(msg);
770
+ }
771
+
772
+ /**
773
+ * Show column headers and detected types
774
+ *
775
+ * @format table
776
+ */
777
+ async schema() {
778
+ await this.load();
779
+
780
+ const schema = this.engine.schema();
781
+ const table =
782
+ '| Column | Type | Non-empty | Total |\n|--------|------|-----------|-------|\n' +
783
+ schema.map((s: any) => `| ${s.column} | ${s.type} | ${s.nonEmpty} | ${s.total} |`).join('\n');
784
+
785
+ return {
786
+ table,
787
+ schema,
788
+ headers: this.engine.getHeaders(),
789
+ message: `${this.engine.colCount} columns, ${this.engine.rowCount} rows`,
790
+ file: this.csvPath,
791
+ };
792
+ }
793
+
794
+ /**
795
+ * Resize the spreadsheet grid
796
+ *
797
+ * @param rows New number of rows
798
+ * @param cols New number of columns
799
+ */
800
+ async resize(params: { rows?: number; cols?: number }) {
801
+ await this.load();
802
+
803
+ this.engine.resize(params.rows, params.cols);
804
+ await this.save();
805
+
806
+ return this.buildResponse(
807
+ `Resized to ${this.engine.rowCount} rows x ${this.engine.colCount} cols`
808
+ );
809
+ }
810
+
811
+ /**
812
+ * Import CSV data
813
+ *
814
+ * Load data from a CSV file path or raw CSV text. The first row is treated as headers.
815
+ *
816
+ * @param file Path to a CSV file to import
817
+ * @param csv Raw CSV text to import (alternative to file)
818
+ * @example ingest({ csv: 'Name,Age\\nAlice,30\\nBob,25' })
819
+ */
820
+ async ingest(params: { file?: string; csv?: string }) {
821
+ let csvText: string;
822
+
823
+ if (params.file) {
824
+ csvText = await fs.readFile(params.file, 'utf-8');
825
+ } else if (params.csv) {
826
+ csvText = params.csv;
827
+ } else {
828
+ throw new Error('Provide either "file" path or "csv" text');
829
+ }
830
+
831
+ this.engine = CsvEngine.fromCSV(csvText);
832
+ this.loaded = true;
833
+
834
+ await this.save();
835
+
836
+ const msg = `Imported ${this.engine.rowCount} rows x ${this.engine.colCount} cols`;
837
+ await this._emitAndWatch(msg);
838
+ return this.buildResponse(msg);
839
+ }
840
+
841
+ /**
842
+ * Export as CSV
843
+ *
844
+ * Returns the raw CSV content (with formulas preserved), or saves to a file.
845
+ *
846
+ * @param file Optional file path to save CSV to
847
+ */
848
+ async dump(params?: { file?: string }) {
849
+ await this.load();
850
+
851
+ const csvText = this.engine.toCSV({ formatRow: this.shouldWriteFormatRow() });
852
+
853
+ if (params?.file) {
854
+ const dir = path.dirname(params.file);
855
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
856
+ await fs.writeFile(params.file, csvText, 'utf-8');
857
+ return { message: `Exported to ${params.file}`, file: params.file };
858
+ }
859
+
860
+ const lineCount = csvText.split('\n').filter((l: string) => l.length > 0).length - 1;
861
+ return { csv: csvText, message: `CSV export (${lineCount} rows)` };
862
+ }
863
+
864
+ /**
865
+ * Clear cells
866
+ *
867
+ * Clear all cells or a specific range.
868
+ *
869
+ * @param range Optional range to clear (e.g., "B:B" or "A1:C5"). Clears all if omitted.
870
+ */
871
+ async clear(params?: { range?: string }) {
872
+ await this.load();
873
+
874
+ this.engine.clear(params?.range);
875
+ await this.save();
876
+
877
+ const msg = params?.range ? `Cleared range ${params.range}` : 'Cleared all cells';
878
+ await this._emitAndWatch(msg);
879
+ return this.buildResponse(msg);
880
+ }
881
+
882
+ /**
883
+ * Rename a column header
884
+ *
885
+ * @param column Column letter or current header name
886
+ * @param name New header name
887
+ * @example rename({ column: 'A', name: 'Product' })
888
+ */
889
+ async rename(params: { column: string; name: string }) {
890
+ await this.load();
891
+
892
+ const old = this.engine.rename(params.column, params.name);
893
+ await this.save();
894
+
895
+ return this.buildResponse(`Renamed column: "${old}" -> "${params.name}"`);
896
+ }
897
+
898
+ /**
899
+ * Set column formatting
900
+ *
901
+ * Set alignment, type, or width for a column. This creates a format row in the CSV
902
+ * when formatting is set to "auto" (default).
903
+ *
904
+ * @param column Column letter or header name
905
+ * @param align Alignment: "left", "right", or "center"
906
+ * @param type Column type: "text", "number", "currency", "percent", "date", "bool", "select", "formula", "markdown", "longtext"
907
+ * @param width Column width in pixels
908
+ * @param wrap Enable text wrapping for this column
909
+ * @example format({ column: 'B', align: 'right', type: 'number' })
910
+ * @example format({ column: 'C', type: 'markdown', wrap: true })
911
+ */
912
+ async format(params: {
913
+ column: string;
914
+ align?: string;
915
+ type?: string;
916
+ width?: number;
917
+ wrap?: boolean;
918
+ }) {
919
+ await this.load();
920
+
921
+ this.engine.format(params.column, {
922
+ align: params.align,
923
+ type: params.type,
924
+ width: params.width,
925
+ wrap: params.wrap,
926
+ });
927
+ await this.save();
928
+
929
+ const changes = [
930
+ params.align && `align=${params.align}`,
931
+ params.type && `type=${params.type}`,
932
+ params.width !== undefined && `width=${params.width}`,
933
+ params.wrap !== undefined && `wrap=${params.wrap}`,
934
+ ]
935
+ .filter(Boolean)
936
+ .join(', ');
937
+
938
+ const msg = `Formatted ${params.column}: ${changes}`;
939
+ await this._emitAndWatch(msg);
940
+ return this.buildResponse(msg);
941
+ }
942
+
943
+ /**
944
+ * Watch the CSV file for appended rows
945
+ *
946
+ * Starts watching the underlying CSV file. When external processes append rows,
947
+ * the spreadsheet updates in real-time. Use `unwatch` to stop.
948
+ */
949
+ async tail() {
950
+ await this.load();
951
+
952
+ if (this._watcher) {
953
+ return {
954
+ message: `Already watching ${path.basename(this.csvPath)}`,
955
+ file: this.csvPath,
956
+ watching: true,
957
+ };
958
+ }
959
+
960
+ this._startFileWatching();
961
+ this._watcherAutoStarted = false;
962
+
963
+ const msg = `Watching ${path.basename(this.csvPath)} for changes`;
964
+ return { ...this.buildResponse(msg), watching: true };
965
+ }
966
+
967
+ /**
968
+ * Stop watching the CSV file
969
+ *
970
+ * Stops the file watcher started by `tail`.
971
+ */
972
+ async untail() {
973
+ this._watcherAutoStarted = false;
974
+ this._stopFileWatching();
975
+ return { message: 'Stopped watching', watching: false };
976
+ }
977
+
978
+ /**
979
+ * Append rows to the spreadsheet
980
+ *
981
+ * Batch-append one or more rows. Each row is a list of values matching
982
+ * the column order, or a key-value object mapping column names to values.
983
+ * Emits after all rows are added so charts and UIs update once.
984
+ *
985
+ * @param rows Array of rows to append. Each row is either an array of values or a {column: value} object.
986
+ * @example push({ rows: [["Alice", "30"], ["Bob", "25"]] })
987
+ * @example push({ rows: [{"Name": "Alice", "Age": "30"}] })
988
+ */
989
+ async push(params: { rows: (string[] | Record<string, string>)[] }) {
990
+ await this.load();
991
+
992
+ const isLarge = statSync(this.csvPath).size > this._isLargeFileThreshold;
993
+ if (isLarge) {
994
+ const headers = this.engine.getHeaders();
995
+ const rowsToAppend = params.rows.map((row) => {
996
+ if (Array.isArray(row)) return row;
997
+ return headers.map((h: any) => row[h] || '');
998
+ });
999
+
1000
+ await this.appendRows(rowsToAppend);
1001
+ // Synchronize engine row count
1002
+ for (const r of params.rows)
1003
+ this.engine.add(typeof r === 'object' && !Array.isArray(r) ? r : {});
1004
+
1005
+ const msg = `Stream-pushed ${rowsToAppend.length} rows`;
1006
+ await this._emitAndWatch(msg, { autoScroll: true });
1007
+ return this.buildResponse(msg);
1008
+ }
1009
+
1010
+ const added = this.engine.push(params.rows);
1011
+ await this.save();
1012
+
1013
+ const msg = `Pushed ${added} row(s) (${this.engine.rowCount} total)`;
1014
+ await this._emitAndWatch(msg, { autoScroll: true });
1015
+ return this.buildResponse(msg);
1016
+ }
1017
+
1018
+ /** Handle file change detected by watcher */
1019
+ private async _onFileChanged(): Promise<void> {
1020
+ try {
1021
+ const csvPath = this.csvPath;
1022
+ if (!existsSync(csvPath)) return;
1023
+
1024
+ const currentSize = statSync(csvPath).size;
1025
+ if (currentSize <= this._lastFileSize) {
1026
+ if (currentSize < this._lastFileSize) {
1027
+ this.loaded = false;
1028
+ await this.load();
1029
+ this._lastFileSize = currentSize;
1030
+ await this._emitAndWatch('File reloaded (truncated)', { autoScroll: false });
1031
+ }
1032
+ return;
1033
+ }
1034
+
1035
+ // Read only the new bytes appended since last check
1036
+ const fd = await fs.open(csvPath, 'r');
1037
+ const newBytes = Buffer.alloc(currentSize - this._lastFileSize);
1038
+ await fd.read(newBytes, 0, newBytes.length, this._lastFileSize);
1039
+ await fd.close();
1040
+ this._lastFileSize = currentSize;
1041
+
1042
+ const newText = newBytes.toString('utf-8');
1043
+ const newLines = newText.split('\n').filter((l) => l.trim().length > 0);
1044
+
1045
+ if (newLines.length === 0) return;
1046
+
1047
+ const added = this.engine.appendCSVLines(newLines);
1048
+
1049
+ if (added > 0) {
1050
+ const msg = `+${added} row(s) from file (${this.engine.rowCount} total)`;
1051
+ await this._emitAndWatch(msg, { autoScroll: true });
1052
+ }
1053
+ } catch (err) {
1054
+ console.error('File watch handler error:', err);
1055
+ }
1056
+ }
1057
+
1058
+ // --- SQL & Watch tools ---
1059
+
1060
+ /**
1061
+ * Run a SQL query on the spreadsheet data
1062
+ *
1063
+ * Query the spreadsheet using SQL syntax. Table name is `data`.
1064
+ * Column names with spaces or special characters need double quotes.
1065
+ *
1066
+ * @param query SQL query string (e.g., "SELECT * FROM data WHERE Age > 25")
1067
+ * @example sql({ query: "SELECT Name, Age FROM data WHERE Age > 25 ORDER BY Age DESC" })
1068
+ * @example sql({ query: "SELECT COUNT(*) as total FROM data" })
1069
+ */
1070
+ async sql(params: { query: string }) {
1071
+ await this.load();
1072
+ return await this.engine.sql(params.query);
1073
+ }
1074
+
1075
+ /**
1076
+ * Create a live SQL watch
1077
+ *
1078
+ * Registers a named SQL query that re-runs after every data change.
1079
+ * When the query returns rows, an alert is emitted. Optionally triggers
1080
+ * a cross-photon action (e.g., "slack.send").
1081
+ *
1082
+ * @param name Unique watch name (e.g., "price-alert")
1083
+ * @param query SQL query — fires when it returns rows (e.g., "SELECT * FROM data WHERE Price < 50")
1084
+ * @param action Optional cross-photon call target (e.g., "slack.send")
1085
+ * @param actionParams Optional parameters passed to the action
1086
+ * @param once If true, auto-removes after first trigger
1087
+ * @example watch({ name: "big-orders", query: "SELECT * FROM data WHERE Amount > 1000" })
1088
+ * @example watch({ name: "notify", query: "SELECT * FROM data WHERE Status = 'critical'", action: "slack.send", actionParams: { text: "Critical row found!" }, once: true })
1089
+ */
1090
+ async watch(params: {
1091
+ name: string;
1092
+ query: string;
1093
+ action?: string;
1094
+ actionParams?: Record<string, any>;
1095
+ once?: boolean;
1096
+ }) {
1097
+ await this.load();
1098
+
1099
+ const def: WatchDef = {
1100
+ name: params.name,
1101
+ query: params.query,
1102
+ action: params.action,
1103
+ actionParams: params.actionParams,
1104
+ once: params.once ?? false,
1105
+ triggerCount: 0,
1106
+ };
1107
+
1108
+ this._watches.set(params.name, def);
1109
+
1110
+ if (!this._watcher) {
1111
+ this._startFileWatching();
1112
+ this._watcherAutoStarted = true;
1113
+ }
1114
+
1115
+ // Run query immediately to check current state
1116
+ let currentMatches = 0;
1117
+ try {
1118
+ const result = await this.engine.sql(params.query);
1119
+ currentMatches = Array.isArray(result.result) ? result.result.length : 0;
1120
+ } catch {
1121
+ /* validation happens on first real run */
1122
+ }
1123
+
1124
+ return {
1125
+ message: `Watch "${params.name}" created${currentMatches > 0 ? ` (${currentMatches} rows match now)` : ''}`,
1126
+ watch: params.name,
1127
+ currentMatches,
1128
+ watches: this._watches.size,
1129
+ };
1130
+ }
1131
+
1132
+ /**
1133
+ * Remove a live SQL watch
1134
+ *
1135
+ * @param name Watch name to remove
1136
+ */
1137
+ async unwatch(params: { name: string }) {
1138
+ const existed = this._watches.delete(params.name);
1139
+
1140
+ if (this._watches.size === 0 && this._watcherAutoStarted) {
1141
+ this._stopFileWatching();
1142
+ this._watcherAutoStarted = false;
1143
+ }
1144
+
1145
+ return {
1146
+ message: existed ? `Removed watch "${params.name}"` : `Watch "${params.name}" not found`,
1147
+ watches: this._watches.size,
1148
+ };
1149
+ }
1150
+
1151
+ /**
1152
+ * List active SQL watches
1153
+ *
1154
+ * Shows all registered watches with their trigger counts and status.
1155
+ */
1156
+ async watches() {
1157
+ const list = Array.from(this._watches.values()).map((w) => ({
1158
+ name: w.name,
1159
+ query: w.query,
1160
+ action: w.action,
1161
+ once: w.once,
1162
+ triggerCount: w.triggerCount,
1163
+ lastTriggered: w.lastTriggered || null,
1164
+ }));
1165
+
1166
+ return {
1167
+ watches: list,
1168
+ count: list.length,
1169
+ message: list.length > 0 ? `${list.length} active watch(es)` : 'No active watches',
1170
+ };
1171
+ }
1172
+
1173
+ // --- File watching helpers ---
1174
+
1175
+ private _startFileWatching(): void {
1176
+ if (this._watcher) return;
1177
+
1178
+ const csvPath = this.csvPath;
1179
+ if (!existsSync(csvPath)) return;
1180
+
1181
+ this._lastFileSize = statSync(csvPath).size;
1182
+ this._watcher = fsWatch(csvPath, () => {
1183
+ if (this._watchDebounce) clearTimeout(this._watchDebounce);
1184
+ this._watchDebounce = setTimeout(() => {
1185
+ void this._onFileChanged();
1186
+ }, 200);
1187
+ });
1188
+ }
1189
+
1190
+ private _stopFileWatching(): void {
1191
+ if (this._watcher) {
1192
+ this._watcher.close();
1193
+ this._watcher = null;
1194
+ if (this._watchDebounce) {
1195
+ clearTimeout(this._watchDebounce);
1196
+ this._watchDebounce = null;
1197
+ }
1198
+ }
1199
+ }
1200
+
1201
+ // --- Emit + watch pipeline ---
1202
+
1203
+ private async _emitAndWatch(msg: string, opts: Record<string, any> = {}): Promise<void> {
1204
+ this.emit({ emit: 'data', ...this.buildResponse(msg), ...opts });
1205
+ await this._rerunWatches();
1206
+ }
1207
+
1208
+ private async _rerunWatches(): Promise<void> {
1209
+ if (this._watches.size === 0) return;
1210
+
1211
+ for (const [name, watch] of this._watches) {
1212
+ try {
1213
+ const result = await this.engine.sql(watch.query);
1214
+ if (Array.isArray(result.result) && result.result.length > 0) {
1215
+ watch.triggerCount++;
1216
+ watch.lastTriggered = new Date().toISOString();
1217
+ this.emit({
1218
+ emit: 'alert',
1219
+ watch: name,
1220
+ rows: result.result,
1221
+ count: result.result.length,
1222
+ });
1223
+
1224
+ if (watch.action && this.call) {
1225
+ try {
1226
+ await this.call(watch.action, { ...watch.actionParams, _matchedRows: result.result });
1227
+ } catch (err) {
1228
+ console.error(`Watch "${name}" action failed:`, err);
1229
+ }
1230
+ }
1231
+
1232
+ if (watch.once) this._watches.delete(name);
1233
+ }
1234
+ } catch (err) {
1235
+ console.error(`Watch "${name}" query error:`, err);
1236
+ }
1237
+ }
1238
+ }
1239
+ }