Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b3cc709
add zod support for SO model versions
nickofthyme Apr 13, 2026
adf6b23
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine Apr 13, 2026
18d6895
fix schema usage
nickofthyme Apr 13, 2026
9834aa8
Merge remote-tracking branch 'origin/zod-model-versions' into zod-mod…
nickofthyme Apr 13, 2026
d746a05
fix test assertion
nickofthyme Apr 13, 2026
7dc13cb
Merge branch 'main' into zod-model-versions
nickofthyme Apr 13, 2026
64061f6
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine Apr 13, 2026
20a0365
fix field mapping SO checks
nickofthyme Apr 13, 2026
c396cd4
Merge branch 'main' into zod-model-versions
nickofthyme Apr 13, 2026
fa663c9
Merge remote-tracking branch 'origin/zod-model-versions' into zod-mod…
nickofthyme Apr 13, 2026
367dfe8
throw an error when the latest model version's `schemas.create` is un…
nickofthyme Apr 13, 2026
2d64674
Merge branch 'main' into zod-model-versions
nickofthyme Apr 15, 2026
ee3ece5
Merge branch 'main' into zod-model-versions
nickofthyme Apr 20, 2026
4606285
Merge branch 'main' into zod-model-versions
nickofthyme Apr 22, 2026
0fce77a
assert config-schema is used in cases
nickofthyme Apr 22, 2026
43f9ed8
Merge branch 'main' into zod-model-versions
nickofthyme Apr 23, 2026
add60d5
Merge branch 'main' into zod-model-versions
nickofthyme Apr 29, 2026
932e78d
Merge branch 'main' into zod-model-versions
nickofthyme May 13, 2026
56fc7dc
Merge branch 'main' into zod-model-versions
nickofthyme May 29, 2026
3d6b69b
cleanup schema type checks
nickofthyme May 29, 2026
4baf59f
add `@kbn/config-schema-helpers` package
nickofthyme May 29, 2026
05aae0b
Merge branch 'main' into zod-model-versions
nickofthyme May 29, 2026
dcbb229
Changes from node scripts/generate codeowners
kibanamachine May 29, 2026
6fc5622
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine May 29, 2026
9772f0c
Merge branch 'main' into zod-model-versions
nickofthyme Jul 10, 2026
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
Expand Up @@ -8,6 +8,7 @@
*/

import { schema } from '@kbn/config-schema';
import { z } from '@kbn/zod';
import { convertModelVersionBackwardConversionSchema } from './backward_conversion_schema';
import type {
SavedObjectUnsanitizedDoc,
Expand All @@ -24,6 +25,16 @@ describe('convertModelVersionBackwardConversionSchema', () => {
...parts,
});

it('should throw if the schema is unknown', () => {
const mySchema = {} as any;

expect(() =>
convertModelVersionBackwardConversionSchema(mySchema)
).toThrowErrorMatchingInlineSnapshot(
`"Unknown forward compatibility schema. Must be defined with \`@kbn/zod\` or \`@kbn/config-schema\`."`
);
});

describe('using functions', () => {
it('converts the schema', () => {
const conversionSchema: jest.MockedFunction<SavedObjectModelVersionForwardCompatibilityFn> =
Expand Down Expand Up @@ -152,4 +163,71 @@ describe('convertModelVersionBackwardConversionSchema', () => {
});
});
});

describe('using zod', () => {
it('converts the schema', () => {
const conversionSchema = z.object({
foo: z.string().optional(),
});
const parseSpy = jest.spyOn(conversionSchema, 'safeParse');

const doc = createDoc({ attributes: { foo: 'bar' } });
const converted = convertModelVersionBackwardConversionSchema(conversionSchema);

const output = converted(doc);

expect(parseSpy).toHaveBeenCalledTimes(1);
expect(parseSpy).toHaveBeenCalledWith({ foo: 'bar' });
expect(output).toEqual(doc);
});

it('returns the document with the updated properties', () => {
const conversionSchema = z.object({
foo: z.string().optional(),
});

const doc = createDoc({ attributes: { foo: 'bar', hello: 'dolly' } });
const converted = convertModelVersionBackwardConversionSchema(conversionSchema);

const output = converted(doc);

expect(output).toEqual({
...doc,
attributes: {
foo: 'bar',
},
});
});

it('throws if the validation throws', () => {
const conversionSchema = z.object({
foo: z.string(),
});

const doc = createDoc({ attributes: { foo: 1 } });
const converted = convertModelVersionBackwardConversionSchema(conversionSchema);

expect(() => converted(doc)).toThrow(/Invalid input: expected string, received number/);
});

it('returns the known subset of keys with their original values', () => {
const conversionSchema = z.object({
durations: z.array(z.string()),
byteSize: z.string(),
});

const doc = createDoc({
attributes: { durations: ['1m', '4d'], byteSize: '1gb', excluded: true },
});
const converted = convertModelVersionBackwardConversionSchema(conversionSchema);

expect(converted(doc)).toEqual({
...doc,
attributes: {
durations: ['1m', '4d'],
byteSize: '1gb',
},
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,47 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { isConfigSchema, type ObjectType } from '@kbn/config-schema';
import { isZod, z } from '@kbn/zod';
import { isConfigSchema } from '@kbn/config-schema';
import type {
SavedObjectUnsanitizedDoc,
SavedObjectModelVersionForwardCompatibilitySchema,
} from '@kbn/core-saved-objects-server';
import { pickValuesBasedOnStructure } from '../utils';

function isObjectType(
schema: SavedObjectModelVersionForwardCompatibilitySchema
): schema is ObjectType {
return isConfigSchema(schema);
}

export type ConvertedSchema = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc;

function toAttributeObject(attributes: unknown): object {
if (attributes !== null && typeof attributes === 'object') {
return attributes;
}
return {};
}

export const convertModelVersionBackwardConversionSchema = (
schema: SavedObjectModelVersionForwardCompatibilitySchema
forwardSchema: SavedObjectModelVersionForwardCompatibilitySchema
): ConvertedSchema => {
if (isObjectType(schema)) {
if (isZod(forwardSchema)) {
const zodSchema = forwardSchema;
return (doc) => {
const originalAttrs = toAttributeObject(doc.attributes);
const result = zodSchema.safeParse(doc.attributes);
if (!result.success) {
throw new Error(z.prettifyError(result.error));
}
Comment on lines +35 to +37

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return prettified Error instead of ZodError.

const convertedAttrs = pickValuesBasedOnStructure(result.data, originalAttrs);
return {
...doc,
attributes: convertedAttrs,
};
};
}

if (isConfigSchema(forwardSchema)) {
return (doc) => {
const originalAttrs = doc.attributes as object;
const originalAttrs = toAttributeObject(doc.attributes);
// Get the validated object, with possible stripping of unknown keys
const validatedAttrs = schema.validate(doc.attributes);
const validatedAttrs = forwardSchema.validate(doc.attributes);
// Use the validated attrs object to pick values from the original attrs.
//
// If we reversed this, validation conversion would be returned in the
Expand All @@ -41,13 +59,19 @@ export const convertModelVersionBackwardConversionSchema = (
attributes: convertedAttrs,
};
};
} else {
}

if (typeof forwardSchema === 'function') {
return (doc) => {
const attrs = schema(doc.attributes);
const attrs = forwardSchema(doc.attributes);
return {
...doc,
attributes: attrs,
};
};
}

throw new Error(
'Unknown forward compatibility schema. Must be defined with `@kbn/zod` or `@kbn/config-schema`.'
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { z } from '@kbn/zod';
import { schema, type Type } from '@kbn/config-schema';
import type { SavedObjectSanitizedDoc } from '@kbn/core-saved-objects-server';

// We convert `SavedObjectSanitizedDoc` to its validation schema representation
// to ensure that we don't forget to keep the schema up-to-date. TS will complain
// if we update `SavedObjectSanitizedDoc` without making changes below.
type SavedObjectSanitizedDocSchema = {
[K in keyof Required<SavedObjectSanitizedDoc>]: Type<SavedObjectSanitizedDoc[K]>;
};

/**
* Base config-schema schema for a saved object.
*
* @internal
*/
export const baseConfigSchema = schema.object({
Comment on lines +14 to +26

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One base for each schema type, both validated against SavedObjectSanitizedDoc.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nickofthyme do you still intend to follow through with this PR? The PR seems to be abandoned.

@nickofthyme nickofthyme May 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TinaHeiligers Yeah it would be nice. I think zod is the future and we should support it here if we support it in other places like route schemas.

The PR seems to be abandoned.

Not abandoned, just no one has had the time review from your team and it's not critical for us so I didn't want to be too much of a bother. I last mentioned it in #kibana-core on slack on May 4th and discussed with Rudolf but never lead anywhere.

id: schema.string({ minLength: 1 }),
type: schema.string(),
references: schema.arrayOf(
schema.object({
name: schema.string(),
type: schema.string(),
id: schema.string(),
}),
{ defaultValue: [], maxSize: 1000 }
),
namespace: schema.maybe(schema.string()),
namespaces: schema.maybe(schema.arrayOf(schema.string(), { maxSize: 100 })),
migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())),
coreMigrationVersion: schema.maybe(schema.string()),
typeMigrationVersion: schema.maybe(schema.string()),
updated_at: schema.maybe(schema.string()),
updated_by: schema.maybe(schema.string()),
created_at: schema.maybe(schema.string()),
created_by: schema.maybe(schema.string()),
version: schema.maybe(schema.string()),
originId: schema.maybe(schema.string()),
managed: schema.maybe(schema.boolean()),
accessControl: schema.maybe(
schema.object({
owner: schema.string(),
accessMode: schema.oneOf([schema.literal('write_restricted'), schema.literal('default')]),
})
),
attributes: schema.recordOf(schema.string(), schema.maybe(schema.any())),
} satisfies SavedObjectSanitizedDocSchema);

/**
* Base Zod schema for a saved object.
*
* @internal
*/
export const baseZodSchema = z.object({
id: z.string().min(1),
type: z.string(),
references: z
.array(
z.object({
name: z.string(),
type: z.string(),
id: z.string(),
})
)
.max(1000)
.default([]),
namespace: z.string().optional(),
namespaces: z.array(z.string()).max(100).optional(),
migrationVersion: z.record(z.string(), z.string()).optional(),
coreMigrationVersion: z.string().optional(),
typeMigrationVersion: z.string().optional(),
updated_at: z.string().optional(),
updated_by: z.string().optional(),
created_at: z.string().optional(),
created_by: z.string().optional(),
version: z.string().optional(),
originId: z.string().optional(),
managed: z.boolean().optional(),
accessControl: z
.object({
owner: z.string(),
accessMode: z.enum(['write_restricted', 'default']),
})
.optional(),
attributes: z.record(z.string(), z.any().optional()),
}) satisfies z.ZodType<SavedObjectSanitizedDoc>;
Loading
Loading