feat:env var pagination

This commit is contained in:
Harsh Mahajan
2026-03-27 16:33:18 +05:30
parent 7df9254257
commit 3f8ca05f7e
8 changed files with 588 additions and 63 deletions
@@ -1,5 +1,6 @@
<script lang="ts">
import { BackupRestoreBox, MigrationBox, UploadBox, CsvExportBox } from '$lib/components';
import VariablesImportBox from './variablesImportBox.svelte';
import { realtime } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { project, stats } from './store';
@@ -187,6 +188,7 @@
<BackupRestoreBox />
<CsvImportBox />
<CsvExportBox />
<VariablesImportBox />
</div>
<style>
@@ -8,14 +8,25 @@
import { parse } from '$lib/helpers/envfile';
import { Icon, InlineCode, Layout, Tabs } from '@appwrite.io/pink-svelte';
import { InputTextarea } from '$lib/elements/forms';
import { IconDownload, IconDuplicate } from '@appwrite.io/pink-icons-svelte';
import {
IconChevronLeft,
IconChevronRight,
IconDownload,
IconDuplicate
} from '@appwrite.io/pink-icons-svelte';
export let isGlobal: boolean;
export let showEditor = false;
export let variableList: Models.VariableList;
type DraftVariable = { key: string; value: string };
const EDITOR_PAGE_LIMIT = 10;
const editableVariables = variableList.variables.filter((variable) => !variable.secret);
const secretVariables = variableList.variables.filter((variable) => variable.secret);
const initialDraftEntries = editableVariables.map((variable) => ({
key: variable.key,
value: variable.value
}));
export let sdkCreateVariable: (
key: string,
@@ -31,39 +42,109 @@
export let sdkDeleteVariable: (variableId: string) => Promise<unknown>;
let error = '';
let envCode = editableVariables
.map((variable) => `${variable.key}=${variable.value}`)
.join('\n');
let jsonCode = JSON.stringify(
JSON.parse(
`{${editableVariables
.map((variable) => `"${variable.key}":"${variable.value.split('"').join('\\"')}"`)
.join(',')}}`
),
null,
2
);
let baseEnvCode = envCode;
let baseJsonCode = jsonCode;
let tab: 'env' | 'json' = 'env';
let isSubmitting = false;
let pageOffset = 0;
let draftEntries = initialDraftEntries.map((variable) => ({ ...variable }));
let editorCode = serializeEntries(tab, getPageEntries(draftEntries, pageOffset));
if (jsonCode === '{}') {
jsonCode = '';
baseJsonCode = '';
function getPageEntries(entries: DraftVariable[], offset: number) {
return entries.slice(offset, offset + EDITOR_PAGE_LIMIT);
}
let tab: 'env' | 'json' = 'env';
function serializeEntries(view: 'env' | 'json', entries: DraftVariable[]) {
if (view === 'env') {
return entries.map((variable) => `${variable.key}=${variable.value}`).join('\n');
}
const jsonObject = Object.fromEntries(
entries.map((variable) => [variable.key, variable.value])
);
return Object.keys(jsonObject).length ? JSON.stringify(jsonObject, null, 2) : '';
}
function parseEntries(code: string, view: 'env' | 'json') {
const variables = view === 'env' ? parse(code) : JSON.parse(code || '{}');
return Object.entries(variables).map(([key, value]) => ({
key,
value: `${value}`
}));
}
function validateEntries(entries: DraftVariable[]) {
for (const { key, value } of entries) {
if (value.length > 8192) {
throw new Error(`Variable ${key} is longer than 8192 allowed characters`);
}
}
}
function replacePageEntries(
entries: DraftVariable[],
offset: number,
pageEntries: DraftVariable[]
) {
return [
...entries.slice(0, offset),
...pageEntries,
...entries.slice(offset + EDITOR_PAGE_LIMIT)
];
}
function buildDraftEntries() {
const pageEntries = parseEntries(editorCode, tab);
validateEntries(pageEntries);
return replacePageEntries(draftEntries, pageOffset, pageEntries);
}
function syncEditorCode() {
editorCode = serializeEntries(tab, getPageEntries(draftEntries, pageOffset));
}
function trySyncDraft() {
try {
draftEntries = buildDraftEntries();
error = '';
} catch {
// Keep draft state intact while the user is mid-edit.
}
}
function changeTab(nextTab: 'env' | 'json') {
try {
draftEntries = buildDraftEntries();
tab = nextTab;
error = '';
syncEditorCode();
} catch (e) {
error = e.message;
}
}
function changePage(nextOffset: number) {
try {
draftEntries = buildDraftEntries();
pageOffset = nextOffset;
error = '';
syncEditorCode();
} catch (e) {
error = e.message;
}
}
async function handleSubmit() {
try {
const vars = tab === 'env' ? parse(envCode) : JSON.parse(jsonCode ? jsonCode : '{}');
isSubmitting = true;
const entries = Object.entries(vars);
for (const [key, value] of entries) {
if (('' + value).length > 8192) {
throw new Error(`Variable ${key} is longer than 8192 allowed characters`);
}
}
const vars = Object.fromEntries(
buildDraftEntries().map((variable) => [variable.key, variable.value])
);
const editableVariables = variableList.variables.filter((variable) => !variable.secret);
const secretVariables = variableList.variables.filter((variable) => variable.secret);
await Promise.all(
editableVariables.map(async (variable) => {
@@ -81,9 +162,20 @@
// Add new variables, skipping keys that exist in secret variables
await Promise.all(
Object.keys(vars).map(async (key) => {
if (!secretVariables.some((v) => v.key === key)) {
await sdkCreateVariable(key, vars[key], false);
const existingVariable = variableList.variables.find(
(variable) => variable.key === key
);
if (existingVariable?.secret) {
return;
}
if (existingVariable) {
await sdkUpdateVariable(existingVariable.$id, key, vars[key], false);
return;
}
await sdkCreateVariable(key, vars[key], false);
})
);
// Ensure secret variables are preserved
@@ -103,11 +195,25 @@
} catch (e) {
error = e.message;
trackError(e, Submit.VariableEditor);
} finally {
isSubmitting = false;
}
}
function downloadVariables() {
const content = tab === 'json' ? jsonCode : envCode;
let content = '';
try {
const nextDraftEntries = buildDraftEntries();
content = serializeEntries(tab, nextDraftEntries);
draftEntries = nextDraftEntries;
error = '';
} catch (e) {
error = e.message;
return;
}
const fileName = tab === 'json' ? 'vars.json' : '.env';
const type = tab === 'json' ? 'application/json' : 'application/x-envoy';
@@ -127,8 +233,19 @@
window.URL.revokeObjectURL(url);
}
$: totalEntries = draftEntries.length;
$: currentPageEntries = getPageEntries(draftEntries, pageOffset);
$: renderedPageCode = serializeEntries(tab, currentPageEntries);
$: hasUnsyncedChanges = editorCode !== renderedPageCode;
$: copyValue = (() => {
try {
return serializeEntries(tab, buildDraftEntries());
} catch {
return editorCode;
}
})();
$: isButtonDisabled =
(tab === 'env' && baseEnvCode === envCode) || (tab === 'json' && baseJsonCode === jsonCode);
!hasUnsyncedChanges && JSON.stringify(draftEntries) === JSON.stringify(initialDraftEntries);
</script>
<Modal title="Editor" bind:show={showEditor} onSubmit={handleSubmit} bind:error>
@@ -138,10 +255,10 @@
</p>
<Layout.Stack gap="s">
<Tabs.Root stretch let:root>
<Tabs.Item.Button {root} on:click={() => (tab = 'env')} active={tab === 'env'}>
<Tabs.Item.Button {root} on:click={() => changeTab('env')} active={tab === 'env'}>
ENV
</Tabs.Item.Button>
<Tabs.Item.Button {root} on:click={() => (tab = 'json')} active={tab === 'json'}>
<Tabs.Item.Button {root} on:click={() => changeTab('json')} active={tab === 'json'}>
JSON
</Tabs.Item.Button>
</Tabs.Root>
@@ -151,23 +268,53 @@
<InputTextarea
spellcheck={false}
id="variables"
bind:value={envCode}
bind:value={editorCode}
on:input={trySyncDraft}
rows={10}
placeholder={`SECRET_KEY=dQw4w9WgXcQ...`} />
{:else if tab === 'json'}
<InputTextarea
spellcheck={false}
id="variables"
bind:value={jsonCode}
bind:value={editorCode}
on:input={trySyncDraft}
rows={10}
placeholder={`{\n "SECRET_KEY": "dQw4w9WgXcQ..."\n}`} />
{/if}
{#if totalEntries > EDITOR_PAGE_LIMIT}
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
<p class="text">
Variables {pageOffset + 1}-{Math.min(
pageOffset + EDITOR_PAGE_LIMIT,
totalEntries
)} of {totalEntries}
</p>
<Layout.Stack direction="row" gap="xs" inline>
<Button
size="xs"
secondary
disabled={pageOffset === 0}
on:click={() => changePage(pageOffset - EDITOR_PAGE_LIMIT)}>
<Icon size="s" slot="start" icon={IconChevronLeft} />
Prev
</Button>
<Button
size="xs"
secondary
disabled={pageOffset + EDITOR_PAGE_LIMIT >= totalEntries}
on:click={() => changePage(pageOffset + EDITOR_PAGE_LIMIT)}>
Next
<Icon size="s" slot="end" icon={IconChevronRight} />
</Button>
</Layout.Stack>
</Layout.Stack>
{/if}
<Layout.Stack direction="row" gap="xs">
<Button size="xs" on:click={() => downloadVariables()} secondary>
<Icon size="s" slot="start" icon={IconDownload} />
Download
</Button>
<Copy value={tab == 'json' ? jsonCode : envCode}>
<Copy value={copyValue}>
<Button size="xs" secondary>
<Icon size="s" slot="start" icon={IconDuplicate} />
Copy
@@ -178,7 +325,11 @@
</Layout.Stack>
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showEditor = false)}>Cancel</Button>
<Button submit disabled={isButtonDisabled}>Save</Button>
<Button secondary on:click={() => (showEditor = false)} disabled={isSubmitting}>
Cancel
</Button>
<Button submit disabled={isButtonDisabled || isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>
</svelte:fragment>
</Modal>
@@ -97,6 +97,10 @@
{sdkDeleteVariable}
isGlobal
variableList={data.variables}
allVariableList={data.allVariables}
backendPagination
variablesOffset={data.variablesOffset}
variablesLimit={data.limit}
project={data.project}
analyticsSource="project_settings" />
<ChangeOrganization />
@@ -8,10 +8,36 @@ export const load: PageLoad = async ({ depends, url, params }) => {
depends(Dependencies.PROJECT_INSTALLATIONS);
const limit = PAGE_LIMIT;
const offset = Number(url.searchParams.get('offset') ?? 0);
const variablesOffset = Number(url.searchParams.get('variablesOffset') ?? 0);
const projectSdk = sdk.forProject(params.region, params.project);
const [variables, installations] = await Promise.all([
sdk.forProject(params.region, params.project).projectApi.listVariables(),
sdk.forProject(params.region, params.project).vcs.listInstallations({
async function listAllVariables() {
const allVariables = [];
let nextOffset = 0;
let total = 0;
do {
const response = await projectSdk.projectApi.listVariables({
queries: [Query.limit(limit), Query.offset(nextOffset)]
});
allVariables.push(...response.variables);
total = response.total;
nextOffset += response.variables.length;
} while (nextOffset < total);
return {
total,
variables: allVariables
};
}
const [variables, allVariables, installations] = await Promise.all([
projectSdk.projectApi.listVariables({
queries: [Query.limit(limit), Query.offset(variablesOffset)]
}),
listAllVariables(),
projectSdk.vcs.listInstallations({
queries: [Query.limit(limit), Query.offset(offset)]
})
]);
@@ -19,7 +45,9 @@ export const load: PageLoad = async ({ depends, url, params }) => {
return {
limit,
offset,
variablesOffset,
variables,
allVariables,
installations
};
};
@@ -4,6 +4,7 @@
import { Button } from '$lib/elements/forms';
import { CardGrid, Empty, Output, PaginationInline } from '$lib/components';
import UploadVariables from './uploadVariablesModal.svelte';
import { variablesOperation, type VariablesOperationItem } from './variablesOperation';
import { goto, invalidate } from '$app/navigation';
import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
@@ -43,6 +44,7 @@
export let project: Models.Project;
export let variableList: Models.VariableList;
export let allVariableList: Models.VariableList | undefined = undefined;
export let globalVariableList: Models.VariableList | undefined = undefined;
export let analyticsSource = '';
export let isGlobal: boolean;
@@ -59,6 +61,9 @@
) => Promise<unknown>;
export let sdkDeleteVariable: (variableId: string) => Promise<unknown>;
export let product: 'function' | 'site' = 'function';
export let backendPagination = false;
export let variablesOffset = 0;
export let variablesLimit = 10;
let selectedVar: Models.Variable = null;
let showVariablesUpload = false;
@@ -71,6 +76,9 @@
let deleteError: string;
let offset = 0;
const limit = 10;
function handleVariablesImportStatus(detail: VariablesOperationItem) {
variablesOperation.set(detail);
}
async function handleVariableCreated(event: CustomEvent<Models.Variable[]>) {
const variables = event.detail;
@@ -140,10 +148,25 @@
}
async function handleVariableDeleted() {
const deleteId = selectedVar.$id;
try {
variablesOperation.set({
id: deleteId,
count: 1,
mode: 'delete',
status: 'deleting'
});
await sdkDeleteVariable(selectedVar.$id);
showDeleteModal = false;
selectedVar = null;
variablesOperation.set({
id: deleteId,
count: 1,
mode: 'delete',
status: 'completed'
});
addNotification({
type: 'success',
message: `${project.name} ${
@@ -153,6 +176,13 @@
trackEvent(Submit.VariableDelete);
} catch (error) {
deleteError = error.message;
variablesOperation.set({
id: deleteId,
count: 1,
mode: 'delete',
status: 'failed',
error: error.message
});
trackError(error, Submit.VariableDelete);
}
}
@@ -260,8 +290,13 @@
})
: [];
$: editorVariableList = allVariableList ?? variableList;
$: displayedVariables = backendPagination
? variableList.variables
: variableList.variables.slice(offset, offset + limit);
$: hasConflictOnPage = globalVariableList
? variableList.variables.slice(offset, offset + limit).filter((variable) => {
? displayedVariables.filter((variable) => {
return globalVariableList.variables.find((globalVariable) => {
return variable.key === globalVariable.key;
});
@@ -279,6 +314,17 @@
{ id: 'value', width: { min: 200, max: 400 } },
{ id: 'actions', width: 50 }
];
async function handleVariablesPageChange() {
const nextUrl = new URL(page.url);
nextUrl.searchParams.set('variablesOffset', String(variablesOffset));
await goto(nextUrl, {
keepFocus: true,
noScroll: true
});
}
</script>
<CardGrid>
@@ -364,7 +410,7 @@
<Table.Header.Cell column="value" {root}>Value</Table.Header.Cell>
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each variableList.variables.slice(offset, offset + limit) as variable}
{#each displayedVariables as variable}
<Table.Row.Base {root}>
<Table.Cell column="key" {root}>
{@const isConflicting = globalVariableList
@@ -461,10 +507,19 @@
</Table.Row.Base>
{/each}
</Table.Root>
{#if sum > limit}
{#if sum > (backendPagination ? variablesLimit : limit)}
<Layout.Stack direction="row" justifyContent="space-between">
<p class="text">Total variables: {sum}</p>
<PaginationInline total={sum} {limit} bind:offset hidePages />
{#if backendPagination}
<PaginationInline
total={sum}
limit={variablesLimit}
bind:offset={variablesOffset}
hidePages
on:change={handleVariablesPageChange} />
{:else}
<PaginationInline total={sum} {limit} bind:offset hidePages />
{/if}
</Layout.Stack>
{/if}
</Layout.Stack>
@@ -504,7 +559,7 @@
{sdkCreateVariable}
{sdkUpdateVariable}
{sdkDeleteVariable}
{variableList}
variableList={editorVariableList}
bind:showEditor={showEditorModal} />
{/if}
@@ -524,8 +579,9 @@
<UploadVariables
{sdkCreateVariable}
{sdkUpdateVariable}
{variableList}
bind:show={showVariablesUpload} />
variableList={editorVariableList}
bind:show={showVariablesUpload}
onStatusChange={handleVariablesImportStatus} />
{/if}
{#if showDeleteModal}
@@ -16,6 +16,7 @@
} from '@appwrite.io/pink-svelte';
import { parse } from '$lib/helpers/envfile';
import { removeFile } from '$lib/helpers/files';
import type { VariablesOperationItem } from './variablesOperation';
export let show = false;
export let variableList: Models.VariableList;
@@ -30,10 +31,12 @@
value: string,
secret?: boolean
) => Promise<unknown>;
export let onStatusChange: (detail: VariablesOperationItem) => void = () => {};
let files: FileList;
let secret = false;
let error: string;
let isSubmitting = false;
$: filesList = files?.length
? Array.from(files).map((file) => ({
...file,
@@ -45,6 +48,9 @@
: [];
async function handleSubmit() {
let importId = '';
let uploadCount = 0;
try {
if (!files?.length) {
throw new Error('No file selected');
@@ -64,27 +70,70 @@
}
}
const filteredEntries = entries.filter(([, value]) => !!value);
if (!filteredEntries.length) {
throw new Error('No variables found');
}
if (filteredEntries.length > 100) {
throw new Error('Please upload a file with fewer than 100 environment variables.');
}
importId = crypto.randomUUID();
uploadCount = filteredEntries.length;
isSubmitting = true;
onStatusChange({
id: importId,
count: uploadCount,
mode: 'import',
status: 'uploading'
});
show = false;
await Promise.all(
entries
.filter(([, value]) => !!value)
.map(([key, value]) => {
const found = variableList.variables.find(
(variable) => variable.key === key
);
return found
? sdkUpdateVariable(found.$id, key, value, secret)
: sdkCreateVariable(key, value, secret);
})
filteredEntries.map(([key, value]) => {
const found = variableList.variables.find((variable) => variable.key === key);
return found
? sdkUpdateVariable(found.$id, key, value, secret)
: sdkCreateVariable(key, value, secret);
})
);
onStatusChange({
id: importId,
count: uploadCount,
mode: 'import',
status: 'completed'
});
addNotification({
type: 'success',
message: `Variables have been uploaded.`
});
show = false;
} catch (e) {
error = e.message;
if (importId) {
onStatusChange({
id: importId,
count: uploadCount,
mode: 'import',
status: 'failed',
error
});
}
if (!show) {
addNotification({
type: 'error',
message: error
});
}
} finally {
isSubmitting = false;
}
}
</script>
@@ -138,7 +187,9 @@
bind:checked={secret}
description="If selected, you and your team won't be able to read the values after creation." />
<svelte:fragment slot="footer">
<Button text on:click={() => (show = false)}>Cancel</Button>
<Button submit disabled={!files?.length}>Import</Button>
<Button text on:click={() => (show = false)} disabled={isSubmitting}>Cancel</Button>
<Button submit disabled={!files?.length || isSubmitting}>
{isSubmitting ? 'Uploading...' : 'Import'}
</Button>
</svelte:fragment>
</Modal>
@@ -0,0 +1,193 @@
<script lang="ts">
import { Link } from '$lib/elements';
import { Modal, Code } from '$lib/components';
import PaginationInline from '$lib/components/paginationInline.svelte';
import { IconExclamationCircle } from '@appwrite.io/pink-icons-svelte';
import { Icon, Layout, Typography } from '@appwrite.io/pink-svelte';
import { variablesOperation, type VariablesOperationItem } from './variablesOperation';
let isOpen = true;
let showDetails = false;
let offset = 0;
$: if (!$variablesOperation.length) {
offset = 0;
}
$: if (offset >= $variablesOperation.length) {
offset = Math.max(0, $variablesOperation.length - 1);
}
$: currentItem = $variablesOperation[offset] ?? null;
function graphSize(status: VariablesOperationItem['status']): number {
switch (status) {
case 'uploading':
case 'deleting':
return 60;
case 'completed':
case 'failed':
return 100;
}
}
function text({ status, count, mode }: VariablesOperationItem): string {
const label = `${count} variable${count === 1 ? '' : 's'}`;
switch (status) {
case 'completed':
return mode === 'delete' ? `${label} deleted` : `${label} uploaded`;
case 'failed':
return mode === 'delete' ? `Variable deletion failed` : `Variable import failed`;
case 'deleting':
return `Deleting ${label}...`;
case 'uploading':
return `Uploading ${label}...`;
}
}
</script>
{#if currentItem}
<Layout.Stack direction="column" gap="l" alignItems="flex-end">
<section class="upload-box">
<header class="upload-box-header">
<h4 class="upload-box-title">
<Typography.Text variant="m-500">
{currentItem.mode === 'delete'
? 'Deleting variables'
: 'Importing variables'}
</Typography.Text>
</h4>
<button
class="upload-box-button"
class:is-open={isOpen}
aria-label="toggle variables import box"
onclick={() => (isOpen = !isOpen)}>
<span class="icon-cheveron-up" aria-hidden="true"></span>
</button>
<button
class="upload-box-button"
aria-label="close variables import box"
onclick={() => variablesOperation.clear(currentItem.id)}>
<span class="icon-x" aria-hidden="true"></span>
</button>
</header>
<div class="upload-box-content-list">
<div class="upload-box-content" class:is-open={isOpen}>
<ul class="upload-box-list">
<li class="upload-box-item">
<section class="progress-bar u-width-full-line">
<div
class="progress-bar-top-line u-flex u-gap-8 u-main-space-between">
<Typography.Text>{text(currentItem)}</Typography.Text>
</div>
<div
class="progress-bar-container"
class:is-danger={currentItem.status === 'failed'}
style="--graph-size:{graphSize(currentItem.status)}%">
</div>
{#if currentItem.status === 'failed'}
<Layout.Stack
direction="row"
gap="xs"
alignItems="center"
inline>
<Icon
icon={IconExclamationCircle}
color="--fgcolor-error"
size="s" />
<Typography.Text color="--fgcolor-error">
There was an issue
{currentItem.mode === 'delete'
? ' deleting'
: ' importing'}
variables.
{#if currentItem.error}
<Link
style="color: inherit"
onclick={() => (showDetails = true)}>
View details
</Link>
{/if}
</Typography.Text>
</Layout.Stack>
{/if}
</section>
</li>
</ul>
{#if $variablesOperation.length > 1}
<Layout.Stack
direction="row"
justifyContent="space-between"
alignItems="center"
class="upload-box-pagination">
<Typography.Caption variant="400">
{offset + 1} of {$variablesOperation.length}
</Typography.Caption>
<PaginationInline
total={$variablesOperation.length}
limit={1}
bind:offset
hidePages />
</Layout.Stack>
{/if}
</div>
</div>
</section>
</Layout.Stack>
{/if}
<Modal title="Import error" bind:show={showDetails} hideFooter>
<Layout.Stack gap="m">
<Code language="sh" code={currentItem?.error ?? ''} withCopy allowScroll />
</Layout.Stack>
</Modal>
<style lang="scss">
.upload-box {
display: flex;
max-height: 320px;
flex-direction: column;
}
.upload-box-header {
flex-shrink: 0;
}
.upload-box-title {
font-size: 11px;
}
.upload-box-content-list {
overflow-y: auto;
}
.upload-box-content {
width: 324px;
}
.upload-box-pagination {
padding-top: 0.75rem;
}
.upload-box-button {
display: flex;
align-items: center;
justify-content: center;
}
.progress-bar-container {
height: 4px;
&::before {
height: 4px;
background-color: var(--bgcolor-neutral-invert);
}
&.is-danger::before {
height: 4px;
background-color: var(--bgcolor-error);
}
}
</style>
@@ -0,0 +1,40 @@
import { writable } from 'svelte/store';
export type VariablesOperationItem = {
id: string;
count: number;
mode: 'import' | 'delete';
status: 'uploading' | 'deleting' | 'completed' | 'failed';
error?: string;
};
function createVariablesOperation() {
const { subscribe, update, set } = writable<VariablesOperationItem[]>([]);
return {
subscribe,
set: (item: VariablesOperationItem) =>
update((items) => {
const index = items.findIndex((current) => current.id === item.id);
if (index !== -1) {
const next = [...items];
next[index] = item;
return next;
}
return [item, ...items];
}),
clear: (id?: string) =>
update((items) => {
if (!id) {
return [];
}
return items.filter((item) => item.id !== id);
}),
reset: () => set([])
};
}
export const variablesOperation = createVariablesOperation();