Merge branch 'feat-documentsdb' of https://github.com/appwrite/console into feat-documentsdb

This commit is contained in:
Prem Palanisamy
2026-04-21 10:07:33 +01:00
33 changed files with 1835 additions and 549 deletions
+2 -2
View File
@@ -93,7 +93,7 @@
"flatted": "^3.4.2",
"immutable": "^5.1.5",
"minimatch": "10.2.3",
"picomatch": "^2.3.2",
"picomatch": "^4.0.4",
"vite": "npm:rolldown-vite@latest",
"yaml": "^1.10.3",
},
@@ -1184,7 +1184,7 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
+1 -1
View File
@@ -106,7 +106,7 @@
"immutable": "^5.1.5",
"flatted": "^3.4.2",
"yaml": "^1.10.3",
"picomatch": "^2.3.2",
"picomatch": "^4.0.4",
"cookie": "^0.7.0"
}
}
+3 -2
View File
@@ -5,7 +5,6 @@ import type { Models } from '@appwrite.io/console';
// Parse feature flags from env as a string array (exact match only)
const flagsRaw = (env.PUBLIC_CONSOLE_FEATURE_FLAGS ?? '').split(',');
// @ts-expect-error: unused method!
function isFlagEnabled(name: string) {
// loose generic to allow safe access while retaining type safety
return <T extends { account?: Account; organization?: Models.Organization }>(data: T) => {
@@ -19,4 +18,6 @@ function isFlagEnabled(name: string) {
};
}
export const flags = {};
export const flags = {
multiDb: isFlagEnabled('multi-db')
};
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 450 KiB

After

Width:  |  Height:  |  Size: 205 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 3.8 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 450 KiB

After

Width:  |  Height:  |  Size: 205 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 40 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 3.8 MiB

@@ -1,10 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { PaginationWithLimit } from '$lib/components';
import { Empty, PaginationWithLimit } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { Container, ResponsiveContainerHeader } from '$lib/layout';
import type { Models } from '@appwrite.io/console';
import Create from './create.svelte';
import Grid from './grid.svelte';
import { columns } from './store';
import Table from './table.svelte';
@@ -23,23 +25,47 @@
import { resolveRoute, withPath } from '$lib/stores/navigation';
import EmptyDatabaseCloud from './empty.svelte';
import { flags } from '$lib/flags';
import { user } from '$lib/stores/user';
import { project } from '../store';
const { data }: PageProps = $props();
let showCreate = $state(false);
const isMultiDb = $derived(flags.multiDb({ account: $user, organization: $organization }));
const isLimited = $derived(isServiceLimited('databases', $organization, data.databases.total));
async function handleCreate(event: CustomEvent<Models.Database>) {
showCreate = false;
await goto(
resolveRoute('/(console)/project-[region]-[project]/databases/database-[database]', {
...page.params,
database: event.detail.$id
})
);
}
async function goToCreateDatabaseWizard() {
await goto(
resolveRoute('/(console)/project-[region]-[project]/databases/create', page.params)
);
}
function triggerCreate() {
if (isMultiDb) {
goToCreateDatabaseWizard();
} else {
showCreate = true;
}
}
$effect(() => {
$registerCommands([
{
label: 'Create database',
callback: () => {
goToCreateDatabaseWizard();
triggerCreate();
},
keys: ['c'],
disabled: !$canWriteDatabases || isLimited,
@@ -56,7 +82,7 @@
{#if data.databases.total}
{#if data.view === 'grid'}
<Grid {data} onCreateDatabaseClick={goToCreateDatabaseWizard} />
<Grid {data} onCreateDatabaseClick={triggerCreate} />
{:else}
<Table
entities={data.entities}
@@ -77,7 +103,7 @@
size="s"
secondary>Clear Search</Button>
</EmptySearch>
{:else}
{:else if isMultiDb}
<EmptyDatabaseCloud
disabled={$canWriteDatabases}
onDatabaseTypeSelected={async (type) => {
@@ -91,9 +117,17 @@
)
);
}} />
{:else}
<Empty
single
target="database"
allowCreate={$canWriteDatabases}
on:click={() => (showCreate = true)} />
{/if}
</Container>
<Create bind:showCreate project={$project} on:created={handleCreate} />
{#snippet containerHeader()}
<ResponsiveContainerHeader
hasSearch
@@ -103,10 +137,7 @@
{#if $canWriteDatabases}
<Tooltip disabled={!isLimited} maxWidth={BODY_TOOLTIP_MAX_WIDTH}>
<div>
<Button
disabled={isLimited}
event="create_database"
on:click={goToCreateDatabaseWizard}>
<Button disabled={isLimited} event="create_database" on:click={triggerCreate}>
<Icon icon={IconPlus} slot="start" size="s" />
Create database
</Button>
@@ -0,0 +1,154 @@
<script lang="ts">
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CustomId, Modal } from '$lib/components';
import { Button, InputText } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { BackupServices, ID, type Models } from '@appwrite.io/console';
import { createEventDispatcher } from 'svelte';
import { isCloud } from '$lib/system';
import { currentPlan } from '$lib/stores/organization';
import { getChangePlanUrl } from '$lib/stores/billing';
import CreatePolicy from './database-[database]/backups/createPolicy.svelte';
import { cronExpression, type UserBackupPolicy } from '$lib/helpers/backups';
import { Alert, Icon, Tag } from '@appwrite.io/pink-svelte';
import { IconPencil } from '@appwrite.io/pink-icons-svelte';
import { page } from '$app/state';
export let showCreate = false;
export let project: Models.Project;
let totalPolicies: UserBackupPolicy[] = [];
const dispatch = createEventDispatcher();
let name = '';
let id: string = null;
let showCustomId = false;
const trackEvents = (policies: UserBackupPolicy[]) => {
policies.forEach((policy: UserBackupPolicy) => {
let actualDay = null;
const monthlyBackupFrequency = policy.monthlyBackupFrequency;
switch (monthlyBackupFrequency) {
case 'first':
actualDay = '1st';
break;
case 'middle':
actualDay = '15th';
break;
case 'end':
default:
actualDay = '28th';
break;
}
const message = {
keepFor: `${policy.retained} days`,
frequency: policy.plainTextFrequency,
policy: policy.default ? 'preset' : 'custom'
};
if (actualDay) {
message['monthlyInterval'] = actualDay;
}
trackEvent('submit_policy_submit', message);
});
};
const createPolicies = async (resourceId: string) => {
if (!totalPolicies.length) return;
const totalPoliciesPromise = totalPolicies.map((policy) => {
cronExpression(policy);
return sdk.forProject(page.params.region, page.params.project).backups.createPolicy({
policyId: ID.unique(),
services: [BackupServices.Databases],
retention: policy.retained,
schedule: policy.schedule,
name: policy.label,
resourceId
});
});
await Promise.all(totalPoliciesPromise);
trackEvents(totalPolicies);
};
const create = async () => {
try {
const databaseId = id ? id : ID.unique();
const database = await sdk
.forProject(page.params.region, page.params.project)
.tablesDB.create({
databaseId,
name
});
await createPolicies(databaseId);
showCreate = false;
dispatch('created', database);
addNotification({
type: 'success',
message: `${name} has been created`
});
trackEvent(Submit.DatabaseCreate, {
customId: !!id
});
name = id = null;
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.DatabaseCreate);
}
};
</script>
<Modal title="Create database" onSubmit={create} bind:show={showCreate}>
<InputText
id="name"
label="Name"
placeholder="Enter database name"
bind:value={name}
autofocus
required />
{#if !showCustomId}
<div>
<Tag
size="s"
on:click={() => {
showCustomId = true;
}}><Icon icon={IconPencil} /> Database ID</Tag>
</div>
{/if}
<CustomId bind:show={showCustomId} name="Database" bind:id autofocus={false} />
{#if isCloud}
{#if !$currentPlan?.backupsEnabled}
<Alert.Inline title="This database won't be backed up" status="warning">
Upgrade your plan to ensure your data stays safe and backed up.
<svelte:fragment slot="actions">
<Button compact href={getChangePlanUrl(project.teamId)}>Upgrade plan</Button>
</svelte:fragment>
</Alert.Inline>
{:else}
<CreatePolicy
{project}
bind:totalPolicies
bind:isShowing={showCreate}
title="Backup policies"
subtitle="Protect your data and ensure quick recovery by adding backup policies." />
{/if}
{/if}
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showCreate = false)}>Cancel</Button>
<Button submit>Create</Button>
</svelte:fragment>
</Modal>
@@ -24,6 +24,9 @@
import type { PageProps } from './$types';
import { createDatabaseStore } from './store';
import { isTabletViewport } from '$lib/stores/viewport';
import { flags } from '$lib/flags';
import { user } from '$lib/stores/user';
import { organization } from '$lib/stores/organization';
const { data }: PageProps = $props();
@@ -42,24 +45,36 @@
const isDark = $derived($app.themeInUse === 'dark');
const backupsImg = $derived(isDark ? EmptyDarkMobile : EmptyLightMobile);
const isMultiDb = $derived(flags.multiDb({ account: $user, organization: $organization }));
const databaseTypes: Array<{
type: DatabaseType;
title: string;
subtitle: string;
}> = [
}> = $derived([
{
type: 'tablesdb',
type: 'tablesdb' as DatabaseType,
title: 'TablesDB',
subtitle:
'Structure your data in rows and columns. Best for relational data and advanced querying.'
},
{
type: 'documentsdb',
title: 'DocumentsDB',
subtitle:
'Store flexible data without a fixed schema. Best for unstructured data and simple querying.'
}
];
...(isMultiDb
? [
{
type: 'documentsdb' as DatabaseType,
title: 'DocumentsDB',
subtitle:
'Store flexible data without a fixed schema. Best for unstructured data and simple querying.'
},
{
type: 'vectorsdb' as DatabaseType,
title: 'VectorsDB',
subtitle:
'Store data as vectors to find similar results. Best for semantic search and recommendations.'
}
]
: [])
]);
afterNavigate(({ from }) => (previousPage = from?.url?.pathname || previousPage));
@@ -276,7 +291,7 @@
{/snippet}
{#snippet selectDatabaseType(disabled = false)}
<Layout.Grid columns={2} columnsS={1}>
<Layout.Grid columns={3} columnsS={1}>
{#each databaseTypes as databaseType}
<div class="card-selector">
<Card.Selector
@@ -17,7 +17,8 @@ import type {
Models,
OrderBy,
TablesDBIndexType,
DocumentsDBIndexType
DocumentsDBIndexType,
VectorsDBIndexType
} from '@appwrite.io/console';
export type DatabaseSdkResult = {
@@ -35,6 +36,7 @@ export type DatabaseSdkResult = {
entityId: string;
name: string;
databaseType?: DatabaseType;
dimension?: number /* vectorsDB specific */;
}) => Promise<Entity>;
getEntity: (params: {
databaseId: string;
@@ -53,6 +55,15 @@ export type DatabaseSdkResult = {
entityId: string;
databaseType?: DatabaseType;
}) => Promise<{}>;
updateEntity: (params: {
databaseId: string;
entityId: string;
name?: string;
permissions?: string[];
documentSecurity?: boolean;
enabled?: boolean;
databaseType?: DatabaseType;
}) => Promise<Entity>;
createRecord: (params: {
databaseId: string;
entityId: string;
@@ -98,8 +109,30 @@ export type DatabaseSdkResult = {
orders?: OrderBy[];
databaseType?: DatabaseType;
}) => Promise<Index>;
deleteIndex: (params: {
databaseId: string;
entityId: string;
key: string;
databaseType?: DatabaseType;
}) => Promise<{}>;
};
/**
* Returns the raw DocumentsDB or VectorsDB SDK service for a given database type.
* Use in load functions (.ts) where Svelte runes aren't available.
*/
export function getCollectionService(region: string, project: string, type: DatabaseType) {
const projectSdk = sdk.forProject(region, project);
switch (type) {
case 'documentsdb':
return projectSdk.documentsDB;
case 'vectorsdb':
return projectSdk.vectorsDB;
default:
throw new Error(`Unsupported collection database type: ${type}`);
}
}
export function useDatabaseSdk(
regionOrPage: string | Page,
projectOrTerminology: string | TerminologyResult,
@@ -131,8 +164,9 @@ export function useDatabaseSdk(
case 'documentsdb': {
return await baseSdk.documentsDB.create(params);
}
case 'vectorsdb':
throw new Error('Database type not supported yet');
case 'vectorsdb': {
return await baseSdk.vectorsDB.create(params);
}
default:
throw new Error('Unknown database type');
}
@@ -141,7 +175,8 @@ export function useDatabaseSdk(
async list(params): Promise<Models.DatabaseList> {
const results = await Promise.all([
baseSdk.tablesDB.list(params),
baseSdk.documentsDB.list(params)
baseSdk.documentsDB.list(params),
baseSdk.vectorsDB.list(params)
]);
return results.reduce(
@@ -171,8 +206,15 @@ export function useDatabaseSdk(
return toSupportiveEntity(table);
}
case 'vectorsdb':
throw new Error('Database type not supported yet');
case 'vectorsdb': {
const collection = await baseSdk.vectorsDB.createCollection({
...params,
dimension: params.dimension,
collectionId: params.entityId
});
return toSupportiveEntity(collection);
}
default:
throw new Error('Unknown database type');
}
@@ -190,8 +232,10 @@ export function useDatabaseSdk(
await baseSdk.documentsDB.listCollections(params);
return { total, entities: collections.map(toSupportiveEntity) };
}
case 'vectorsdb':
throw new Error(`Database type not supported yet`);
case 'vectorsdb': {
const { total, collections } = await baseSdk.vectorsDB.listCollections(params);
return { total, entities: collections.map(toSupportiveEntity) };
}
default:
throw new Error(`Unknown database type`);
}
@@ -215,8 +259,14 @@ export function useDatabaseSdk(
return toSupportiveEntity(collection);
}
case 'vectorsdb':
throw new Error(`Database type not supported yet`);
case 'vectorsdb': {
const collection = await baseSdk.vectorsDB.getCollection({
databaseId: params.databaseId,
collectionId: params.entityId
});
return toSupportiveEntity(collection);
}
default:
throw new Error(`Unknown database type`);
}
@@ -230,7 +280,7 @@ export function useDatabaseSdk(
case 'documentsdb':
return await baseSdk.documentsDB.delete(params);
case 'vectorsdb':
throw new Error('Database type not supported yet');
return await baseSdk.vectorsDB.delete(params);
default:
throw new Error(`Unknown database type`);
}
@@ -250,7 +300,51 @@ export function useDatabaseSdk(
collectionId: params.entityId
});
case 'vectorsdb':
throw new Error('Database type not supported yet');
return await baseSdk.vectorsDB.deleteCollection({
databaseId: params.databaseId,
collectionId: params.entityId
});
default:
throw new Error(`Unknown database type`);
}
},
async updateEntity(params) {
switch (type ?? params.databaseType) {
case 'legacy': /* databases api */
case 'tablesdb':
return toSupportiveEntity(
await baseSdk.tablesDB.updateTable({
databaseId: params.databaseId,
tableId: params.entityId,
name: params.name,
permissions: params.permissions,
rowSecurity: params.documentSecurity,
enabled: params.enabled
})
);
case 'documentsdb':
return toSupportiveEntity(
await baseSdk.documentsDB.updateCollection({
databaseId: params.databaseId,
collectionId: params.entityId,
name: params.name,
permissions: params.permissions,
documentSecurity: params.documentSecurity,
enabled: params.enabled
})
);
case 'vectorsdb':
return toSupportiveEntity(
await baseSdk.vectorsDB.updateCollection({
databaseId: params.databaseId,
collectionId: params.entityId,
name: params.name,
permissions: params.permissions,
documentSecurity: params.documentSecurity,
enabled: params.enabled
})
);
default:
throw new Error(`Unknown database type`);
}
@@ -275,8 +369,15 @@ export function useDatabaseSdk(
data: params.data,
permissions: params.permissions
});
case 'vectorsdb':
throw new Error('Database type not supported yet');
case 'vectorsdb': {
return await baseSdk.vectorsDB.createDocument({
databaseId: params.databaseId,
collectionId: params.entityId,
documentId: params.recordId,
data: params.data,
permissions: params.permissions
});
}
default:
throw new Error(`Unknown database type`);
}
@@ -301,8 +402,15 @@ export function useDatabaseSdk(
data: params.data,
permissions: params.permissions
});
case 'vectorsdb':
throw new Error('Database type not supported yet');
case 'vectorsdb': {
return await baseSdk.vectorsDB.upsertDocument({
databaseId: params.databaseId,
collectionId: params.entityId,
documentId: params.recordId,
data: params.data,
permissions: params.permissions
});
}
default:
throw new Error(`Unknown database type`);
}
@@ -325,8 +433,14 @@ export function useDatabaseSdk(
documentId: params.recordId,
permissions: params.permissions
});
case 'vectorsdb':
throw new Error('Database type not supported yet');
case 'vectorsdb': {
return await baseSdk.vectorsDB.upsertDocument({
databaseId: params.databaseId,
collectionId: params.entityId,
documentId: params.recordId,
permissions: params.permissions
});
}
default:
throw new Error(`Unknown database type`);
}
@@ -351,8 +465,19 @@ export function useDatabaseSdk(
});
return toSupportiveRecord(document);
}
case 'vectorsdb':
throw new Error('Database type not supported yet');
case 'vectorsdb': {
if (!params.recordId) {
throw new Error('Record ID is required to delete a VectorsDB document');
}
const document = await baseSdk.vectorsDB.deleteDocument({
databaseId: params.databaseId,
collectionId: params.entityId,
documentId: params.recordId
});
return toSupportiveRecord(document);
}
default:
throw new Error(`Unknown database type`);
}
@@ -377,8 +502,15 @@ export function useDatabaseSdk(
});
return { total, records: documents.map(toSupportiveRecord) };
}
case 'vectorsdb':
throw new Error('Database type not supported yet');
case 'vectorsdb': {
const { total, documents } = await baseSdk.vectorsDB.deleteDocuments({
databaseId: params.databaseId,
collectionId: params.entityId,
queries: params.queries
});
return { total, records: documents.map(toSupportiveRecord) };
}
default:
throw new Error(`Unknown database type`);
}
@@ -411,8 +543,45 @@ export function useDatabaseSdk(
});
return toSupportiveIndex(index);
}
case 'vectorsdb': {
const index = await baseSdk.vectorsDB.createIndex({
databaseId: params.databaseId,
collectionId: params.entityId,
key: params.key,
type: params.type as VectorsDBIndexType,
attributes: params.attributes,
lengths: params.lengths,
orders: params.orders
});
return toSupportiveIndex(index);
}
default:
throw new Error(`Unknown database type`);
}
},
async deleteIndex(params) {
switch (type ?? params.databaseType) {
case 'legacy': /* databases api */
case 'tablesdb':
return await baseSdk.tablesDB.deleteIndex({
databaseId: params.databaseId,
tableId: params.entityId,
key: params.key
});
case 'documentsdb':
return await baseSdk.documentsDB.deleteIndex({
databaseId: params.databaseId,
collectionId: params.entityId,
key: params.key
});
case 'vectorsdb':
throw new Error('Database type not supported yet');
return await baseSdk.vectorsDB.deleteIndex({
databaseId: params.databaseId,
collectionId: params.entityId,
key: params.key
});
default:
throw new Error(`Unknown database type`);
}
@@ -6,10 +6,13 @@ import type { Attributes, Collection, Columns, Table } from '$database/store';
import type { Term, TerminologyResult, TerminologyShape } from '$database/(entity)/helpers/types';
type BaseTerminology = typeof baseTerminology;
type ImplementedDBTypes = Omit<BaseTerminology, 'vectorsdb' | 'legacy'>;
type ImplementedDBTypes = Omit<BaseTerminology, 'legacy'>;
/* manual type for the time being because vectorsdb is pending */
export type DatabaseType = 'legacy' | 'tablesdb' | 'documentsdb' | 'vectorsdb';
export type CollectionDatabaseType = Extract<DatabaseType, 'documentsdb' | 'vectorsdb'>;
export const DEFAULT_VECTOR_DIMENSION = 768;
export type RecordType = ImplementedDBTypes[keyof ImplementedDBTypes]['record'];
@@ -18,6 +21,7 @@ export type Entity = Partial<Collection | Table> & {
indexes?: Index[];
fields?: (Attributes | Columns)[];
recordSecurity?: Models.Collection['documentSecurity'] | Models.Table['rowSecurity'];
dimension?: number;
};
export type Field = Partial<Attributes> | Partial<Columns>;
@@ -62,7 +66,11 @@ export const baseTerminology = {
field: 'attribute',
record: 'document'
},
vectorsdb: {}
vectorsdb: {
entity: 'collection',
field: 'attribute',
record: 'document'
}
} as const;
const createTerm = (singular: string, pluralForm: string): Term => {
@@ -4,14 +4,14 @@
import { Modal, CustomId } from '$lib/components';
import { subNavigation } from '$lib/stores/database';
import { ID } from '@appwrite.io/console';
import { Button, InputText } from '$lib/elements/forms';
import { Button, InputNumber, InputText } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import {
Input as SuggestionsInput,
entityColumnSuggestions
} from '$database/(suggestions)/index';
import { getTerminologies } from '../helpers';
import { getTerminologies, DEFAULT_VECTOR_DIMENSION } from '$database/(entity)';
import { resetSampleFieldsConfig } from '$database/store';
let {
@@ -21,7 +21,7 @@
}: {
show: boolean;
useSuggestions?: boolean;
onCreateEntity: (id: string, name: string) => Promise<void>;
onCreateEntity: (id: string, name: string, dimension?: number) => Promise<void>;
} = $props();
const { analytics, terminology } = getTerminologies();
@@ -29,12 +29,14 @@
const lower = terminology.entity.lower.singular;
const title = terminology.entity.title.singular;
const analyticsCreateSubmit = analytics.submit.entity('Create');
const isVectorsDb = terminology.type === 'vectorsdb';
// example - `table-[table]`, `collection-[collection]`
const isOnEntitiesPage = $derived(page.route?.id.endsWith(`${lower}-[${lower}]`));
let name = $state('');
let id = $state(null);
let dimension = $state(DEFAULT_VECTOR_DIMENSION);
let error = $state(null);
let creatingEntity = $state(false);
@@ -64,7 +66,7 @@
enableThinkingModeForSuggestions(finalId, name);
// create entity.
await onCreateEntity(finalId, name);
await onCreateEntity(finalId, name, isVectorsDb ? dimension : undefined);
// cleanup
updateAndCleanup();
@@ -121,6 +123,16 @@
<CustomId show bind:id required={false} autofocus={false} name={title} syncFrom={name} />
{#if isVectorsDb}
<InputNumber
id="dimension"
label="Vector dimension"
bind:value={dimension}
min={1}
max={4096}
required />
{/if}
{#if useSuggestions}
<SuggestionsInput showSampleCountPicker={!terminology.schema} />
{/if}
@@ -292,7 +292,7 @@
const spreadsheetColumns = $derived.by(() => {
return isRecordMode
? type !== 'documentsdb'
? type === 'tablesdb' || type === 'legacy'
? getRowColumns()
: getDocumentsDbColumns()
: getIndexesColumns();
@@ -382,7 +382,7 @@
{#snippet noSqlEditor()}
{#if showNoSqlEditor}
{#if type === 'documentsdb' && mode === 'records'}
{#if (type === 'documentsdb' || type === 'vectorsdb') && mode === 'records'}
<NoSqlEditor loading />
{/if}
{/if}
@@ -411,7 +411,8 @@
</Layout.Stack>
{#if showActions && actions}
{@const isOnlyIndexes = mode === 'indexes' && type === 'documentsdb'}
{@const isOnlyIndexes =
mode === 'indexes' && (type === 'documentsdb' || type === 'vectorsdb')}
{@const inline = mode === 'records-filtered' || isOnlyIndexes}
<div class="controlled-width" class:single-mode={isOnlyIndexes}>
<Layout.Stack
@@ -504,7 +505,8 @@
}
}
&[data-mode='records'][data-type='documentsdb'] {
&[data-mode='records'][data-type='documentsdb'],
&[data-mode='records'][data-type='vectorsdb'] {
position: unset;
// disable animation when not loading!
&[data-loading='false'] :global(.skeleton) {
@@ -35,6 +35,8 @@
const record = terminology.record.lower;
const entity = terminology.entity.lower.singular;
const isSchemaless = type === 'documentsdb' || type === 'vectorsdb';
const title = $derived.by(() => {
switch (type) {
default:
@@ -45,20 +47,19 @@
: `Smart ${field.singular} suggestions available on Cloud`;
case 'documentsdb':
case 'vectorsdb':
return featureActive ? `Sample Data` : `Sample Data available on Cloud`;
}
});
const subtitle = $derived.by(() => {
const isDocs = type === 'documentsdb';
if (featureActive) {
return isDocs
return isSchemaless
? `Generate sample ${record.plural} based on your ${entity} name`
: `Enable AI to suggest useful ${field.plural} based on your ${entity} name`;
}
return isDocs
return isSchemaless
? `Sign up for Cloud to generate sample documents based on your ${entity} name`
: `Sign up for Cloud to generate ${field.plural} based on your ${entity} name`;
});
@@ -42,11 +42,12 @@
$registerSearchers(tablesSearcher);
async function createEntity(entityId: string, name: string) {
async function createEntity(entityId: string, name: string, dimension?: number) {
const entity = await databaseSdk.createEntity({
databaseId,
entityId,
name
name,
dimension
});
await invalidate(Dependencies.DATABASE);
@@ -0,0 +1,84 @@
<script lang="ts">
import { page } from '$app/state';
import { Modal } from '$lib/components';
import { Button, InputTextarea } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import { Layout, Icon } from '@appwrite.io/pink-svelte';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
let {
show = $bindable(false),
onGenerate
}: {
show: boolean;
onGenerate: (embeddings: number[]) => void;
} = $props();
let content = $state('');
let generating = $state(false);
const MAX_LENGTH = 25000;
async function generate() {
if (!content.trim()) return;
generating = true;
try {
const response = await sdk
.forProject(page.params.region, page.params.project)
.vectorsDB.createTextEmbeddings({ texts: [content.trim()] });
const embedding = response?.embeddings?.[0]?.embedding;
if (embedding?.length) {
onGenerate(embedding);
content = '';
show = false;
} else {
const error = response?.embeddings?.[0]?.error;
throw new Error(error || 'Failed to generate embeddings');
}
} catch (e) {
addNotification({ type: 'error', message: e instanceof Error ? e.message : String(e) });
} finally {
generating = false;
}
}
$effect(() => {
if (!show) {
content = '';
}
});
</script>
<Modal size="m" bind:show title="Text for embedding" onSubmit={generate}>
<p class="u-margin-block-end-8">
Enter the content you want to convert into a vector. This will allow semantic search and
similarity matching.
</p>
<InputTextarea
id="embedding-content"
label="Content to embed"
placeholder="Paste or type your text here..."
bind:value={content}
maxlength={MAX_LENGTH}
autofocus
required />
<Layout.Stack direction="row" gap="xs" alignItems="center">
<Icon icon={IconInfo} size="s" />
<span class="u-color-text-offline"
>Embeddings are generated using Embedding Gemma model</span>
</Layout.Stack>
<svelte:fragment slot="footer">
<Button secondary disabled={generating} on:click={() => (show = false)}>Cancel</Button>
<Button
submit
disabled={generating || !content.trim()}
submissionLoader
forceShowLoader={generating}>Generate</Button>
</svelte:fragment>
</Modal>
@@ -15,6 +15,7 @@
<script lang="ts">
import {
EditorView,
ViewPlugin,
keymap,
lineNumbers,
highlightActiveLine,
@@ -44,11 +45,12 @@
import type { Text as CmText } from '@codemirror/state';
import { onMount, onDestroy } from 'svelte';
import Id, { truncateId } from '$lib/components/id.svelte';
import { Icon, Layout, Skeleton, Tooltip } from '@appwrite.io/pink-svelte';
import { Badge, Icon, Layout, Skeleton, Tooltip, Typography } from '@appwrite.io/pink-svelte';
import { IconDuplicate } from '@appwrite.io/pink-icons-svelte';
import { Button } from '$lib/elements/forms';
import { copy } from '$lib/helpers/copy';
import { isSmallViewport } from '$lib/stores/viewport';
import { isMac } from '$lib/helpers/platform';
import { makeErrorMessage } from './helpers/errorMessages';
import { customTheme, customSyntaxHighlighting } from './helpers/theme';
@@ -81,6 +83,7 @@
import { sleep } from '$lib/helpers/promises';
import { toLocaleDateTime } from '$lib/helpers/date';
import { Suggestions, Error as ErrorSonner, Save as SavingSonner } from '../sonners';
import HintBadge from '../hintBadge.svelte';
import { json5, json5ParseCache, json5ParseLinter } from 'codemirror-json5';
import { SvelteMap } from 'svelte/reactivity';
@@ -100,6 +103,8 @@
showSuggestions?: boolean;
suggestedAttributes?: string[];
showMockSuggestions?: boolean;
suggestedDefaults?: Record<string, unknown>;
onGenerateEmbedding?: () => void;
}
let {
@@ -117,7 +122,9 @@
showHeaderActions = true,
showSuggestions = false,
suggestedAttributes = [],
showMockSuggestions = false
showMockSuggestions = false,
suggestedDefaults,
onGenerateEmbedding
}: Props = $props();
let editorContainer: HTMLDivElement = $state(null);
@@ -143,6 +150,9 @@
let tooltipMessage = $state('Copy document');
// Cache embeddings values for the fold placeholder (survives fold state changes)
let cachedEmbeddingsPreview = '';
// Store the original data to preserve system values
let originalData = $state<JsonValue>(data);
@@ -197,7 +207,7 @@
if (filteredEntries.length === 0) return '{}';
// Sort entries: $id first, user fields in middle, timestamps last
// Sort entries: $id first, metadata before embeddings, timestamps last
const sortedEntries = filteredEntries.sort(([keyA], [keyB]) => {
// $id always comes first
if (keyA === '$id') return -1;
@@ -210,6 +220,10 @@
if (isKeyATimestamp && !isKeyBTimestamp) return 1;
if (!isKeyATimestamp && isKeyBTimestamp) return -1;
// metadata before embeddings
if (keyA === 'metadata' && keyB === 'embeddings') return -1;
if (keyA === 'embeddings' && keyB === 'metadata') return 1;
return 0;
});
@@ -228,6 +242,11 @@
const items = value as JsonArray;
if (items.length === 0) return '[]';
// Render embeddings as 3 lines: [ / values / ] — enables native fold gutter
if (key === 'embeddings' && items.length > 0 && typeof items[0] === 'number') {
return `[\n${items.join(',')}\n${indentStr}]`;
}
const elements = items.map((item, index) => {
const isLast = index === items.length - 1;
const formattedValue = dataToString(item, indent + 1);
@@ -278,6 +297,27 @@
return serialized;
}
export function replaceData(newData: JsonValue) {
if (!editorView) return;
data = newData;
const content = dataToString(newData);
lastExpectedContent = content;
lastSerializedData = null;
isUpdatingFromEditor = true;
const currentContent = editorView.state.doc.toString();
editorView.dispatch({
changes: { from: 0, to: currentContent.length, insert: content },
annotations: [Transaction.addToHistory.of(false)]
});
editorView.requestMeasure({
read() {},
write(_measure, view) {
foldEmbeddings(view);
}
});
queueMicrotask(() => (isUpdatingFromEditor = false));
}
function findNewDocCursorPos(state: EditorState): number | null {
const maxLines = Math.min(state.doc.lines, 12);
for (let ln = 1; ln <= maxLines; ln += 1) {
@@ -940,10 +980,10 @@
return;
}
// Create an object with suggested attributes as empty strings
const suggestedObject: Record<string, string> = {};
// Create an object with suggested attributes
const suggestedObject: Record<string, unknown> = {};
for (const attr of suggestedAttributes) {
suggestedObject[attr] = '';
suggestedObject[attr] = suggestedDefaults?.[attr] ?? '';
}
// Merge with existing data (keeping system fields)
@@ -1005,6 +1045,40 @@
undo(editorView);
}
/**
* Auto-fold the embeddings array. Uses multi-line format so the
* native fold gutter handles expand/collapse with the same chevron as metadata.
*/
function foldEmbeddings(view: EditorView) {
const doc = view.state.doc;
for (let ln = 1; ln <= doc.lines; ln++) {
const line = doc.line(ln);
const match = line.text.match(/^(\s*embeddings:\s*\[)/);
if (!match) continue;
// Find closing ] line and cache values for fold placeholder
for (let endLn = ln + 1; endLn <= doc.lines; endLn++) {
const endLine = doc.line(endLn);
if (endLine.text.trim().startsWith(']')) {
// Cache the values line before folding hides it
const valuesLn = ln + 1;
if (valuesLn <= doc.lines && valuesLn < endLn) {
cachedEmbeddingsPreview = doc.line(valuesLn).text.trim();
}
const from = line.from + match[1].length; // after [
const to = endLine.from + endLine.text.indexOf(']'); // before ]
if (to > from) {
view.dispatch({
effects: [foldEffect.of({ from, to })],
annotations: [Transaction.addToHistory.of(false)]
});
}
break;
}
}
return;
}
}
onMount(() => {
if (!editorContainer) return;
@@ -1015,6 +1089,76 @@
lineNumbers(),
highlightActiveLine(),
highlightActiveLineGutter(),
// Replace embeddings fold placeholder with truncated value preview
ViewPlugin.define((view) => {
function updatePlaceholder() {
if (!cachedEmbeddingsPreview) return;
// Get char width and styles from a number element, or fall back to cm-content
const numberEl = view.dom.querySelector('.cm-number');
const refEl = numberEl || view.dom.querySelector('.cm-content');
if (!refEl) return;
const charWidth = numberEl
? numberEl.getBoundingClientRect().width /
(numberEl.textContent?.length || 1)
: 7.8;
const numberStyles = getComputedStyle(refEl);
for (const p of view.dom.querySelectorAll('.cm-foldPlaceholder')) {
const lineEl = p.closest('.cm-line');
if (!lineEl?.textContent?.match(/embeddings:\s*\[/)) continue;
const pos = view.posAtDOM(lineEl);
const line = view.state.doc.lineAt(pos);
const match = line.text.match(/^(\s*embeddings:\s*\[)/);
if (!match) continue;
const contentWidth =
view.dom.querySelector('.cm-content')?.clientWidth ?? 600;
const available =
Math.floor(contentWidth / charWidth) - match[1].length - 8;
if (available <= 10) continue;
const truncated = cachedEmbeddingsPreview.slice(0, available - 3);
const lastComma = truncated.lastIndexOf(',');
const el = p as HTMLElement;
el.textContent =
(lastComma > 0 ? truncated.slice(0, lastComma) : truncated) + '...';
el.classList.add('cm-embeddings-fold');
Object.assign(el.style, {
fontFamily: numberStyles.fontFamily,
fontSize: numberStyles.fontSize,
fontWeight: numberStyles.fontWeight,
lineHeight: numberStyles.lineHeight,
letterSpacing: numberStyles.letterSpacing
});
}
}
function scheduleUpdate() {
requestAnimationFrame(() => updatePlaceholder());
}
const observer = new ResizeObserver(scheduleUpdate);
observer.observe(view.dom);
return {
update(update: ViewUpdate) {
if (
update.transactions.some((tr) =>
tr.effects.some((e) => e.is(foldEffect))
)
) {
scheduleUpdate();
}
},
destroy() {
observer.disconnect();
}
};
}),
// Use fold gutter, hide default glyphs (we style via CSS)
foldGutter({ openText: ' ', closedText: ' ' }),
indentUnit.of(' '), // Use 2 spaces for indentation
@@ -1077,6 +1221,17 @@
return false;
}
},
{
key: 'Mod-g',
preventDefault: true,
run: () => {
if (onGenerateEmbedding) {
onGenerateEmbedding();
return true;
}
return false;
}
},
{
key: 'Escape',
run: () => {
@@ -1193,6 +1348,8 @@
state: startState,
parent: editorContainer
});
foldEmbeddings(editorView);
});
onDestroy(() => {
@@ -1259,6 +1416,7 @@
extensions: baseExtensions
});
editorView.setState(newState);
foldEmbeddings(editorView);
if (isNew && !readonly) {
const pos = findNewDocCursorPos(editorView.state);
@@ -1294,6 +1452,7 @@
changes: { from: 0, to: currentContent.length, insert: expectedContent },
annotations: [Transaction.addToHistory.of(false)]
});
foldEmbeddings(editorView);
queueMicrotask(() => (isUpdatingFromEditor = false));
}
});
@@ -1424,6 +1583,30 @@
applySuggestedAttributes();
}} />
{/if}
{#if onGenerateEmbedding && !$isSmallViewport}
<div class="embedding-hint">
<HintBadge>
<Layout.Stack inline gap="xs" direction="row" alignItems="center">
<Typography.Caption variant="400" color="--fgcolor-neutral-secondary">
Press
</Typography.Caption>
<Layout.Stack
direction="row"
inline
gap="xxxs"
alignItems="center"
style="height: fit-content">
<Badge content={isMac() ? '⌘' : 'Ctrl'} variant="secondary" size="xs" />
<Badge content="G" variant="secondary" size="xs" />
</Layout.Stack>
<Typography.Caption variant="400" color="--fgcolor-neutral-secondary">
to generate embeddings
</Typography.Caption>
</Layout.Stack>
</HintBadge>
</div>
{/if}
</div>
{#if !loading && (errorMessage || warningMessage)}
@@ -1715,5 +1898,18 @@
padding: 0 4px;
background: var(--bgcolor-neutral-secondary);
}
:global(.cm-foldPlaceholder.cm-embeddings-fold) {
color: var(--brand-mint-600);
background: transparent;
}
}
.embedding-hint {
bottom: 12px;
right: 12px;
z-index: 50;
position: absolute;
pointer-events: none;
}
</style>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="hint-badge">
{@render children()}
</div>
<style>
.hint-badge {
height: 44px;
width: max-content;
gap: var(--gap-xxs);
align-items: center;
display: inline-flex;
justify-content: center;
padding: var(--space-5);
border-radius: var(--border-radius-m);
background: var(--bgcolor-neutral-primary);
border: var(--border-width-s) solid var(--border-neutral);
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.03),
0 4px 4px 0 rgba(0, 0, 0, 0.04);
}
</style>
@@ -1,6 +1,7 @@
<script lang="ts">
import { Badge, FloatingActionBar, Layout, Typography } from '@appwrite.io/pink-svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import HintBadge from '../hintBadge.svelte';
let {
show = true,
@@ -29,7 +30,7 @@
</button>
{:else}
<div class="suggestions-wrapper" style:transform={`translateX(${translateX})`}>
<div class="popover-content">
<HintBadge>
<Layout.Stack inline gap="xs" direction="row" alignItems="center">
<Typography.Caption variant="400" color="--fgcolor-neutral-secondary">
Press
@@ -49,7 +50,7 @@
{suffix}
</Typography.Caption>
</Layout.Stack>
</div>
</HintBadge>
</div>
{/if}
{/if}
@@ -62,22 +63,6 @@
pointer-events: none;
}
.popover-content {
height: 44px;
width: max-content;
gap: var(--gap-xxs);
align-items: center;
display: inline-flex;
justify-content: center;
padding: var(--space-5);
border-radius: var(--border-radius-m);
background: var(--bgcolor-neutral-primary);
border: var(--border-width-s) solid var(--border-neutral);
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.03),
0 4px 4px 0 rgba(0, 0, 0, 0.04);
}
.floating-action-bar {
left: 50%;
bottom: 5%;
@@ -51,11 +51,12 @@
SideSheet,
EditRecordPermissions,
RecordActivity,
CreateIndex,
useDatabaseSdk,
DEFAULT_VECTOR_DIMENSION,
type Field,
type Index
} from '$database/(entity)';
import CreateIndex from './indexes/createIndex.svelte';
import {
entityColumnSuggestions,
type ColumnInput,
@@ -98,7 +99,10 @@
}
return realtime.forProject(page.params.region, ['project', 'console'], (response) => {
if (response.events.includes('documentsdb.*.collections.*.indexes.*')) {
if (
response.events.includes('documentsdb.*.collections.*.indexes.*') ||
response.events.includes('vectorsdb.*.collections.*.indexes.*')
) {
if (!isWaterfallFromFaker && !$entityColumnSuggestions.entity) {
invalidate(Dependencies.COLLECTION);
}
@@ -205,7 +209,11 @@
});
async function handleCreateIndex(index: Index) {
const databaseSdk = useDatabaseSdk(page.params.region, page.params.project);
const databaseSdk = useDatabaseSdk(
page.params.region,
page.params.project,
data.database.type
);
await databaseSdk.createIndex({
databaseId: page.params.database,
@@ -271,13 +279,33 @@
const { rows, ids } = generateFakeRecords($randomDataModalState.value, fields);
documentIds = ids;
await sdk
.forProject(page.params.region, page.params.project)
.documentsDB.createDocuments({
const dbType = data.database?.type;
const isVectorsDb = dbType === 'vectorsdb';
const dimension = collection?.dimension ?? DEFAULT_VECTOR_DIMENSION;
// For vectorsdb, wrap fields in metadata and add empty embeddings
const documents = isVectorsDb
? rows.map((row) => {
const { $id, ...rest } = row;
return { $id, metadata: rest, embeddings: new Array(dimension).fill(0) };
})
: rows;
const projectSdk = sdk.forProject(page.params.region, page.params.project);
if (isVectorsDb) {
await projectSdk.vectorsDB.createDocuments({
databaseId: page.params.database,
collectionId: page.params.collection,
documents: rows
documents
});
} else {
await projectSdk.documentsDB.createDocuments({
databaseId: page.params.database,
collectionId: page.params.collection,
documents
});
}
await invalidate(Dependencies.DOCUMENTS);
} catch (e) {
@@ -340,6 +368,7 @@
}}>
<CreateIndex
entity={collection}
databaseType={data.database.type}
bind:this={createIndex}
bind:showCreateIndex={$showCreateIndexSheet.show}
externalFieldKey={$showCreateIndexSheet.column}
@@ -304,7 +304,7 @@
{/snippet}
</EmptySheet>
{:else}
<EmptySheet mode="records" type="documentsdb" showActions={$canWriteRows}>
<EmptySheet mode="records" type={data.database.type} showActions={$canWriteRows}>
{#snippet actions()}
<EmptySheetCards
icon={IconViewBoards}
@@ -1,12 +1,12 @@
import { Dependencies, SPREADSHEET_PAGE_LIMIT } from '$lib/constants';
import { getLimit, getPage, getQuery, getView, pageToOffset, View } from '$lib/helpers/load';
import { sdk } from '$lib/stores/sdk';
import type { PageLoad } from './$types';
import { queries, queryParamToMap } from '$lib/components/filters';
import { buildGridQueries, extractSortFromQueries } from '$database/store';
import { getCollectionService } from '$database/(entity)';
export const load: PageLoad = async ({ params, depends, url, route, parent }) => {
const { collection } = await parent();
const { collection, database } = await parent();
depends(Dependencies.DOCUMENTS);
const page = getPage(url);
@@ -20,6 +20,7 @@ export const load: PageLoad = async ({ params, depends, url, route, parent }) =>
queries.set(parsedQueries);
const currentSort = extractSortFromQueries(parsedQueries);
const collectionSdk = getCollectionService(params.region, params.project, database.type);
return {
offset,
@@ -28,7 +29,7 @@ export const load: PageLoad = async ({ params, depends, url, route, parent }) =>
query,
currentSort,
parsedQueries,
documents: await sdk.forProject(params.region, params.project).documentsDB.listDocuments({
documents: await collectionSdk.listDocuments({
databaseId: params.database,
collectionId: params.collection,
queries: buildGridQueries(limit, offset, parsedQueries, collection)
@@ -1,13 +1,12 @@
<script lang="ts">
import { page } from '$app/state';
import { sdk } from '$lib/stores/sdk';
import { type DocumentsDBIndexType } from '@appwrite.io/console';
import type { PageProps } from './$types';
import {
type CreateIndexesCallbackType,
Indexes,
EmptySheet,
EmptySheetCards
EmptySheetCards,
useDatabaseSdk
} from '$database/(entity)';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import CreateIndexForm from './createIndex.svelte';
@@ -16,20 +15,14 @@
let createIndexRef: CreateIndexForm;
const params = $derived({
databaseId: page.params.database,
collectionId: page.params.collection
});
const documentsDB = $derived(
sdk.forProject(page.params.region, page.params.project).documentsDB
);
const databaseSdk = useDatabaseSdk(page.params.region, page.params.project, data.database.type);
async function onCreateIndex(index: CreateIndexesCallbackType) {
await documentsDB.createIndex({
...params,
await databaseSdk.createIndex({
databaseId: page.params.database,
entityId: page.params.collection,
key: index.key,
type: index.type as DocumentsDBIndexType,
type: index.type,
attributes: index.fields,
lengths: index.lengths,
orders: index.orders
@@ -39,8 +32,9 @@
async function onDeleteIndexes(selectedKeys: string[]) {
await Promise.all(
selectedKeys.map((key) =>
documentsDB.deleteIndex({
...params,
databaseSdk.deleteIndex({
databaseId: page.params.database,
entityId: page.params.collection,
key
})
)
@@ -52,6 +46,7 @@
{#snippet createIndexForm()}
<CreateIndexForm
entity={data.collection}
databaseType={data.database.type}
{onCreateIndex}
showCreateIndex={true}
bind:this={createIndexRef} />
@@ -1,6 +1,7 @@
<script module lang="ts">
import { DocumentsDBIndexType, OrderBy } from '@appwrite.io/console';
import { DocumentsDBIndexType, VectorsDBIndexType, OrderBy } from '@appwrite.io/console';
import type { CreateIndexesCallbackType } from '$database/(entity)';
import type { DatabaseType } from '$database/(entity)/helpers/terminology';
</script>
<script lang="ts">
@@ -12,28 +13,60 @@
import { remove } from '$lib/helpers/array';
import { addNotification } from '$lib/stores/notifications';
import { Icon, Layout } from '@appwrite.io/pink-svelte';
import { IconCalendar, IconFingerPrint, IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
import { IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import { type Entity, getTerminologies } from '$database/(entity)';
import { resolveRoute, withPath } from '$lib/stores/navigation';
let {
entity,
databaseType,
showCreateIndex = $bindable(false),
externalFieldKey = null,
onCreateIndex
}: {
entity: Entity;
databaseType: DatabaseType;
showCreateIndex: boolean;
externalFieldKey?: string | null;
onCreateIndex: (index: CreateIndexesCallbackType) => Promise<void>;
} = $props();
let key = $state('');
let initializedForOpen = $state(false);
let selectedType = $state<DocumentsDBIndexType>(DocumentsDBIndexType.Key);
let selectedType = $state<string>(
databaseType === 'vectorsdb' ? VectorsDBIndexType.Key : DocumentsDBIndexType.Key
);
const { dependencies, terminology } = getTerminologies();
const hasAttributes = $derived(entity.fields?.length > 0);
const types = $derived.by(() => {
if (databaseType === 'vectorsdb') {
return [
{ value: VectorsDBIndexType.Key, label: 'Key' },
{ value: VectorsDBIndexType.Unique, label: 'Unique' },
{ value: VectorsDBIndexType.HnswEuclidean, label: 'HNSW Euclidean' },
{ value: VectorsDBIndexType.HnswDot, label: 'HNSW Dot' },
{ value: VectorsDBIndexType.HnswCosine, label: 'HNSW Cosine' },
{ value: VectorsDBIndexType.Object, label: 'Object' }
];
}
return [
{ value: DocumentsDBIndexType.Key, label: 'Key' },
{ value: DocumentsDBIndexType.Unique, label: 'Unique' },
{ value: DocumentsDBIndexType.Fulltext, label: 'Fulltext' }
];
});
const isHnswType = $derived(
[
VectorsDBIndexType.HnswEuclidean,
VectorsDBIndexType.HnswDot,
VectorsDBIndexType.HnswCosine
].includes(selectedType as VectorsDBIndexType)
);
const isObjectType = $derived(selectedType === VectorsDBIndexType.Object);
const fieldOptions = $derived(
(entity.fields ?? []).map((field) => ({
@@ -44,16 +77,10 @@
let fieldList: Array<{
value: string;
order: OrderBy;
order: OrderBy | null;
length: number | null;
}> = $state([{ value: '', order: OrderBy.Asc, length: null }]);
const types = [
{ value: DocumentsDBIndexType.Key, label: 'Key' },
{ value: DocumentsDBIndexType.Unique, label: 'Unique' },
{ value: DocumentsDBIndexType.Fulltext, label: 'Fulltext' }
];
const orderOptions = [
{ value: OrderBy.Asc, label: 'ASC' },
{ value: OrderBy.Desc, label: 'DESC' }
@@ -71,12 +98,20 @@
}
function initialize() {
selectedType = DocumentsDBIndexType.Key;
fieldList = [{ value: '', order: OrderBy.Asc, length: null }];
selectedType =
databaseType === 'vectorsdb' ? VectorsDBIndexType.Key : DocumentsDBIndexType.Key;
fieldList = externalFieldKey
? [{ value: externalFieldKey, order: OrderBy.Asc, length: null }]
: [{ value: '', order: OrderBy.Asc, length: null }];
key = `index_${entity.indexes.length + 1}`;
}
const addFieldDisabled = $derived(!fieldList.at(-1)?.value || !fieldList.at(-1)?.order);
const addFieldDisabled = $derived(
isHnswType ||
isObjectType ||
!fieldList.at(-1)?.value ||
(!isObjectType && !fieldList.at(-1)?.order)
);
const isOnIndexesPage = $derived(page.route.id?.endsWith('/indexes'));
const navigatorPathToIndexes = $derived.by(() => {
@@ -101,10 +136,25 @@
}
});
// HNSW indexes: auto-fill embeddings, single field only.
// When switching away to Key/Unique, reset stale `order: null` left over from HNSW.
$effect(() => {
if (isHnswType) {
fieldList = [{ value: 'embeddings', order: null, length: null }];
} else if (!isObjectType && fieldList.some((f) => f.order === null)) {
fieldList = [{ value: '', order: OrderBy.Asc, length: null }];
}
});
export async function create() {
const fieldType = terminology.field.lower.singular;
if (!key || !selectedType || addFieldDisabled) {
if (
!key ||
!selectedType ||
(!isHnswType && !isObjectType && addFieldDisabled) ||
((isHnswType || isObjectType) && !fieldList.at(0)?.value)
) {
addNotification({
type: 'error',
message: `Selected ${fieldType} key or type invalid`
@@ -117,8 +167,16 @@
key,
type: selectedType,
fields: fieldList.map((a) => a.value),
lengths: fieldList.map((a) => (a.length ? Number(a.length) : null)),
orders: fieldList.map((a) => a.order)
lengths:
isHnswType || isObjectType
? []
: fieldList.map((a) => (a.length ? Number(a.length) : null)),
orders:
isHnswType || isObjectType
? []
: fieldList
.map((a) => a.order)
.filter((order): order is OrderBy => order !== null)
});
await Promise.allSettled([
@@ -173,21 +231,20 @@
{#each fieldList as field, index}
{@const direction = $isSmallViewport ? 'column' : 'row'}
<Layout.Stack {direction}>
{#if hasAttributes}
{#if isHnswType}
<InputText
required
disabled
id={`field-${index}`}
label={index === 0 ? fieldType : undefined}
bind:value={field.value} />
{:else if fieldOptions.length}
<InputSelect
required
options={[
{ value: '$id', label: '$id', leadingIcon: IconFingerPrint },
{
value: '$createdAt',
label: '$createdAt',
leadingIcon: IconCalendar
},
{
value: '$updatedAt',
label: '$updatedAt',
leadingIcon: IconCalendar
},
{ value: '$id', label: '$id' },
{ value: '$createdAt', label: '$createdAt' },
{ value: '$updatedAt', label: '$updatedAt' },
...fieldOptions
]}
id={`field-${index}`}
@@ -203,20 +260,22 @@
bind:value={field.value} />
{/if}
<InputSelect
options={orderOptions}
required
id={`order-${index}`}
label={index === 0 ? 'Order' : undefined}
bind:value={field.order}
placeholder="Select order" />
{#if !isHnswType && !isObjectType}
<InputSelect
options={orderOptions}
required
id={`order-${index}`}
label={index === 0 ? 'Order' : undefined}
bind:value={field.order}
placeholder="Select order" />
{#if selectedType === DocumentsDBIndexType.Key}
<InputNumber
id={`length-${index}`}
label={index === 0 ? 'Length' : undefined}
placeholder="Enter length"
bind:value={field.length} />
{#if selectedType === DocumentsDBIndexType.Key || selectedType === VectorsDBIndexType.Key}
<InputNumber
id={`length-${index}`}
label={index === 0 ? 'Length' : undefined}
placeholder="Enter length"
bind:value={field.length} />
{/if}
{/if}
{#if $isSmallViewport}
@@ -247,12 +306,14 @@
{/if}
</Layout.Stack>
{/each}
<div>
<Button compact on:click={addField} disabled={addFieldDisabled}>
<Icon icon={IconPlus} slot="start" size="s" />
Add {fieldTypeLower}
</Button>
</div>
{#if !isHnswType && !isObjectType}
<div>
<Button compact on:click={addField} disabled={addFieldDisabled}>
<Icon icon={IconPlus} slot="start" size="s" />
Add {fieldTypeLower}
</Button>
</div>
{/if}
</Layout.Stack>
<style lang="scss">
@@ -1,13 +1,13 @@
<script lang="ts">
import { page } from '$app/state';
import { sdk } from '$lib/stores/sdk';
import { Container } from '$lib/layout';
import {
DangerZone,
UpdateName,
UpdatePermissions,
UpdateSecurity,
UpdateStatus
UpdateStatus,
useDatabaseSdk
} from '$database/(entity)';
import type { PageProps } from './$types';
import DisplayName from './displayName.svelte';
@@ -17,18 +17,15 @@
const collection = $derived(data.collection);
const params = $derived.by(() => {
return {
name: collection.name,
collectionId: page.params.collection,
databaseId: page.params.database
};
const databaseSdk = useDatabaseSdk(page.params.region, page.params.project, data.database.type);
const entityParams = $derived({
databaseId: page.params.database,
entityId: page.params.collection
});
async function deleteCollection() {
await sdk
.forProject(page.params.region, page.params.project)
.documentsDB.deleteCollection({ ...params });
await databaseSdk.deleteEntity(entityParams);
}
async function updateCollection(
@@ -39,9 +36,11 @@
documentSecurity: boolean;
}>
) {
await sdk
.forProject(page.params.region, page.params.project)
.documentsDB.updateCollection({ ...params, ...updates });
await databaseSdk.updateEntity({
...entityParams,
name: collection.name,
...updates
});
}
</script>
@@ -60,6 +60,7 @@
type JsonValue,
NoSqlEditor
} from '$database/collection-[collection]/(components)/editor';
import EmbeddingModal from '$database/collection-[collection]/(components)/editor/embeddingModal.svelte';
import { buildFieldUrl } from '$database/(entity)/helpers/navigation';
import {
SpreadsheetOptions,
@@ -75,13 +76,15 @@
$: if ($documents) {
paginatedDocuments.clear();
const docs = $documents.documents;
// If we have a new document, add it at the start
if ($noSqlDocument.isDirty && $noSqlDocument.isNew) {
const tempDoc = $noSqlDocument.document as Models.DefaultDocument;
const docsWithTemp = [tempDoc, ...$documents.documents];
const docsWithTemp = [tempDoc, ...docs];
paginatedDocuments.setPage(1, docsWithTemp);
} else {
paginatedDocuments.setPage(1, $documents.documents);
paginatedDocuments.setPage(1, docs);
}
}
@@ -89,6 +92,25 @@
const collectionId = page.params.collection;
const databaseSdk = useDatabaseSdk(page.params.region, page.params.project, data.database.type);
const isVectorsDb = data.database.type === 'vectorsdb';
let showEmbeddingModal = false;
let editorRef: { replaceData: (data: JsonValue) => void } | undefined;
const projectSdk = sdk.forProject(page.params.region, page.params.project);
const listDocumentsFn = isVectorsDb
? projectSdk.vectorsDB.listDocuments.bind(projectSdk.vectorsDB)
: projectSdk.documentsDB.listDocuments.bind(projectSdk.documentsDB);
const getDocumentFn = isVectorsDb
? projectSdk.vectorsDB.getDocument.bind(projectSdk.vectorsDB)
: projectSdk.documentsDB.getDocument.bind(projectSdk.documentsDB);
function handleEmbeddingGenerated(embeddings: number[]) {
if ($noSqlDocument.document && typeof $noSqlDocument.document === 'object') {
const updated = { ...$noSqlDocument.document, embeddings };
editorRef?.replaceData(updated);
}
}
const emptyCellsLimit = $spreadsheetLoading
? 30
: $isSmallViewport
@@ -115,13 +137,11 @@
const documentId = $noSqlDocument.documentId;
noSqlDocument.update({ documentId: null }); // reset for later!
const loadedDocument = await sdk
.forProject(page.params.region, page.params.project)
.documentsDB.getDocument({
databaseId: page.params.database,
collectionId: page.params.collection,
documentId
});
const loadedDocument = await getDocumentFn({
databaseId: page.params.database,
collectionId: page.params.collection,
documentId
});
if (loadedDocument) {
noSqlDocument.edit(loadedDocument);
@@ -462,7 +482,9 @@
spreadsheetRenderKey.set(hash(Date.now().toString()));
const firstDocument = $documents?.documents?.[0];
if (firstDocument) {
noSqlDocument.update({ document: firstDocument });
noSqlDocument.update({
document: firstDocument
});
}
} catch (error) {
addNotification({
@@ -492,19 +514,17 @@
const filterQueries = parsedQueries.size ? data.parsedQueries.values() : [];
$paginatedDocumentsLoading = true;
const loadedRows = await sdk
.forProject(page.params.region, page.params.project)
.documentsDB.listDocuments({
databaseId,
collectionId,
queries: [
getCorrectOrderQuery(),
Query.limit(SPREADSHEET_PAGE_LIMIT),
Query.offset(pageToOffset(pageNumber, SPREADSHEET_PAGE_LIMIT)),
...filterQueries /* filter queries */,
...buildWildcardEntitiesQuery(collection)
]
});
const loadedRows = await listDocumentsFn({
databaseId,
collectionId,
queries: [
getCorrectOrderQuery(),
Query.limit(SPREADSHEET_PAGE_LIMIT),
Query.offset(pageToOffset(pageNumber, SPREADSHEET_PAGE_LIMIT)),
...filterQueries /* filter queries */,
...buildWildcardEntitiesQuery(collection)
]
});
paginatedDocuments.setPage(pageNumber, loadedRows.documents);
$paginatedDocumentsLoading = false;
@@ -520,18 +540,16 @@
paginatedDocuments.setMaxPage(targetPageNum);
$paginatedDocumentsLoading = true;
const loadedRows = await sdk
.forProject(page.params.region, page.params.project)
.documentsDB.listDocuments({
databaseId,
collectionId,
queries: [
getCorrectOrderQuery(),
Query.limit(SPREADSHEET_PAGE_LIMIT),
Query.offset(pageToOffset(targetPageNum, SPREADSHEET_PAGE_LIMIT)),
...buildWildcardEntitiesQuery(collection)
]
});
const loadedRows = await listDocumentsFn({
databaseId,
collectionId,
queries: [
getCorrectOrderQuery(),
Query.limit(SPREADSHEET_PAGE_LIMIT),
Query.offset(pageToOffset(targetPageNum, SPREADSHEET_PAGE_LIMIT)),
...buildWildcardEntitiesQuery(collection)
]
});
paginatedDocuments.setPage(targetPageNum, loadedRows.documents);
$paginatedDocumentsLoading = false;
@@ -553,14 +571,29 @@
const MIN_DOCS_FOR_FUZZY_SUGGESTIONS = 5;
$: useMockSuggestions =
!isVectorsDb &&
$noSqlDocument.isNew &&
($documents?.documents?.length ?? 0) < MIN_DOCS_FOR_FUZZY_SUGGESTIONS;
$: metadataKeys =
isVectorsDb && $documents?.documents
? (fuzzySearchKeys(
$documents.documents.map((d) => d.metadata ?? {}),
{ minOccurrences: 2 }
) ?? [])
: [];
$: vectorsDbMetadataDefaults = isVectorsDb
? Object.fromEntries(metadataKeys.map((key) => [key, '']))
: {};
$: suggestedAttributes =
$noSqlDocument.isNew && $documents?.documents
? useMockSuggestions
? mockSuggestions.columns.map((column) => column.name)
: (fuzzySearchKeys($documents.documents, { minOccurrences: 2 }) ?? [])
? isVectorsDb
? ['metadata', 'embeddings']
: useMockSuggestions
? mockSuggestions.columns.map((column) => column.name)
: (fuzzySearchKeys($documents.documents, { minOccurrences: 2 }) ?? [])
: [];
$: showSuggestions = $noSqlDocument.isNew && suggestedAttributes.length > 0;
@@ -863,6 +896,7 @@
{#snippet noSqlEditor()}
<NoSqlEditor
bind:this={editorRef}
ctrlSave
isNew={$noSqlDocument.isNew}
loading={$noSqlDocument.loading}
@@ -872,6 +906,12 @@
{showSuggestions}
{suggestedAttributes}
showMockSuggestions={useMockSuggestions}
suggestedDefaults={isVectorsDb
? {
metadata: vectorsDbMetadataDefaults,
embeddings: []
}
: undefined}
onDiscard={() => {
const firstDocument = $documents?.documents?.[0];
if (firstDocument) {
@@ -881,7 +921,8 @@
}
}}
onSave={async (document) => await createOrUpdateDocument(document)}
onChange={(_, hasDataChanged) => noSqlDocument.update({ hasDataChanged })} />
onChange={(_, hasDataChanged) => noSqlDocument.update({ hasDataChanged })}
onGenerateEmbedding={isVectorsDb ? () => (showEmbeddingModal = true) : undefined} />
{/snippet}
{#snippet sideSheetHeaderAction()}
@@ -981,6 +1022,10 @@
</Confirm>
{/if}
{#if isVectorsDb}
<EmbeddingModal bind:show={showEmbeddingModal} onGenerate={handleEmbeddingGenerated} />
{/if}
<style lang="scss">
.floating-action-bar {
left: 50%;
@@ -0,0 +1,200 @@
<script lang="ts" module>
export type HeaderCellAction =
| 'update'
| 'column-left'
| 'column-right'
| 'duplicate-header'
| 'create-index'
| 'sort-asc'
| 'sort-desc'
| 'delete';
export type RowCellAction =
| 'update'
| 'duplicate-row'
| 'permissions'
| 'activity'
| 'copy-url'
| 'copy-json'
// | 'copy-snippet'
| 'delete';
</script>
<script lang="ts">
import type { ComponentType, Snippet } from 'svelte';
import { Divider, Popover, ActionMenu } from '@appwrite.io/pink-svelte';
import {
IconArrowLeft,
IconArrowRight,
IconChartBar,
IconClipboardCopy,
IconDuplicate,
IconKey,
IconLink,
IconPencil,
IconSortAscending,
IconSortDescending,
IconTrash
} from '@appwrite.io/pink-icons-svelte';
import { databaseColumnSheetOptions } from './store';
import type { Columns } from '$database/store';
import { isRelationship } from './rows/store';
interface MenuItem {
label?: string;
action?: HeaderCellAction | RowCellAction;
danger?: boolean;
divider?: boolean;
icon?: ComponentType;
}
// Only allow sort for these columns
const internalColumns = ['$sequence', '$id', '$createdAt', '$updatedAt'];
const headerMenuItems: MenuItem[] = [
{ label: 'Update', icon: IconPencil, action: 'update' },
{ label: 'Create column left', icon: IconArrowLeft, action: 'column-left' },
{ label: 'Create column right', icon: IconArrowRight, action: 'column-right' },
{ label: 'Duplicate', icon: IconDuplicate, action: 'duplicate-header' },
{ divider: true },
{ label: 'Create index', icon: IconPencil, action: 'create-index' },
{ label: 'Sort ascending', icon: IconSortAscending, action: 'sort-asc' },
{ label: 'Sort descending', icon: IconSortDescending, action: 'sort-desc' },
{ divider: true },
{ label: 'Delete', icon: IconTrash, action: 'delete', danger: true }
];
const rowMenuItems: MenuItem[] = [
{ label: 'Update', icon: IconPencil, action: 'update' },
{ label: 'Duplicate', icon: IconDuplicate, action: 'duplicate-row' },
{ divider: true },
{ label: 'Manage permissions', icon: IconKey, action: 'permissions' },
{ label: 'View activity', icon: IconChartBar, action: 'activity' },
{ divider: true },
{ label: 'Copy URL', icon: IconLink, action: 'copy-url' },
{ label: 'Copy as JSON', icon: IconClipboardCopy, action: 'copy-json' },
{ divider: true },
{ label: 'Delete', icon: IconTrash, action: 'delete', danger: true }
];
let {
column,
columnId = null,
children,
onSelect,
onVisibilityChanged,
type
}: {
column: Columns;
columnId?: string;
type: 'header' | 'row';
onVisibilityChanged?: (visible: boolean) => void;
onSelect: (option: HeaderCellAction | RowCellAction, columnId: string) => void;
children: Snippet<[toggle: (event: Event) => void]>;
} = $props();
function handleSelect(action: HeaderCellAction | RowCellAction, hide: () => void) {
hide();
$databaseColumnSheetOptions.column = column;
if (action === 'column-left') {
$databaseColumnSheetOptions.direction = {
neighbour: columnId,
to: 'left'
};
} else if (action === 'column-right') {
$databaseColumnSheetOptions.direction = {
neighbour: columnId,
to: 'right'
};
}
onSelect(action, columnId);
}
function shouldShow(item: MenuItem): boolean {
const isSequence = columnId === '$sequence';
const isSystemColumn = internalColumns.includes(columnId);
if (type === 'header') {
if (isSequence) {
return ['sort-asc', 'sort-desc'].includes(item?.action);
}
if (['delete', 'update', 'duplicate-header'].includes(item?.action) && isSystemColumn) {
return false;
}
// hide column-left and create-index for $id (first column, already indexed)
if (columnId === '$id' && ['column-left', 'create-index'].includes(item?.action)) {
return false;
}
// hide sort options for relationship columns
if (isRelationship(column) && ['sort-asc', 'sort-desc'].includes(item?.action)) {
return false;
}
}
return true;
}
function cleanMenu(items: MenuItem[]): MenuItem[] {
const visible = items.filter((item) => item.divider || shouldShow(item));
return visible.filter((item, i, arr) => {
const prev = arr[i - 1];
const next = arr[i + 1];
if (item.divider) {
return prev && !prev.divider && next && !next.divider;
}
return true;
});
}
let htmlSpanElement = $state<HTMLSpanElement | null>(null);
$effect(() => {
const visible = htmlSpanElement !== null;
onVisibilityChanged?.(visible);
});
</script>
<Popover let:toggle padding="none" placement="bottom-start" portal>
{@render children(toggle)}
<svelte:fragment slot="tooltip" let:hide>
{@const menuItems = cleanMenu(type === 'header' ? headerMenuItems : rowMenuItems)}
<!-- hacky, i know! -->
<span bind:this={htmlSpanElement}> </span>
<div class="action-menu-root">
<ActionMenu.Root width="180px">
{#each menuItems as item, index (index)}
{#if item.divider}
{@const isLastDivider = index === menuItems.length - 2}
<div
style:margin-inline="-1rem"
style:padding-block-start="0.5rem"
style:padding-block-end={isLastDivider ? '0.25rem' : '0.5rem'}>
<Divider />
</div>
{:else if shouldShow(item)}
<ActionMenu.Item.Button
leadingIcon={item.icon}
status={item.danger ? 'danger' : undefined}
on:click={() => item.action && handleSelect(item.action, hide)}>
{item.label}
</ActionMenu.Item.Button>
{/if}
{/each}
</ActionMenu.Root>
</div>
</svelte:fragment>
</Popover>
<style>
</style>
@@ -14,8 +14,14 @@
import DocumentsDB from './(assets)/documents-db.svg';
import DocumentsDBDark from './(assets)/dark/documents-db.svg';
import { isSmallViewport, isViewPortWidthInRange } from '$lib/stores/viewport';
import VectorsDB from './(assets)/vectors-db.svg';
import VectorsDBDark from './(assets)/dark/vectors-db.svg';
import { isSmallViewport } from '$lib/stores/viewport';
import type { DatabaseType } from '$database/(entity)';
import { flags } from '$lib/flags';
import { user } from '$lib/stores/user';
import { organization } from '$lib/stores/organization';
const {
disabled,
@@ -29,8 +35,7 @@
/*const mongoDbImage = $derived(isDark ? MongoDBDark : MongoDB);*/
const tablesDbImage = $derived(isDark ? TablesDBDark : TablesDB);
const documentsDbImage = $derived(isDark ? DocumentsDBDark : DocumentsDB);
const inRangeStore = isViewPortWidthInRange(1024, 1280);
const vectorsDbImage = $derived(isDark ? VectorsDBDark : VectorsDB);
</script>
{#if $isSmallViewport}
@@ -50,7 +55,7 @@
>Store, organize, and manage your app data</Typography.Text>
</Layout.Stack>
<Layout.Grid columns={2} columnsS={1} gap="xl">
<Layout.Grid columns={3} columnsS={2} columnsXS={1} gap="xl">
<!-- legacy, tablesDB -->
{@render databaseTypeCard({
type: 'tablesdb',
@@ -60,14 +65,25 @@
image: tablesDbImage
})}
<!-- documentsDB -->
{@render databaseTypeCard({
type: 'documentsdb',
title: 'DocumentsDB',
subtitle:
'Store flexible data without a fixed schema. Best for unstructured data and simple querying.',
image: documentsDbImage
})}
{#if flags.multiDb({ account: $user, organization: $organization })}
<!-- documentsDB -->
{@render databaseTypeCard({
type: 'documentsdb',
title: 'DocumentsDB',
subtitle:
'Store flexible data without a fixed schema. Best for unstructured data and simple querying.',
image: documentsDbImage
})}
<!-- vectorsDB -->
{@render databaseTypeCard({
type: 'vectorsdb',
title: 'VectorsDB',
subtitle:
'Store data as vectors to find similar results. Best for semantic search and recommendations.',
image: vectorsDbImage
})}
{/if}
</Layout.Grid>
</Layout.Stack>
{/snippet}
@@ -79,25 +95,16 @@
padding="none"
{disabled}
on:click={() => onDatabaseTypeSelected?.(type)}>
{@const direction = $isSmallViewport || $inRangeStore ? 'column' : 'row'}
<Layout.Stack gap="none" {direction}>
<img
src={image}
class="database-image"
class:adaptive-height={$inRangeStore}
alt="database type artwork" />
<Layout.Stack gap="none" direction="column">
<img src={image} class="database-image adaptive-height" alt="database type artwork" />
{#if $isSmallViewport || $inRangeStore}
<Divider />
{/if}
<Divider />
<Layout.Stack
gap="xxs"
direction="column"
justifyContent="space-between"
style="padding: var(--gap-xl); flex: 1; {!$isSmallViewport && !$inRangeStore
? 'border-inline-start: 1px solid var(--border-neutral);'
: ''}">
style="padding: var(--gap-xl); flex: 1;">
<Layout.Stack direction="column" gap="xxs">
<Layout.Stack
inline
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 223 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 223 KiB