n8n-nodes-variable 1.0.9 → 1.1.0
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 +53 -0
- package/dist/credentials/N8nVariableNodeApi.credentials.d.ts +7 -0
- package/dist/credentials/N8nVariableNodeApi.credentials.js +31 -0
- package/dist/nodes/Variable/Variable.node.js +235 -3
- package/dist/nodes/Variable/helpers/dataTableStorage.d.ts +8 -0
- package/dist/nodes/Variable/helpers/dataTableStorage.js +162 -0
- package/dist/nodes/Variable/helpers/types.d.ts +1 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ The **Variable** node lets you store, retrieve, update, and delete named variabl
|
|
|
13
13
|
- **Node Local** — persists for the specific node instance
|
|
14
14
|
- **Custom Namespace** — workflow global storage with a fully dynamic namespace string (great for per-user / per-guild data)
|
|
15
15
|
- **Cross-Workflow (Shared)** — variables are stored in a local JSON file and shared across **all** workflows on this n8n instance
|
|
16
|
+
- **Cross-Workflow (Data Tables)** — variables are stored in n8n's built-in **Data Tables**, visible in the Data Tables UI tab and accessible from any workflow via the n8n API
|
|
16
17
|
|
|
17
18
|
---
|
|
18
19
|
|
|
@@ -107,6 +108,31 @@ The file is **created automatically on first use** — no setup or dependencies
|
|
|
107
108
|
|
|
108
109
|
> **Note:** the file lives on the n8n host machine. If you run n8n in a container or cloud environment, ensure the `.n8n` data directory is persisted to a volume so data is not lost on container restarts.
|
|
109
110
|
|
|
111
|
+
### Cross-Workflow (Data Tables)
|
|
112
|
+
|
|
113
|
+
Variables are stored as rows in an n8n **Data Table**, making them visible and editable directly in the n8n UI under the **Data Tables** tab.
|
|
114
|
+
|
|
115
|
+
Each namespace maps to one table named `var_<namespace>` (e.g., namespace `global_stats` → table `var_global_stats`). The table is **created automatically on first use**. Each row stores a `key` and a `value` (JSON-serialised).
|
|
116
|
+
|
|
117
|
+
#### Prerequisites
|
|
118
|
+
|
|
119
|
+
1. **Enable the n8n API** — in your n8n instance go to **Settings → API** and create an API key.
|
|
120
|
+
2. **Create a credential** — add a new credential of type **n8n Variable Node API** and enter:
|
|
121
|
+
- **n8n Instance URL** — the base URL your n8n instance is reachable at *from within the n8n process itself* (e.g. `http://localhost:5678` for local/Docker installs, or `https://your-instance.example.com` for cloud).
|
|
122
|
+
- **API Key** — the key generated in step 1.
|
|
123
|
+
3. In the Variable node, set **Scope** to **Cross-Workflow (Data Tables)** and select the credential.
|
|
124
|
+
|
|
125
|
+
#### What gets stored
|
|
126
|
+
|
|
127
|
+
| Column | Content |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `key` | The variable key string |
|
|
130
|
+
| `value` | The variable value, JSON-serialised (numbers, booleans, arrays, and objects all round-trip correctly) |
|
|
131
|
+
|
|
132
|
+
> **Tip:** Because the data lives in a real Data Table you can query it with the built-in **n8n Data Table** node, view and edit it in the UI, and use it as a lightweight shared datastore without any external database.
|
|
133
|
+
|
|
134
|
+
> **Note for Docker users:** HTTP calls made by the Variable node originate from inside the container. Use `http://localhost:5678` (or the container's own hostname/service name in Docker Compose) as the base URL — not the external host address.
|
|
135
|
+
|
|
110
136
|
---
|
|
111
137
|
|
|
112
138
|
## Examples
|
|
@@ -214,6 +240,33 @@ Because the database is shared, all workflows see and update the same value.
|
|
|
214
240
|
|
|
215
241
|
---
|
|
216
242
|
|
|
243
|
+
### 6. Cross-workflow shared counter (Data Tables)
|
|
244
|
+
|
|
245
|
+
Same counter as example 5, but stored in n8n Data Tables so you can see and edit the value in the UI.
|
|
246
|
+
|
|
247
|
+
**Prerequisites:** Create an **n8n Variable Node API** credential (see the *Cross-Workflow (Data Tables)* scope section above).
|
|
248
|
+
|
|
249
|
+
**Increment on each webhook hit:**
|
|
250
|
+
- Operation: `Increment Variable`
|
|
251
|
+
- Scope: `Cross-Workflow (Data Tables)`
|
|
252
|
+
- Credential: *(select your n8n Variable Node API credential)*
|
|
253
|
+
- Namespace: `global_stats`
|
|
254
|
+
- Key: `webhook_hits`
|
|
255
|
+
- Amount: `1`
|
|
256
|
+
- Initialize If Missing: `true`
|
|
257
|
+
- Initial Value: `0`
|
|
258
|
+
|
|
259
|
+
**Read the counter from any other workflow:**
|
|
260
|
+
- Operation: `Get Variable`
|
|
261
|
+
- Scope: `Cross-Workflow (Data Tables)`
|
|
262
|
+
- Credential: *(same credential)*
|
|
263
|
+
- Namespace: `global_stats`
|
|
264
|
+
- Key: `webhook_hits`
|
|
265
|
+
|
|
266
|
+
n8n automatically creates a Data Table called `var_global_stats` with `key` and `value` columns. You can inspect or edit the data at any time from the **Data Tables** tab in the n8n sidebar.
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
217
270
|
| Mode | Description |
|
|
218
271
|
|---|---|
|
|
219
272
|
| **Preserve Input + Add Result** _(default)_ | Keep all input fields and add a result object at `variable` (or your chosen field name) |
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.N8nVariableNodeApi = void 0;
|
|
4
|
+
class N8nVariableNodeApi {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.name = 'n8nVariableNodeApi';
|
|
7
|
+
this.displayName = 'n8n Variable Node API';
|
|
8
|
+
this.documentationUrl = 'https://docs.n8n.io/api/authentication/';
|
|
9
|
+
this.properties = [
|
|
10
|
+
{
|
|
11
|
+
displayName: 'n8n Instance URL',
|
|
12
|
+
name: 'baseUrl',
|
|
13
|
+
type: 'string',
|
|
14
|
+
default: 'http://localhost:5678',
|
|
15
|
+
placeholder: 'https://your-n8n-instance.example.com',
|
|
16
|
+
required: true,
|
|
17
|
+
description: 'The base URL of your n8n instance (no trailing slash)',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
displayName: 'API Key',
|
|
21
|
+
name: 'apiKey',
|
|
22
|
+
type: 'string',
|
|
23
|
+
typeOptions: { password: true },
|
|
24
|
+
default: '',
|
|
25
|
+
required: true,
|
|
26
|
+
description: 'Your n8n API key. Generate one in Settings → API.',
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
exports.N8nVariableNodeApi = N8nVariableNodeApi;
|
|
@@ -5,10 +5,22 @@ const n8n_workflow_1 = require("n8n-workflow");
|
|
|
5
5
|
const valueParser_1 = require("./helpers/valueParser");
|
|
6
6
|
const storage_1 = require("./helpers/storage");
|
|
7
7
|
const dbStorage_1 = require("./helpers/dbStorage");
|
|
8
|
+
const dataTableStorage_1 = require("./helpers/dataTableStorage");
|
|
8
9
|
class Variable {
|
|
9
10
|
constructor() {
|
|
10
11
|
this.description = {
|
|
11
12
|
displayName: 'Variable',
|
|
13
|
+
credentials: [
|
|
14
|
+
{
|
|
15
|
+
name: 'n8nVariableNodeApi',
|
|
16
|
+
required: false,
|
|
17
|
+
displayOptions: {
|
|
18
|
+
show: {
|
|
19
|
+
scope: ['crossWorkflowDataTable'],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
],
|
|
12
24
|
name: 'variable',
|
|
13
25
|
icon: 'file:variable.svg',
|
|
14
26
|
group: ['transform'],
|
|
@@ -74,6 +86,11 @@ class Variable {
|
|
|
74
86
|
value: 'crossWorkflow',
|
|
75
87
|
description: 'Variables are stored in a shared local database file and are accessible across ALL workflows on this instance.',
|
|
76
88
|
},
|
|
89
|
+
{
|
|
90
|
+
name: 'Cross-Workflow (Data Tables)',
|
|
91
|
+
value: 'crossWorkflowDataTable',
|
|
92
|
+
description: 'Variables are stored in n8n Data Tables (visible in the Data Tables tab). Requires n8n API credentials.',
|
|
93
|
+
},
|
|
77
94
|
],
|
|
78
95
|
default: 'workflowGlobal',
|
|
79
96
|
description: 'Where to store the variable',
|
|
@@ -108,7 +125,7 @@ class Variable {
|
|
|
108
125
|
description: 'Namespace to organize variables. Supports expressions like economy_{{$json.guild.id}}.',
|
|
109
126
|
displayOptions: {
|
|
110
127
|
show: {
|
|
111
|
-
scope: ['workflowGlobal', 'nodeLocal', 'crossWorkflow'],
|
|
128
|
+
scope: ['workflowGlobal', 'nodeLocal', 'crossWorkflow', 'crossWorkflowDataTable'],
|
|
112
129
|
},
|
|
113
130
|
},
|
|
114
131
|
},
|
|
@@ -353,7 +370,7 @@ class Variable {
|
|
|
353
370
|
name: 'includeMetadata',
|
|
354
371
|
type: 'boolean',
|
|
355
372
|
default: false,
|
|
356
|
-
description: 'Whether to store and expose createdAt/updatedAt/type metadata for variables',
|
|
373
|
+
description: 'Whether to store and expose createdAt/updatedAt/type metadata for variables (not supported for Data Tables scope)',
|
|
357
374
|
displayOptions: {
|
|
358
375
|
show: { scope: ['workflowGlobal', 'nodeLocal', 'customNamespace', 'crossWorkflow'] },
|
|
359
376
|
},
|
|
@@ -398,7 +415,13 @@ async function processItem(ctx, item, i) {
|
|
|
398
415
|
(0, valueParser_1.validateNamespace)(resolvedNamespace);
|
|
399
416
|
// Clone item JSON so we don't mutate the input
|
|
400
417
|
const itemJson = { ...item.json };
|
|
401
|
-
|
|
418
|
+
let result;
|
|
419
|
+
if (scope === 'crossWorkflowDataTable') {
|
|
420
|
+
result = await executeDataTableOperation(ctx, operation, resolvedNamespace, i);
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
result = executeOperation(ctx, operation, scope, resolvedNamespace, itemJson, i, includeMetadata);
|
|
424
|
+
}
|
|
402
425
|
return buildOutputItem(ctx, item, itemJson, result, outputMode, i);
|
|
403
426
|
}
|
|
404
427
|
// ─── Namespace resolution ─────────────────────────────────────────────────────
|
|
@@ -502,6 +525,215 @@ function getStaticData(ctx, scope) {
|
|
|
502
525
|
return ctx.getWorkflowStaticData('node');
|
|
503
526
|
return ctx.getWorkflowStaticData('global');
|
|
504
527
|
}
|
|
528
|
+
// ─── Data Table scope async executor ─────────────────────────────────────────
|
|
529
|
+
async function executeDataTableOperation(ctx, operation, namespace, i) {
|
|
530
|
+
const scopeLabel = 'crossWorkflowDataTable';
|
|
531
|
+
switch (operation) {
|
|
532
|
+
case 'set': {
|
|
533
|
+
const key = getKey(ctx, i);
|
|
534
|
+
const valueType = ctx.getNodeParameter('valueType', i, 'auto');
|
|
535
|
+
const rawValue = ctx.getNodeParameter('value', i, '');
|
|
536
|
+
const overwrite = ctx.getNodeParameter('overwriteExisting', i, true);
|
|
537
|
+
if (!overwrite) {
|
|
538
|
+
const exists = await (0, dataTableStorage_1.dtHasVariable)(ctx, namespace, key);
|
|
539
|
+
if (exists) {
|
|
540
|
+
throw new Error(`Variable "${key}" already exists in namespace "${namespace}". Enable "Overwrite If Exists" to update it.`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const parsed = (0, valueParser_1.parseValueByType)(rawValue, valueType);
|
|
544
|
+
const typeName = valueType === 'auto' ? (0, valueParser_1.inferValueType)(parsed) : valueType;
|
|
545
|
+
await (0, dataTableStorage_1.dtSetVariable)(ctx, namespace, key, parsed, typeName, false);
|
|
546
|
+
return { operation: 'set', scope: scopeLabel, namespace, key, value: parsed };
|
|
547
|
+
}
|
|
548
|
+
case 'get': {
|
|
549
|
+
const key = getKey(ctx, i);
|
|
550
|
+
const exists = await (0, dataTableStorage_1.dtHasVariable)(ctx, namespace, key);
|
|
551
|
+
const useDefault = ctx.getNodeParameter('useDefaultValue', i, false);
|
|
552
|
+
const defaultValue = ctx.getNodeParameter('defaultValue', i, '');
|
|
553
|
+
let value;
|
|
554
|
+
if (exists) {
|
|
555
|
+
const entry = await (0, dataTableStorage_1.dtGetVariable)(ctx, namespace, key);
|
|
556
|
+
value = entry?.value;
|
|
557
|
+
}
|
|
558
|
+
else if (useDefault) {
|
|
559
|
+
value = defaultValue;
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
throw new Error(`Variable "${key}" does not exist in namespace "${namespace}". Enable "Use Default Value" or create the variable first.`);
|
|
563
|
+
}
|
|
564
|
+
return { operation: 'get', scope: scopeLabel, namespace, key, value, exists };
|
|
565
|
+
}
|
|
566
|
+
case 'delete': {
|
|
567
|
+
const key = getKey(ctx, i);
|
|
568
|
+
const existed = await (0, dataTableStorage_1.dtHasVariable)(ctx, namespace, key);
|
|
569
|
+
const deleted = await (0, dataTableStorage_1.dtDeleteVariable)(ctx, namespace, key);
|
|
570
|
+
return { operation: 'delete', scope: scopeLabel, namespace, key, exists: existed, deleted };
|
|
571
|
+
}
|
|
572
|
+
case 'has': {
|
|
573
|
+
const key = getKey(ctx, i);
|
|
574
|
+
const exists = await (0, dataTableStorage_1.dtHasVariable)(ctx, namespace, key);
|
|
575
|
+
return { operation: 'has', scope: scopeLabel, namespace, key, exists };
|
|
576
|
+
}
|
|
577
|
+
case 'list': {
|
|
578
|
+
const includeValues = ctx.getNodeParameter('includeValues', i, true);
|
|
579
|
+
const raw = await (0, dataTableStorage_1.dtListVariables)(ctx, namespace);
|
|
580
|
+
const keys = Object.keys(raw);
|
|
581
|
+
const result = {
|
|
582
|
+
operation: 'list',
|
|
583
|
+
scope: scopeLabel,
|
|
584
|
+
namespace,
|
|
585
|
+
keys,
|
|
586
|
+
count: keys.length,
|
|
587
|
+
};
|
|
588
|
+
if (includeValues) {
|
|
589
|
+
const vars = {};
|
|
590
|
+
for (const [k, entry] of Object.entries(raw)) {
|
|
591
|
+
vars[k] = entry.value;
|
|
592
|
+
}
|
|
593
|
+
result.variables = vars;
|
|
594
|
+
}
|
|
595
|
+
return result;
|
|
596
|
+
}
|
|
597
|
+
case 'clear': {
|
|
598
|
+
const confirmation = ctx.getNodeParameter('clearConfirmation', i, '');
|
|
599
|
+
if (confirmation.trim() !== 'CLEAR') {
|
|
600
|
+
throw new Error('Clear cancelled: you must type CLEAR (uppercase) in the Confirmation field to proceed.');
|
|
601
|
+
}
|
|
602
|
+
const count = await (0, dataTableStorage_1.dtClearNamespace)(ctx, namespace);
|
|
603
|
+
return { operation: 'clear', scope: scopeLabel, namespace, count, cleared: true };
|
|
604
|
+
}
|
|
605
|
+
case 'increment':
|
|
606
|
+
case 'decrement': {
|
|
607
|
+
const key = getKey(ctx, i);
|
|
608
|
+
const amount = ctx.getNodeParameter('incrementAmount', i, 1);
|
|
609
|
+
const initIfMissing = ctx.getNodeParameter('initIfMissingNumeric', i, true);
|
|
610
|
+
const initialValue = ctx.getNodeParameter('numericInitialValue', i, 0);
|
|
611
|
+
const direction = operation === 'increment' ? 1 : -1;
|
|
612
|
+
let current;
|
|
613
|
+
const existed = await (0, dataTableStorage_1.dtHasVariable)(ctx, namespace, key);
|
|
614
|
+
if (!existed) {
|
|
615
|
+
if (!initIfMissing) {
|
|
616
|
+
throw new Error(`Variable "${key}" does not exist in namespace "${namespace}". Enable "Initialize If Missing" to create it automatically.`);
|
|
617
|
+
}
|
|
618
|
+
current = initialValue;
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
const entry = await (0, dataTableStorage_1.dtGetVariable)(ctx, namespace, key);
|
|
622
|
+
const existing = entry?.value;
|
|
623
|
+
if (typeof existing !== 'number' || !Number.isFinite(existing)) {
|
|
624
|
+
throw new Error(`Cannot ${operation} "${key}": current value is not a finite number (got ${typeof existing}: ${JSON.stringify(existing)}).`);
|
|
625
|
+
}
|
|
626
|
+
current = existing;
|
|
627
|
+
}
|
|
628
|
+
const newValue = current + direction * Math.abs(amount);
|
|
629
|
+
await (0, dataTableStorage_1.dtSetVariable)(ctx, namespace, key, newValue, 'number', false);
|
|
630
|
+
return {
|
|
631
|
+
operation,
|
|
632
|
+
scope: scopeLabel,
|
|
633
|
+
namespace,
|
|
634
|
+
key,
|
|
635
|
+
value: newValue,
|
|
636
|
+
previousValue: existed ? current : undefined,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
case 'appendToArray': {
|
|
640
|
+
const key = getKey(ctx, i);
|
|
641
|
+
const valueType = ctx.getNodeParameter('valueType', i, 'auto');
|
|
642
|
+
const rawValue = ctx.getNodeParameter('value', i, '');
|
|
643
|
+
const initIfMissing = ctx.getNodeParameter('initIfMissingArray', i, true);
|
|
644
|
+
let arr;
|
|
645
|
+
const exists = await (0, dataTableStorage_1.dtHasVariable)(ctx, namespace, key);
|
|
646
|
+
if (!exists) {
|
|
647
|
+
if (!initIfMissing) {
|
|
648
|
+
throw new Error(`Variable "${key}" does not exist. Enable "Initialize If Missing" to create an empty array automatically.`);
|
|
649
|
+
}
|
|
650
|
+
arr = [];
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
const entry = await (0, dataTableStorage_1.dtGetVariable)(ctx, namespace, key);
|
|
654
|
+
const existing = entry?.value;
|
|
655
|
+
if (!Array.isArray(existing)) {
|
|
656
|
+
throw new Error(`Cannot append to "${key}": existing value is not an array (got ${typeof existing}).`);
|
|
657
|
+
}
|
|
658
|
+
arr = [...existing];
|
|
659
|
+
}
|
|
660
|
+
const parsed = (0, valueParser_1.parseValueByType)(rawValue, valueType);
|
|
661
|
+
arr.push(parsed);
|
|
662
|
+
await (0, dataTableStorage_1.dtSetVariable)(ctx, namespace, key, arr, 'array', false);
|
|
663
|
+
return { operation: 'appendToArray', scope: scopeLabel, namespace, key, value: arr };
|
|
664
|
+
}
|
|
665
|
+
case 'mergeObject': {
|
|
666
|
+
const key = getKey(ctx, i);
|
|
667
|
+
const objectJsonRaw = ctx.getNodeParameter('objectJson', i, '{}');
|
|
668
|
+
const useDeepMerge = ctx.getNodeParameter('deepMerge', i, false);
|
|
669
|
+
const initIfMissing = ctx.getNodeParameter('initIfMissingObject', i, true);
|
|
670
|
+
let incoming;
|
|
671
|
+
try {
|
|
672
|
+
const p = typeof objectJsonRaw === 'object' && objectJsonRaw !== null
|
|
673
|
+
? objectJsonRaw
|
|
674
|
+
: JSON.parse(String(objectJsonRaw));
|
|
675
|
+
if (typeof p !== 'object' || p === null || Array.isArray(p))
|
|
676
|
+
throw new Error('not a plain object');
|
|
677
|
+
incoming = p;
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
throw new Error(`Object (JSON) must be a plain object. Got: ${String(objectJsonRaw).slice(0, 100)}`);
|
|
681
|
+
}
|
|
682
|
+
let base;
|
|
683
|
+
const exists = await (0, dataTableStorage_1.dtHasVariable)(ctx, namespace, key);
|
|
684
|
+
if (!exists) {
|
|
685
|
+
if (!initIfMissing) {
|
|
686
|
+
throw new Error(`Variable "${key}" does not exist. Enable "Initialize If Missing" to create an empty object automatically.`);
|
|
687
|
+
}
|
|
688
|
+
base = {};
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
const entry = await (0, dataTableStorage_1.dtGetVariable)(ctx, namespace, key);
|
|
692
|
+
const existing = entry?.value;
|
|
693
|
+
if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) {
|
|
694
|
+
throw new Error(`Cannot merge into "${key}": existing value is not a plain object (got ${Array.isArray(existing) ? 'array' : typeof existing}).`);
|
|
695
|
+
}
|
|
696
|
+
base = { ...existing };
|
|
697
|
+
}
|
|
698
|
+
const merged = useDeepMerge ? (0, valueParser_1.deepMerge)(base, incoming) : { ...base, ...incoming };
|
|
699
|
+
await (0, dataTableStorage_1.dtSetVariable)(ctx, namespace, key, merged, 'object', false);
|
|
700
|
+
return { operation: 'mergeObject', scope: scopeLabel, namespace, key, value: merged };
|
|
701
|
+
}
|
|
702
|
+
case 'toggleBoolean': {
|
|
703
|
+
const key = getKey(ctx, i);
|
|
704
|
+
const initIfMissing = ctx.getNodeParameter('initIfMissingBoolean', i, true);
|
|
705
|
+
const initialValue = ctx.getNodeParameter('booleanInitialValue', i, false);
|
|
706
|
+
let current;
|
|
707
|
+
const exists = await (0, dataTableStorage_1.dtHasVariable)(ctx, namespace, key);
|
|
708
|
+
if (!exists) {
|
|
709
|
+
if (!initIfMissing) {
|
|
710
|
+
throw new Error(`Variable "${key}" does not exist. Enable "Initialize If Missing" to create it automatically.`);
|
|
711
|
+
}
|
|
712
|
+
current = initialValue;
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
const entry = await (0, dataTableStorage_1.dtGetVariable)(ctx, namespace, key);
|
|
716
|
+
const existing = entry?.value;
|
|
717
|
+
if (typeof existing !== 'boolean') {
|
|
718
|
+
throw new Error(`Cannot toggle "${key}": existing value is not a boolean (got ${typeof existing}: ${JSON.stringify(existing)}).`);
|
|
719
|
+
}
|
|
720
|
+
current = existing;
|
|
721
|
+
}
|
|
722
|
+
const newValue = !current;
|
|
723
|
+
await (0, dataTableStorage_1.dtSetVariable)(ctx, namespace, key, newValue, 'boolean', false);
|
|
724
|
+
return {
|
|
725
|
+
operation: 'toggleBoolean',
|
|
726
|
+
scope: scopeLabel,
|
|
727
|
+
namespace,
|
|
728
|
+
key,
|
|
729
|
+
value: newValue,
|
|
730
|
+
previousValue: current,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
default:
|
|
734
|
+
throw new Error(`Unknown operation: ${operation}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
505
737
|
// ─── Individual operations ────────────────────────────────────────────────────
|
|
506
738
|
function opSet(ctx, i, namespace, scopeLabel, get, set, has) {
|
|
507
739
|
const key = getKey(ctx, i);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IExecuteFunctions } from 'n8n-workflow';
|
|
2
|
+
import type { StoredVariableEntry } from './types';
|
|
3
|
+
export declare function dtGetVariable(ctx: IExecuteFunctions, namespace: string, key: string): Promise<StoredVariableEntry | undefined>;
|
|
4
|
+
export declare function dtSetVariable(ctx: IExecuteFunctions, namespace: string, key: string, value: unknown, _typeName: string, _includeMetadata: boolean): Promise<void>;
|
|
5
|
+
export declare function dtDeleteVariable(ctx: IExecuteFunctions, namespace: string, key: string): Promise<boolean>;
|
|
6
|
+
export declare function dtHasVariable(ctx: IExecuteFunctions, namespace: string, key: string): Promise<boolean>;
|
|
7
|
+
export declare function dtListVariables(ctx: IExecuteFunctions, namespace: string): Promise<Record<string, StoredVariableEntry>>;
|
|
8
|
+
export declare function dtClearNamespace(ctx: IExecuteFunctions, namespace: string): Promise<number>;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.dtGetVariable = dtGetVariable;
|
|
4
|
+
exports.dtSetVariable = dtSetVariable;
|
|
5
|
+
exports.dtDeleteVariable = dtDeleteVariable;
|
|
6
|
+
exports.dtHasVariable = dtHasVariable;
|
|
7
|
+
exports.dtListVariables = dtListVariables;
|
|
8
|
+
exports.dtClearNamespace = dtClearNamespace;
|
|
9
|
+
// In-process cache: namespace -> tableId
|
|
10
|
+
const tableIdCache = new Map();
|
|
11
|
+
function makeTableName(namespace) {
|
|
12
|
+
// Sanitize namespace to be URL-safe for table names
|
|
13
|
+
return `var_${namespace.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
|
|
14
|
+
}
|
|
15
|
+
function makeKeyFilter(key) {
|
|
16
|
+
return {
|
|
17
|
+
type: 'and',
|
|
18
|
+
filters: [{ columnName: 'key', condition: 'eq', value: key }],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
async function apiRequest(ctx, method, path, body, qs) {
|
|
22
|
+
const creds = await ctx.getCredentials('n8nVariableNodeApi');
|
|
23
|
+
const baseUrl = String(creds.baseUrl).replace(/\/+$/, '');
|
|
24
|
+
const apiKey = String(creds.apiKey);
|
|
25
|
+
const options = {
|
|
26
|
+
method,
|
|
27
|
+
url: `${baseUrl}/api/v1${path}`,
|
|
28
|
+
headers: {
|
|
29
|
+
'X-N8N-API-KEY': apiKey,
|
|
30
|
+
},
|
|
31
|
+
json: true,
|
|
32
|
+
};
|
|
33
|
+
if (qs !== undefined) {
|
|
34
|
+
options.qs = qs;
|
|
35
|
+
}
|
|
36
|
+
if (body !== undefined) {
|
|
37
|
+
options.body = body;
|
|
38
|
+
}
|
|
39
|
+
return ctx.helpers.httpRequest(options);
|
|
40
|
+
}
|
|
41
|
+
async function findTableId(ctx, namespace) {
|
|
42
|
+
const tName = makeTableName(namespace);
|
|
43
|
+
const resp = await apiRequest(ctx, 'GET', '/data-tables', undefined, { limit: 250 });
|
|
44
|
+
const tables = resp.data ?? [];
|
|
45
|
+
return tables.find((t) => t.name === tName)?.id;
|
|
46
|
+
}
|
|
47
|
+
async function getOrCreateTable(ctx, namespace, forceRefresh = false) {
|
|
48
|
+
if (!forceRefresh && tableIdCache.has(namespace)) {
|
|
49
|
+
return tableIdCache.get(namespace);
|
|
50
|
+
}
|
|
51
|
+
const existing = await findTableId(ctx, namespace);
|
|
52
|
+
if (existing) {
|
|
53
|
+
tableIdCache.set(namespace, existing);
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
// Create the table with key + value columns
|
|
57
|
+
const created = await apiRequest(ctx, 'POST', '/data-tables', {
|
|
58
|
+
name: makeTableName(namespace),
|
|
59
|
+
columns: [
|
|
60
|
+
{ name: 'key', type: 'string' },
|
|
61
|
+
{ name: 'value', type: 'string' },
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
const newId = created.id;
|
|
65
|
+
tableIdCache.set(namespace, newId);
|
|
66
|
+
return newId;
|
|
67
|
+
}
|
|
68
|
+
async function dtGetVariable(ctx, namespace, key) {
|
|
69
|
+
const tableId = await getOrCreateTable(ctx, namespace);
|
|
70
|
+
const resp = await apiRequest(ctx, 'GET', `/data-tables/${tableId}/rows`, undefined, {
|
|
71
|
+
filter: JSON.stringify(makeKeyFilter(key)),
|
|
72
|
+
limit: 1,
|
|
73
|
+
});
|
|
74
|
+
const rows = resp.data ?? [];
|
|
75
|
+
if (rows.length === 0)
|
|
76
|
+
return undefined;
|
|
77
|
+
const row = rows[0];
|
|
78
|
+
try {
|
|
79
|
+
return { value: JSON.parse(String(row['value'])) };
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return { value: row['value'] };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function dtSetVariable(ctx, namespace, key, value, _typeName, _includeMetadata) {
|
|
86
|
+
const tableId = await getOrCreateTable(ctx, namespace);
|
|
87
|
+
const serialized = JSON.stringify(value);
|
|
88
|
+
// Try update first; if nothing was updated, insert
|
|
89
|
+
const updateResult = await apiRequest(ctx, 'PATCH', `/data-tables/${tableId}/rows/update`, {
|
|
90
|
+
filter: makeKeyFilter(key),
|
|
91
|
+
data: { value: serialized },
|
|
92
|
+
returnData: true,
|
|
93
|
+
dryRun: false,
|
|
94
|
+
});
|
|
95
|
+
const updatedRows = Array.isArray(updateResult) ? updateResult : [];
|
|
96
|
+
if (updatedRows.length === 0) {
|
|
97
|
+
await apiRequest(ctx, 'POST', `/data-tables/${tableId}/rows`, {
|
|
98
|
+
data: [{ key, value: serialized }],
|
|
99
|
+
returnType: 'count',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function dtDeleteVariable(ctx, namespace, key) {
|
|
104
|
+
const exists = await dtHasVariable(ctx, namespace, key);
|
|
105
|
+
if (!exists)
|
|
106
|
+
return false;
|
|
107
|
+
const tableId = await getOrCreateTable(ctx, namespace);
|
|
108
|
+
await apiRequest(ctx, 'DELETE', `/data-tables/${tableId}/rows/delete`, {
|
|
109
|
+
filter: makeKeyFilter(key),
|
|
110
|
+
});
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
async function dtHasVariable(ctx, namespace, key) {
|
|
114
|
+
const tableId = await getOrCreateTable(ctx, namespace);
|
|
115
|
+
const resp = await apiRequest(ctx, 'GET', `/data-tables/${tableId}/rows`, undefined, {
|
|
116
|
+
filter: JSON.stringify(makeKeyFilter(key)),
|
|
117
|
+
limit: 1,
|
|
118
|
+
});
|
|
119
|
+
const rows = resp.data ?? [];
|
|
120
|
+
return rows.length > 0;
|
|
121
|
+
}
|
|
122
|
+
async function dtListVariables(ctx, namespace) {
|
|
123
|
+
const tableId = await getOrCreateTable(ctx, namespace);
|
|
124
|
+
const allRows = [];
|
|
125
|
+
let cursor;
|
|
126
|
+
do {
|
|
127
|
+
const qs = { limit: 250 };
|
|
128
|
+
if (cursor)
|
|
129
|
+
qs['cursor'] = cursor;
|
|
130
|
+
const resp = await apiRequest(ctx, 'GET', `/data-tables/${tableId}/rows`, undefined, qs);
|
|
131
|
+
const typed = resp;
|
|
132
|
+
allRows.push(...(typed.data ?? []));
|
|
133
|
+
cursor = typed.nextCursor;
|
|
134
|
+
} while (cursor);
|
|
135
|
+
const result = {};
|
|
136
|
+
for (const row of allRows) {
|
|
137
|
+
const k = String(row['key'] ?? '');
|
|
138
|
+
if (!k)
|
|
139
|
+
continue;
|
|
140
|
+
try {
|
|
141
|
+
result[k] = { value: JSON.parse(String(row['value'])) };
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
result[k] = { value: row['value'] };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
async function dtClearNamespace(ctx, namespace) {
|
|
150
|
+
const tableId = await findTableId(ctx, namespace);
|
|
151
|
+
if (!tableId)
|
|
152
|
+
return 0;
|
|
153
|
+
// Count rows before deleting
|
|
154
|
+
const vars = await dtListVariables(ctx, namespace);
|
|
155
|
+
const count = Object.keys(vars).length;
|
|
156
|
+
if (count > 0) {
|
|
157
|
+
// Delete the entire table; it will be recreated on next use
|
|
158
|
+
await apiRequest(ctx, 'DELETE', `/data-tables/${tableId}`);
|
|
159
|
+
tableIdCache.delete(namespace);
|
|
160
|
+
}
|
|
161
|
+
return count;
|
|
162
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type VariableScope = 'localExecution' | 'workflowGlobal' | 'nodeLocal' | 'customNamespace' | 'crossWorkflow';
|
|
1
|
+
export type VariableScope = 'localExecution' | 'workflowGlobal' | 'nodeLocal' | 'customNamespace' | 'crossWorkflow' | 'crossWorkflowDataTable';
|
|
2
2
|
export type VariableOperation = 'set' | 'get' | 'delete' | 'has' | 'list' | 'clear' | 'increment' | 'decrement' | 'appendToArray' | 'mergeObject' | 'toggleBoolean';
|
|
3
3
|
export type ValueType = 'string' | 'number' | 'boolean' | 'json' | 'array' | 'object' | 'auto';
|
|
4
4
|
export type OutputMode = 'preserveAndAdd' | 'resultOnly' | 'addField';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "n8n-nodes-variable",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Local and global scoped variables for n8n workflows",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"n8n-community-node-package",
|
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
],
|
|
33
33
|
"n8n": {
|
|
34
34
|
"n8nNodesApiVersion": 1,
|
|
35
|
-
"credentials": [
|
|
35
|
+
"credentials": [
|
|
36
|
+
"dist/credentials/N8nVariableNodeApi.credentials.js"
|
|
37
|
+
],
|
|
36
38
|
"nodes": [
|
|
37
39
|
"dist/nodes/Variable/Variable.node.js"
|
|
38
40
|
]
|