Single Table Design #43
-
One table for 3 entities ie. User, Post & UserLiketype UserProps = {
email: string, // pk and sk
name: string,
username: string // gsi
}
// Access Patterns
// 1. Get user by username
type PostProps = {
slug: string, // pk and sk
title: string
publisher_id: string // gsi
}
// 1. Get all posts of a publisher (just like username in User Entity)
type UserLike = {
entity_id: string, // pk
email: string // sk
}Table Preview
Problems I'm facing
My Table Code// single.ts
import { Dynamode, attribute, TableManager, Entity } from "dynamode";
Dynamode.ddb.local();
class BaseEntity extends Entity {
@attribute.partitionKey.string()
pk: string
@attribute.sortKey.string()
sk: string
@attribute.gsi.partitionKey.string({ indexName: "gsi1" })
gsi1pk?: string;
@attribute.date.string()
created_at: Date
@attribute.date.string()
updated_at: Date
constructor(props: { pk: string, sk: string }) {
super();
this.pk = props.pk;
this.sk = props.sk;
this.created_at = new Date();
this.updated_at = new Date();
}
}
const BaseEntityTableManager = new TableManager(BaseEntity, {
tableName: "main-table",
partitionKey: "pk",
sortKey: "sk",
indexes: {
gsi1: { partitionKey: 'gsi1pk' }
},
})
export class User extends BaseEntity {
@attribute.partitionKey.string({ prefix: "USER" })
override pk: string
@attribute.sortKey.string({ prefix: "USER" })
override sk: string
@attribute.string()
name: string
@attribute.gsi.partitionKey.string({ indexName: "gsi1", prefix: "USERNAME" })
override gsi1pk: string
constructor(props: { email: string, username: string, name: string }) {
super({ pk: props.email, sk: props.email })
this.pk = props.email;
this.sk = props.email;
this.gsi1pk = props.username;
this.name = props.name;
this.created_at = new Date();
this.updated_at = new Date();
}
}
// ⚠️ typescript error - Argument of type 'typeof User' is not assignable to parameter of type 'typeof BaseEntity'.
export const UserManager = BaseEntityTableManager.entityManager(User);
export class Post extends BaseEntity {
@attribute.partitionKey.string({ prefix: "POST" })
override pk: string
@attribute.sortKey.string({ prefix: "POST" })
override sk: string
@attribute.string()
title: string
@attribute.gsi.partitionKey.string({ indexName: "gsi1", prefix: "ADMIN" })
override gsi1pk: string
constructor(props: { slug: string, title: string, publisher_id: string }) {
super({ pk: props.slug, sk: props.slug })
this.pk = props.slug;
this.sk = props.slug;
this.gsi1pk = props.publisher_id;
this.title = props.title;
this.created_at = new Date();
this.updated_at = new Date();
}
}
// ⚠️ typescript error - Argument of type 'typeof Post' is not assignable to parameter of type 'typeof BaseEntity'.
export const PostManager = BaseEntityTableManager.entityManager(Post);
export class UserLike extends BaseEntity {
@attribute.partitionKey.string({ prefix: "ENTITY" })
override pk: string
@attribute.sortKey.string({ prefix: "USER" })
override sk: string
constructor(props: { entity_id: string, user_id: string }) {
super({ pk: props.entity_id, sk: props.user_id })
this.pk = props.entity_id;
this.sk = props.user_id;
}
}
// ⚠️ typescript error - Argument of type 'typeof UserLike' is not assignable to parameter of type 'typeof BaseEntity'.
export const UserLikeManager = BaseEntityTableManager.entityManager(UserLike);
try { await BaseEntityTableManager.createTable() } catch { }Problems I'm facingimport { UserLikeManager, UserManager, PostManager, Post, User, UserLike } from "./single";
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
describe("user", () => {
const email = "user1@gmail.com";
beforeAll(async () => {
await UserManager.create(new User({ email, name: "User 1", username: "user1" }))
// bunch of users
await UserManager.batchPut(Array.from({ length: 10 }, (_, i) => new User({ email: `user_id_${i}@gmail.com`, username: `username_${i}`, name: `User N${i}` })))
})
afterAll(async () => {
await UserManager.delete({ pk: email, sk: email })
await UserManager.batchDelete(Array.from({ length: 10 }, (_, i) => ({ pk: `user_id_${i}@gmail.com`, sk: `user_id_${i}@gmail.com` })))
})
test("get by id", async () => {
const user = await UserManager.get({ pk: email, sk: email });
// User {
// dynamodeEntity: "User",
// pk: undefined,
// sk: undefined,
// created_at: 2025-10-12T19:41:55.801Z,
// updated_at: 2025-10-12T19:41:55.801Z,
// gsi1pk: undefined,
// name: "User 1",
// }
// expect(user.pk).toBe(email); // ❌ fails
// expect(user.sk).toBe(email); // ❌ fails
expect(user.name).toBe("User 1") // ✅
})
test("successfully updates created_at", async () => {
const new_created_at = new Date("2000-10-12T19:41:55.801Z");
await UserManager.update({ pk: email, sk: email }, { set: { created_at: new_created_at } });
const user = await UserManager.get({ pk: email, sk: email });
expect(user.created_at).toEqual(new_created_at); // ✅
// ⚠️ not type safe, name in attributes array
const user_without_created_at_attribute = await UserManager.get({ pk: email, sk: email }, { attributes: ['name'] });
// expect(user_without_created_at_attribute.created_at).toEqual(new_created_at); // ❌ fails - returns new Date() from User constructor
})
test("no unnecessary attributes", async () => {
const user_without_name_attribute = await UserManager.get({ pk: email, sk: email });
// ⚠️ not type safe, .name shows typescript error - Property 'name' does not exist on type 'BaseEntity & GetItemCommandOutput'.
expect(user_without_name_attribute.name).toEqual("User 1");
const user = await UserManager.get({ pk: email, sk: email }, { attributes: ["name"] })
// expect(user.created_at).toBeUndefined(); // ❌ fails
// expect(user.updated_at).toBeUndefined(); // ❌ fails
expect(user.name).toEqual("User 1") // ✅
})
test("query by username", async () => {
const users = await UserManager.query().partitionKey("gsi1pk").eq("user1").run();
expect(users.items.length).toEqual(1);
const user = users.items[0];
// expect(user.pk).toEqual(email) // ❌ fails
// expect(user.sk).toEqual(email) // ❌ fails
expect(user.name).toEqual("User 1") // ✅
})
})
describe("post", () => {
const slug = "post-1"
const publisher_id = "pub-001";
beforeAll(async () => {
await PostManager.create(new Post({ slug, title: "First Post", publisher_id, authorized: true }))
})
afterAll(async () => {
await PostManager.delete({ pk: slug, sk: slug })
})
test("get item with and without attributes", async () => {
const post_with_attributes = await PostManager.get({ pk: slug, sk: slug }, { attributes: ['authorized'] });
// ⚠️ not type safe, .authorized show typescript error - Property 'authorized' does not exist on type 'BaseEntity & GetItemCommandOutput'.
expect(post_with_attributes.authorized).toEqual(true); // ✅ - passed authorized = true during creation
const post_without_attributes = await PostManager.get({ pk: slug, sk: slug }, { attributes: ['title'] });
// expect(post_without_attributes.authorized).toEqual(true); // ❌ fails - assigns value in contructor of Post class
})
test("get by id", async () => {
const post = await PostManager.get({ pk: slug, sk: slug });
// Post {
// dynamodeEntity: "Post",
// pk: undefined,
// sk: undefined,
// created_at: 2025-10-12T19:59:19.534Z,
// updated_at: 2025-10-12T19:59:19.534Z,
// gsi1pk: undefined,
// title: "First Post",
// }
// expect(post.pk).toBe(slug); // ❌ fails
// expect(post.sk).toBe(slug); // ❌ fails
expect(post.title).toBe("First Post") // ✅
})
test("query by username", async () => {
const posts = await PostManager.query().partitionKey("gsi1pk").eq(publisher_id).run();
expect(posts.items.length).toEqual(1);
const post = posts.items[0];
// expect(user.pk).toEqual(email) // ❌ fails
// expect(user.sk).toEqual(email) // ❌ fails
expect(post.title).toEqual("First Post") // ✅
})
})
describe("user likes", () => {
const entity_id = "post-1";
const user_id = "user1@gmail.com";
beforeAll(async () => {
await UserLikeManager.create(new UserLike({ entity_id, user_id }));
// create 10 more likes to this entity by different users
await UserLikeManager.batchPut(Array.from({ length: 10 }, (_, i) => new UserLike({ entity_id, user_id: `user_id_${i}@gmail.com` })))
// likes to different entity
await UserLikeManager.batchPut(Array.from({ length: 10 }, (_, i) => new UserLike({ entity_id: "e0001", user_id: `user_id_${i}@gmail.com` })))
})
afterAll(async () => {
await UserLikeManager.delete({ pk: entity_id, sk: user_id });
await UserLikeManager.batchDelete(Array.from({ length: 10 }, (_, i) => ({ pk: entity_id, sk: `user_id_${i}@gmail.com` })))
await UserLikeManager.batchDelete(Array.from({ length: 10 }, (_, i) => ({ pk: "e0001", sk: `user_id_${i}@gmail.com` })))
})
test("get by id", async () => {
const userlike = await UserLikeManager.get({ pk: entity_id, sk: user_id });
// UserLike {
// dynamodeEntity: "UserLike",
// pk: undefined,
// sk: undefined,
// created_at: 2025-10-12T20:07:25.720Z, // ⚠️ unnecessary
// updated_at: 2025-10-12T20:07:25.720Z, // ⚠️ unnecessary
// }
// expect(userlike.pk).toBe(entity_id); // ❌ fails
// expect(userlike.sk).toBe(entity_id); // ❌ fails
// expect(userlike.created_at).toBeUndefined(); // ❌ fails
// expect(userlike.updated_at).toBeUndefined(); // ❌ fails
})
test("get total num of likes on an entity", async () => {
const total_likes = await UserLikeManager.query().partitionKey("pk").eq(entity_id).count().run();
expect(total_likes.scannedCount).toEqual(11);
expect(total_likes.count).toEqual(11);
})
}) |
Beta Was this translation helpful? Give feedback.
Replies: 5 comments 18 replies
-
|
This is how your table should look like: export type UserTablePrimaryKey = {
pk: string;
sk: string;
};
export type UserTableProps = UserTablePrimaryKey & {
createdAt?: Date;
updatedAt?: Date;
gsi_pk_1?: string;
gsi_sk_1?: string;
};
export const USER_TABLE_NAME = 'table';
export const GSI_1_INDEX = 'GSI_1_INDEX';
@entity.customName('UserTable')
export default class UserTable extends Entity {
@attribute.partitionKey.string()
pk: string;
@attribute.sortKey.string()
sk: string;
@attribute.gsi.partitionKey.string({ indexName: GSI_1_INDEX })
gsi_pk_1?: string;
@attribute.gsi.sortKey.string({ indexName: GSI_1_INDEX })
gsi_sk_1?: string;
@attribute.date.string()
createdAt: Date;
@attribute.date.string()
updatedAt: Date;
constructor(props: UserTableProps) {
super(props);
this.pk = props.pk;
this.sk = props.sk;
this.gsi_pk_1 = props.gsi_pk_1;
this.gsi_sk_1 = props.gsi_sk_1;
this.createdAt = props.createdAt || new Date();
this.updatedAt = props.updatedAt || new Date();
}
}
export const USER_TABLE_METADATA = {
tableName: USER_TABLE_NAME,
partitionKey: 'pk',
sortKey: 'sk',
indexes: {
[GSI_1_INDEX]: {
partitionKey: 'gsi_pk_1',
sortKey: 'gsi_sk_1',
},
},
createdAt: 'createdAt',
updatedAt: 'updatedAt',
} as const;
export const UserTableManager = new TableManager(UserTable, USER_TABLE_METADATA);
I think you forgot to also include gsi_sk_1 value, without it you won't be able to lookup this index, here's how I would code User entity: type UserProps = UserTableProps & {
email: string;
name: string;
username: string;
};
@entity.customName('User')
export default class User extends UserTable {
@attribute.gsi.partitionKey.string({ indexName: GSI_1_INDEX, suffix: 'USERNAME' })
gsi_pk_1: string;
@attribute.gsi.sortKey.string({ indexName: GSI_1_INDEX, suffix: 'USERNAME' })
gsi_sk_1: string;
@attribute.string()
email: string;
@attribute.string()
name: string;
@attribute.string()
username: string;
constructor(props: UserProps) {
super(props);
this.email = props.email;
this.name = props.name;
this.username = props.username;
this.gsi_pk_1 = props.username;
this.gsi_sk_1 = this.createdAt.toISOString();
}
static getPrimaryKey(email: string): UserTablePrimaryKey {
return {
pk: email,
sk: email,
};
}
}
export const UserManager = UserTableManager.entityManager(User);And now you should be able to query: UserManager.query().partitionKey('gsi_pk_1').eq('jake') |
Beta Was this translation helpful? Give feedback.
-
In User entity you should be able to set undefined in the constructor, but I've never done it before 😄 constructor(props: UserProps) {
super(props);
this.email = props.email;
this.name = props.name;
this.username = props.username;
this.gsi_pk_1 = props.username;
this.gsi_sk_1 = this.createdAt.toISOString();
this.createdAt = undefined;
this.updatedAt = undefined;
}This would require a little change in your UserTable: @attribute.date.string()
createdAt?: Date;
@attribute.date.string()
updatedAt?: Date; |
Beta Was this translation helpful? Give feedback.
-
|
Hope this helps! |
Beta Was this translation helpful? Give feedback.
-
|
How can i work around this ? if i do not pass a specific attribute in the describe("post", () => {
const slug = "post-1"
const publisher_id = "pub-001";
beforeAll(async () => {
await PostManager.create(new Post({ ...Post.getPrimaryKey(slug), slug, title: "First Post", publisher_id, authorized: true }))
})
afterAll(async () => {
await PostManager.delete({ pk: slug, sk: slug })
})
test("get item with and without attributes", async () => {
const post_with_attributes = await PostManager.get({ pk: slug, sk: slug }, { attributes: ['authorized'] });
expect(post_with_attributes.authorized).toEqual(true); // ✅ - passed authorized = true during creation
const post_without_attributes = await PostManager.get({ pk: slug, sk: slug }, { attributes: ['title'] });
expect(post_without_attributes.authorized).toEqual(true); // ❌ fails - value gets assigned in contructor of Post entity and do not matches the database
})
})// Post Entity
export class Post extends BaseEntity {
@attribute.partitionKey.string({ prefix: "POST" })
override pk: string
@attribute.sortKey.string({ prefix: "POST" })
override sk: string
@attribute.string()
slug: string
@attribute.string()
title: string
@attribute.boolean()
authorized: boolean
@attribute.gsi.partitionKey.string({ indexName: "gsi1", prefix: "ADMIN" })
override gsi1pk: string
constructor(props: BaseEntityProps & { slug: string, title: string, publisher_id: string, authorized?: boolean }) {
super(props)
this.pk = props.slug;
this.sk = props.slug;
this.slug = props.slug;
this.gsi1pk = props.publisher_id;
this.title = props.title;
this.authorized = props.authorized ?? false;
}
}
export const PostManager = BaseEntityTableManager.entityManager(Post); |
Beta Was this translation helpful? Give feedback.
-
|
There should be a method like For example const user = await UserManager.get(User.getPrimaryKey(email), {attribute: ["name", "created_at", "isAdmin", "required_object", "optional"]});
const userJson= user.toJSON();
// {
// name: string,
// created_at: Date,
// isAdmin: boolean,
// optional?: string,
// required_object: {
// a: number,
// b?: string,
// },
// }
// other attributes present in the User are excluded because they were not passed in the attributes array
// userJson.age ❌ should throw typescript error as it is not included in attributes array
// userJson.name ✅ valid |
Beta Was this translation helpful? Give feedback.


What I usually end up doing is removing the props that are used in pk and sk and use
getPrimaryKeystatic method like this: