Skip to content

Commit

Permalink
Add basics of versioning
Browse files Browse the repository at this point in the history
  • Loading branch information
tgandrews committed Feb 18, 2023
1 parent 05f1c80 commit 1a44e9a
Show file tree
Hide file tree
Showing 7 changed files with 249 additions and 46 deletions.
16 changes: 12 additions & 4 deletions src/deserializer.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import * as AWSDDB from "@aws-sdk/client-dynamodb";

import { PlainObject } from "./types";
import { PlainObject, VersionedObject } from "./types";

type DynamoType = keyof AWSDDB.AttributeValue;
type AttributeMap = Record<string, AWSDDB.AttributeValue>;
export type AttributeMap = Record<string, AWSDDB.AttributeValue>;

class Deserializer {
fromDynamoValue(attributeValue: AWSDDB.AttributeValue): any {
private fromDynamoValue(attributeValue: AWSDDB.AttributeValue): any {
const [dynamoType, dynamoValue] = Object.entries(attributeValue)[0] as [
DynamoType,
any
Expand Down Expand Up @@ -41,7 +41,7 @@ class Deserializer {
}
}

fromDynamoMap(dbObj: AttributeMap): PlainObject {
private fromDynamoMap(dbObj: AttributeMap): PlainObject {
return Object.entries(dbObj).reduce((userObj, [key, attributeValue]) => {
const userValue = this.fromDynamoValue(attributeValue);
return {
Expand All @@ -50,6 +50,14 @@ class Deserializer {
};
}, {});
}

deserialize(dbObj: AttributeMap): VersionedObject {
const obj = this.fromDynamoMap(dbObj);
return {
_v: 0,
...obj,
};
}
}

export default Deserializer;
154 changes: 150 additions & 4 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { AnyRecord } from "dns";
import Joi from "joi";
import Omanyd from "./";

Expand Down Expand Up @@ -362,16 +361,16 @@ describe("omanyd", () => {
await Omanyd.createTables();

const [savedThing1, savedThing2] = await Promise.all([
ThingStore.create({ id: "id", version: "1", value: "hello world" }),
ThingStore.create({ id: "id", version: "2", value: "hello world" }),
ThingStore.create({ id: "id", version: "1", value: "hello world 1" }),
ThingStore.create({ id: "id", version: "2", value: "hello world 2" }),
]);

const readThings = await ThingStore.getAllByHashKey("id");

expect(readThings).toStrictEqual([savedThing1, savedThing2]);
});

it("should return all items for a hash key and if there are multiple items", async () => {
it("should return all items for a hash key - finds none", async () => {
interface Thing {
id: string;
version: string;
Expand Down Expand Up @@ -1142,4 +1141,151 @@ describe("omanyd", () => {
await ThingStore.deleteByHashKey("non-existant-id");
});
});

describe("versioning", () => {
it("should automatically migrate between the old and new version on read", async () => {
interface ThingV0 {
id: string;
value: string;
}
const v0Schema = Joi.object({
id: Omanyd.types.id(),
value: Joi.string().required(),
});
const v0Store = Omanyd.define<ThingV0>({
name: "automaticallyMigrate",
hashKey: "id",
schema: v0Schema,
});
await Omanyd.createTables();
const storedV0Thing = await v0Store.create({ value: "hello" });
expect(storedV0Thing).toStrictEqual({
id: storedV0Thing.id,
value: "hello",
});

interface ThingV1 {
id: string;
value: string;
extra: string;
}
const v1Store = Omanyd.define<ThingV1>({
name: "automaticallyMigrate",
hashKey: "id",
schema: Joi.object({
id: Omanyd.types.id(),
value: Joi.string().required(),
extra: Joi.string().required(),
}),
versions: [
{
schema: v0Schema,
migrate: (thingV0: ThingV0): ThingV1 => {
return {
...thingV0,
extra: "default value",
};
},
},
],
// This wouldn't be needed in prod but needed here
allowNameClash: true,
});

const readV1Item = await v1Store.getByHashKey(storedV0Thing.id);
expect(readV1Item).toStrictEqual({
id: storedV0Thing.id,
value: "hello",
extra: "default value",
});
});

it("should only run the migrations needed", async () => {
interface ThingV0 {
id: string;
value: string;
}
const v0Schema = Joi.object({
id: Omanyd.types.id(),
value: Joi.string().required(),
});

interface ThingV1 {
id: string;
value: string;
extra: string;
}
const v0ToV1Migration = jest.fn((thingV0: ThingV0): ThingV1 => {
return {
...thingV0,
extra: "default value",
};
});
const v1Schema = Joi.object({
id: Omanyd.types.id(),
value: Joi.string().required(),
extra: Joi.string().required(),
});
const v1Store = Omanyd.define<ThingV1>({
name: "automaticallyMigrateOnlyNeeded",
hashKey: "id",
schema: v1Schema,
versions: [
{
schema: v0Schema,
migrate: v0ToV1Migration,
},
],
});
await Omanyd.createTables();
const storedV1Thing = await v1Store.create({
value: "hello",
extra: "extra",
});

interface ThingV2 {
id: string;
value: string;
extra: string;
extra2: number;
}
const v1ToV2Migration = jest.fn((thingV1: ThingV1): ThingV2 => {
return {
...thingV1,
extra2: 5,
};
});
const v2Store = Omanyd.define<ThingV1>({
name: "automaticallyMigrateOnlyNeeded",
hashKey: "id",
schema: Joi.object({
id: Omanyd.types.id(),
value: Joi.string().required(),
extra: Joi.string().required(),
extra2: Joi.number().required(),
}),
versions: [
{
schema: v0Schema,
migrate: v0ToV1Migration,
},
{
schema: v1Schema,
migrate: v1ToV2Migration,
},
],
allowNameClash: true,
});

const readV2Item = await v2Store.getByHashKey(storedV1Thing.id);
expect(readV2Item).toStrictEqual({
id: storedV1Thing.id,
value: "hello",
extra: "extra",
extra2: 5,
});
expect(v0ToV1Migration).not.toHaveBeenCalled();
expect(v1ToV2Migration).toHaveBeenCalled();
});
});
});
26 changes: 7 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ export function define<T extends PlainObject>(options: Options) {
async create(obj: Omit<T, "id"> | T): Promise<T> {
const validated: T = await validator.validateAsync(obj);
const result = await t.create(validated);
const validatedResult = await validator.validateAsync(result);
return validatedResult as unknown as T;
return result as T;
},

async put(obj: T): Promise<T> {
Expand All @@ -48,8 +47,7 @@ export function define<T extends PlainObject>(options: Options) {
if (res === null) {
return res;
}
const validated = await validator.validateAsync(res);
return validated as unknown as T;
return res as T;
},

async getByHashAndRangeKey(
Expand All @@ -64,16 +62,12 @@ export function define<T extends PlainObject>(options: Options) {
if (res === null) {
return res;
}
const validated = await validator.validateAsync(res);
return validated as unknown as T;
return res as T;
},

async getAllByHashKey(hashKey: string): Promise<T[]> {
const res = await t.getAllByHashKey(hashKey);
const validated = await Promise.all(
res.map((data) => validator.validateAsync(data))
);
return validated as unknown[] as T[];
const validated = await t.getAllByHashKey(hashKey);
return validated as T[];
},

async getByIndex(
Expand All @@ -85,18 +79,12 @@ export function define<T extends PlainObject>(options: Options) {
if (res === null) {
return res;
}
const validated = await validator.validateAsync(res);
return validated as unknown as T;
return res as T;
},

async scan(): Promise<T[]> {
const res = await t.scan();
const validated = await Promise.all(
res.map(async (r) => {
return await validator.validateAsync(r);
})
);
return validated as unknown as T[];
return res as T[];
},

async deleteByHashKey(hashKey: string): Promise<void> {
Expand Down
34 changes: 34 additions & 0 deletions src/migrator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type Joi from "joi";
import { PlainObject, Version, VersionedObject } from "./types";

export default class Migrator {
constructor(
private versions: Version[],
private currentSchema: Joi.ObjectSchema
) {}

async migrate(obj: VersionedObject): Promise<PlainObject> {
const objectVersion = obj._v;
const clientObject: PlainObject = obj as PlainObject;
delete clientObject._v;
const versionsToMigrate = this.versions.slice(objectVersion);

// Check if read version from the DB is valid to its schema
const validatedResult = await (
versionsToMigrate[0]?.schema ?? this.currentSchema
).validateAsync(clientObject);

let upToDateObject = validatedResult;
for (let i = 0; i < versionsToMigrate.length; ++i) {
const { migrate } = versionsToMigrate[i];
const migratedResult = migrate(upToDateObject);
const targetSchema =
versionsToMigrate[i + 1]?.schema ?? this.currentSchema;
const validatedMigratedResult = await targetSchema.validateAsync(
migratedResult
);
upToDateObject = validatedMigratedResult;
}
return upToDateObject;
}
}
12 changes: 10 additions & 2 deletions src/serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { PlainObject } from "./types";
import { getItemSchemaFromObjectSchema } from "./joiReflection";

class Serializer {
constructor(private schema: Joi.ObjectSchema) {}
constructor(private schema: Joi.ObjectSchema, private version: number) {}

private string(value: string): AWSDDB.AttributeValue {
return { S: value };
Expand Down Expand Up @@ -124,7 +124,7 @@ class Serializer {
}
}

toDynamoMap(
private toDynamoMap(
userObj: PlainObject,
objectSchema: Joi.ObjectSchema = this.schema
): Record<string, AWSDDB.AttributeValue> {
Expand All @@ -140,6 +140,14 @@ class Serializer {
};
}, {});
}

serialize(userObj: PlainObject, objectSchema?: Joi.ObjectSchema) {
const versionedObject = {
...userObj,
_v: this.version,
};
return this.toDynamoMap(versionedObject, objectSchema);
}
}

export default Serializer;
Loading

0 comments on commit 1a44e9a

Please sign in to comment.