Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { useHandler } from "~tests/context/useHandler";
import { createModelPlugin } from "@webiny/api-headless-cms";
import { configurations } from "~/configurations";
import type { CmsContext } from "~/types";
import type { CmsModel } from "@webiny/api-headless-cms/types/index.js";

const productPlugin = createModelPlugin({
noValidate: true,
modelId: "product",
singularApiName: "Product",
pluralApiName: "Products",
group: "ecommerce",
name: "Product",
description: "Product model",
fields: [
{
id: "name",
fieldId: "name",
storageId: "text@name",
type: "text",
label: "Name",
validation: [],
listValidation: []
}
],
layout: [],
titleFieldId: "name"
});

process.env.OPENSEARCH_SHARED_INDEXES = "true";

describe("delete model - shared index must survive model deletion", () => {
const { handler, elasticsearch } = useHandler<CmsContext>({
plugins: [productPlugin]
});

const createContext = () => {
return handler({
path: "/cms/manage/en-US",
headers: {
"x-tenant": "root"
}
});
};

it("should not destroy shared ES index when model is deleted via storage operations", async () => {
const context = await createContext();
const model = await context.cms.getModel("product");

// Create entries so the index gets populated.
for (let i = 0; i < 3; i++) {
await context.cms.createEntry(model, {
values: { name: `Product ${i}` }
});
}

const { index } = configurations.es({ model });
await elasticsearch.indices.refresh({ index });

// Verify index exists and has entries.
const existsBefore = await elasticsearch.indices.exists({ index });
expect(existsBefore.body).toBeTrue();

const countBefore = await elasticsearch.count({ index });
expect(countBefore.body.count).toBeGreaterThanOrEqual(3);

// Delete model via storage operations.
// With shared indexes, this index is shared across ALL tenants.
// Deleting it here would nuke every tenant's product entries.
await context.cms.storageOperations.models.delete({
model: model as CmsModel
});

// The shared index must still exist after model deletion.
const existsAfter = await elasticsearch.indices.exists({ index });
expect(existsAfter.body).toBeTrue();

// Entries must still be in the index.
const countAfter = await elasticsearch.count({ index });
expect(countAfter.body.count).toBeGreaterThanOrEqual(3);
});

it("should preserve entries queryable via CMS API after model storage record is deleted", async () => {
const context = await createContext();
const model = await context.cms.getModel("product");

await context.cms.createEntry(model, {
values: { name: "Survivor" }
});

const { index } = configurations.es({ model });
await elasticsearch.indices.refresh({ index });

// Delete model record from storage (DDB + ES index).
await context.cms.storageOperations.models.delete({
model: model as CmsModel
});

// Model still resolves (it's a plugin model).
const modelAfter = await context.cms.getModel("product");
expect(modelAfter).toBeTruthy();

// Entries must still be listable.
const [entries] = await context.cms.listLatestEntries(modelAfter, {});
expect(entries).toHaveLength(1);
expect(entries[0].values.name).toBe("Survivor");
});
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import type { Client } from "@webiny/api-opensearch";
import { isSharedOpenSearchIndex } from "@webiny/api-opensearch";
import type { CmsModel } from "@webiny/api-headless-cms/types/index.js";
import { configurations } from "~/configurations.js";

interface DeleteElasticsearchIndexParams {
client: Client;
model: CmsModel;
model: Pick<CmsModel, "modelId" | "tenant">;
}

export const deleteElasticsearchIndex = async (
params: DeleteElasticsearchIndexParams
): Promise<void> => {
/**
* With shared indexes, a single index holds entries from all tenants
* for this model. Deleting it would destroy other tenants' data.
*/
if (isSharedOpenSearchIndex()) {
return;
}

const { client, model } = params;

const { index } = configurations.es({
Expand Down
28 changes: 5 additions & 23 deletions packages/api-headless-cms-ddb-es/src/operations/model/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import { configurations } from "~/configurations.js";
import type { Client } from "@webiny/api-opensearch";
import type { IModelEntity } from "~/definitions/types.js";
import { deleteElasticsearchIndex } from "~/elasticsearch/deleteElasticsearchIndex.js";

interface PartitionKeysParams {
tenant: string;
Expand Down Expand Up @@ -129,10 +130,6 @@ export const createModelsStorageOperations = (
const { model } = params;
const keys = createKeys(model);

const { index } = configurations.es({
model
});

try {
await entity.delete(keys);
} catch (ex) {
Expand All @@ -146,25 +143,10 @@ export const createModelsStorageOperations = (
}
);
}
/**
* Always delete the model index after deleting the model.
*/
try {
await elasticsearch.indices.delete({
index,
ignore_unavailable: true
});
} catch (ex) {
throw new WebinyError(
`Could not delete elasticsearch index "${index}" after model record delete.`,
"DELETE_MODEL_INDEX_ERROR",
{
error: ex,
index,
model
}
);
}
await deleteElasticsearchIndex({
client: elasticsearch,
model: model
});
};

const get = async (params: CmsModelStorageOperationsGetParams) => {
Expand Down