Skip to content

Commit 5e871d9

Browse files
committed
✨ feat(sub-calendars): update watch schema and tests
1 parent 3c3239f commit 5e871d9

File tree

5 files changed

+59
-133
lines changed

5 files changed

+59
-133
lines changed
Lines changed: 7 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
import { faker } from "@faker-js/faker";
2-
import {
3-
Watch,
4-
WatchInput,
5-
WatchSchema,
6-
WatchSchemaStrict,
7-
} from "@core/types/watch.types";
2+
import { Watch, WatchSchema } from "@core/types/watch.types";
83

94
describe("Watch Types", () => {
105
const validWatch: Watch = {
116
_id: faker.string.uuid(),
12-
userId: faker.database.mongodbObjectId(),
7+
user: faker.database.mongodbObjectId(),
138
resourceId: faker.string.alphanumeric(20),
149
expiration: faker.date.future(),
1510
createdAt: new Date(),
@@ -30,26 +25,26 @@ describe("Watch Types", () => {
3025
expect(parsed.createdAt).toBeInstanceOf(Date);
3126
});
3227

33-
it("accepts valid MongoDB ObjectId for userId", () => {
28+
it("accepts valid MongoDB ObjectId for user", () => {
3429
const watchData = {
3530
...validWatch,
36-
userId: faker.database.mongodbObjectId(),
31+
user: faker.database.mongodbObjectId(),
3732
};
3833

3934
expect(() => WatchSchema.parse(watchData)).not.toThrow();
4035
});
4136

42-
it("rejects invalid MongoDB ObjectId for userId", () => {
37+
it("rejects invalid MongoDB ObjectId for user", () => {
4338
const watchData = {
4439
...validWatch,
45-
userId: "invalid-object-id",
40+
user: "invalid-object-id",
4641
};
4742

4843
expect(() => WatchSchema.parse(watchData)).toThrow();
4944
});
5045

5146
it("requires all mandatory fields", () => {
52-
const requiredFields = ["_id", "userId", "resourceId", "expiration"];
47+
const requiredFields = ["_id", "user", "resourceId", "expiration"];
5348

5449
requiredFields.forEach((field) => {
5550
const incompleteWatch = { ...validWatch };
@@ -77,48 +72,4 @@ describe("Watch Types", () => {
7772
expect(() => WatchSchema.parse(watchData)).toThrow();
7873
});
7974
});
80-
81-
describe("WatchSchemaStrict", () => {
82-
it("rejects additional properties when using strict schema", () => {
83-
const watchWithExtra = {
84-
...validWatch,
85-
extraProperty: "should-not-be-allowed",
86-
};
87-
88-
expect(() => WatchSchemaStrict.parse(watchWithExtra)).toThrow();
89-
});
90-
91-
it("accepts valid watch data with strict schema", () => {
92-
expect(() => WatchSchemaStrict.parse(validWatch)).not.toThrow();
93-
});
94-
});
95-
96-
describe("WatchInput type", () => {
97-
it("allows creating watch input without createdAt", () => {
98-
const watchInput: WatchInput = {
99-
_id: faker.string.uuid(),
100-
userId: faker.database.mongodbObjectId(),
101-
resourceId: faker.string.alphanumeric(20),
102-
expiration: faker.date.future(),
103-
};
104-
105-
// This should compile without errors
106-
expect(watchInput).toBeDefined();
107-
expect(watchInput.createdAt).toBeUndefined();
108-
});
109-
110-
it("allows creating watch input with createdAt", () => {
111-
const watchInput: WatchInput = {
112-
_id: faker.string.uuid(),
113-
userId: faker.database.mongodbObjectId(),
114-
resourceId: faker.string.alphanumeric(20),
115-
expiration: faker.date.future(),
116-
createdAt: new Date(),
117-
};
118-
119-
// This should compile without errors
120-
expect(watchInput).toBeDefined();
121-
expect(watchInput.createdAt).toBeInstanceOf(Date);
122-
});
123-
});
12475
});

packages/core/src/types/watch.types.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,13 @@ import { IDSchemaV4 } from "@core/types/type.utils";
1010
*/
1111
export const WatchSchema = z.object({
1212
_id: z.string(), // channel_id - unique identifier for the notification channel
13-
userId: IDSchemaV4, // user who owns this watch channel
13+
user: IDSchemaV4, // user who owns this watch channel
1414
resourceId: z.string(), // Google Calendar resource identifier
1515
expiration: z.date(), // when the channel expires
16-
createdAt: z.date().default(() => new Date()), // when this watch was created
16+
createdAt: z
17+
.date()
18+
.optional()
19+
.default(() => new Date()), // when this watch was created
1720
});
1821

1922
export type Watch = z.infer<typeof WatchSchema>;
20-
21-
// Type for creating a new watch (without auto-generated fields)
22-
export type WatchInput = Omit<Watch, "createdAt"> & {
23-
createdAt?: Date;
24-
};
25-
26-
// Schema for database storage (with strict validation)
27-
export const WatchSchemaStrict = WatchSchema.strict();

packages/scripts/src/__tests__/integration/2025.10.13T14.18.20.watch-collection.test.ts

Lines changed: 20 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { faker } from "@faker-js/faker";
22
import { zodToMongoSchema } from "@scripts/common/zod-to-mongo-schema";
33
import Migration from "@scripts/migrations/2025.10.13T14.18.20.watch-collection";
4-
import { Watch, WatchSchemaStrict } from "@core/types/watch.types";
4+
import { Watch, WatchSchema } from "@core/types/watch.types";
55
import {
66
cleanupCollections,
77
cleanupTestDb,
@@ -22,7 +22,7 @@ describe("2025.10.13T14.18.20.watch-collection", () => {
2222
function generateWatch(): Watch {
2323
return {
2424
_id: faker.string.uuid(),
25-
userId: faker.database.mongodbObjectId(),
25+
user: faker.database.mongodbObjectId(),
2626
resourceId: faker.string.alphanumeric(20),
2727
expiration: faker.date.future(),
2828
createdAt: faker.date.recent(),
@@ -32,7 +32,7 @@ describe("2025.10.13T14.18.20.watch-collection", () => {
3232
async function validateUpMigration() {
3333
const indexes = await mongoService.watch.indexes();
3434
const collectionInfo = await mongoService.watch.options();
35-
const $jsonSchema = zodToMongoSchema(WatchSchemaStrict);
35+
const $jsonSchema = zodToMongoSchema(WatchSchema);
3636

3737
expect(collectionInfo["validationLevel"]).toBe("strict");
3838
expect(collectionInfo["validator"]).toBeDefined();
@@ -43,12 +43,12 @@ describe("2025.10.13T14.18.20.watch-collection", () => {
4343
expect.arrayContaining([
4444
expect.objectContaining({ name: "_id_", key: { _id: 1 } }),
4545
expect.objectContaining({
46-
name: `${collectionName}_userId_index`,
47-
key: { userId: 1 },
46+
name: `${collectionName}_user_index`,
47+
key: { user: 1 },
4848
}),
4949
expect.objectContaining({
50-
name: `${collectionName}_userId_expiration_index`,
51-
key: { userId: 1, expiration: 1 },
50+
name: `${collectionName}_user_expiration_index`,
51+
key: { user: 1, expiration: 1 },
5252
}),
5353
]),
5454
);
@@ -110,20 +110,10 @@ describe("2025.10.13T14.18.20.watch-collection", () => {
110110
});
111111

112112
describe("mongo $jsonSchema validation", () => {
113-
function generateValidWatch() {
114-
return {
115-
_id: faker.string.uuid(),
116-
userId: faker.database.mongodbObjectId(),
117-
resourceId: faker.string.alphanumeric(20),
118-
expiration: new Date(faker.date.future()),
119-
createdAt: new Date(faker.date.recent()),
120-
};
121-
}
122-
123113
beforeEach(migration.up.bind(migration));
124114

125115
it("allows valid watch documents", async () => {
126-
const watch = generateValidWatch();
116+
const watch = generateWatch();
127117

128118
await expect(mongoService.watch.insertOne(watch)).resolves.toMatchObject({
129119
acknowledged: true,
@@ -132,24 +122,20 @@ describe("2025.10.13T14.18.20.watch-collection", () => {
132122
});
133123

134124
it("rejects documents with missing required fields", async () => {
135-
const incompleteWatch = {
136-
_id: faker.string.uuid(),
137-
userId: faker.database.mongodbObjectId(),
138-
// missing resourceId and expiration
139-
createdAt: new Date(),
140-
};
125+
const incompleteWatch = generateWatch();
126+
127+
delete (incompleteWatch as Partial<Watch>).resourceId;
128+
delete (incompleteWatch as Partial<Watch>).expiration;
141129

142130
await expect(
143131
mongoService.watch.insertOne(incompleteWatch),
144132
).rejects.toThrow();
145133
});
146134

147-
it("rejects documents with missing userId", async () => {
148-
const watchWithoutUserId = {
149-
...generateValidWatch(),
150-
};
151-
// @ts-expect-error testing missing userId field
152-
delete watchWithoutUserId.userId;
135+
it("rejects documents with missing user", async () => {
136+
const watchWithoutUserId = generateWatch();
137+
138+
delete (watchWithoutUserId as Partial<Watch>).user;
153139

154140
await expect(
155141
mongoService.watch.insertOne(watchWithoutUserId),
@@ -158,7 +144,7 @@ describe("2025.10.13T14.18.20.watch-collection", () => {
158144

159145
it("rejects documents with additional properties", async () => {
160146
const watchWithExtra = {
161-
...generateValidWatch(),
147+
...generateWatch(),
162148
extraProperty: "should-not-be-allowed",
163149
};
164150

@@ -168,15 +154,11 @@ describe("2025.10.13T14.18.20.watch-collection", () => {
168154
});
169155

170156
it("enforces unique constraint on _id (channelId)", async () => {
171-
const watch1 = generateValidWatch();
172-
const watch2 = {
173-
...generateValidWatch(),
174-
_id: watch1._id, // Same channelId
175-
};
157+
const watch = generateWatch();
176158

177-
await mongoService.watch.insertOne(watch1);
159+
await mongoService.watch.insertOne(watch);
178160

179-
await expect(mongoService.watch.insertOne(watch2)).rejects.toThrow();
161+
await expect(mongoService.watch.insertOne(watch)).rejects.toThrow();
180162
});
181163
});
182164
});

packages/scripts/src/migrations/2025.10.13T14.18.20.watch-collection.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { RunnableMigration } from "umzug";
22
import { MigrationContext } from "@scripts/common/cli.types";
33
import { zodToMongoSchema } from "@scripts/common/zod-to-mongo-schema";
4-
import { WatchSchemaStrict } from "@core/types/watch.types";
4+
import { WatchSchema } from "@core/types/watch.types";
55
import mongoService from "@backend/common/services/mongo.service";
66

77
export default class Migration implements RunnableMigration<MigrationContext> {
@@ -11,8 +11,7 @@ export default class Migration implements RunnableMigration<MigrationContext> {
1111
async up(): Promise<void> {
1212
const { collectionName } = mongoService.watch;
1313
const exists = await mongoService.collectionExists(collectionName);
14-
15-
const $jsonSchema = zodToMongoSchema(WatchSchemaStrict);
14+
const $jsonSchema = zodToMongoSchema(WatchSchema);
1615

1716
if (exists) {
1817
// do not run in session
@@ -28,18 +27,16 @@ export default class Migration implements RunnableMigration<MigrationContext> {
2827
});
2928
}
3029

31-
// _id is unique by default in MongoDB, no need to create explicit index
32-
33-
// Create index on userId for efficient user-based queries
30+
// Create index on user for efficient user-based queries
3431
await mongoService.watch.createIndex(
35-
{ userId: 1 },
36-
{ name: `${collectionName}_userId_index` },
32+
{ user: 1 },
33+
{ name: `${collectionName}_user_index` },
3734
);
3835

39-
// Create compound index on userId and expiration for cleanup operations
36+
// Create compound index on user and expiration for cleanup operations
4037
await mongoService.watch.createIndex(
41-
{ userId: 1, expiration: 1 },
42-
{ name: `${collectionName}_userId_expiration_index` },
38+
{ user: 1, expiration: 1 },
39+
{ name: `${collectionName}_user_expiration_index` },
4340
);
4441
}
4542

@@ -57,9 +54,9 @@ export default class Migration implements RunnableMigration<MigrationContext> {
5754
});
5855

5956
// _id index is built-in, no need to drop
60-
await mongoService.watch.dropIndex(`${collectionName}_userId_index`);
57+
await mongoService.watch.dropIndex(`${collectionName}_user_index`);
6158
await mongoService.watch.dropIndex(
62-
`${collectionName}_userId_expiration_index`,
59+
`${collectionName}_user_expiration_index`,
6360
);
6461
}
6562
}

packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-events-watch-data.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import type { RunnableMigration } from "umzug";
22
import { MigrationContext } from "@scripts/common/cli.types";
3-
import { Schema_Sync } from "@core/types/sync.types";
43
import { Watch } from "@core/types/watch.types";
54
import mongoService from "@backend/common/services/mongo.service";
5+
import dayjs from "../../../core/src/util/date/dayjs";
66

77
export default class Migration implements RunnableMigration<MigrationContext> {
88
readonly name: string = "2025.10.13T14.22.21.migrate-events-watch-data";
99
readonly path: string = "2025.10.13T14.22.21.migrate-events-watch-data.ts";
1010

1111
async up(): Promise<void> {
12+
const session = await mongoService.startSession();
1213
// This is a non-destructive migration to copy events watch data from sync collection to watch collection
1314

14-
const cursor = mongoService.sync.find({
15-
"google.events": { $exists: true, $ne: [] },
16-
});
15+
const cursor = mongoService.sync.find(
16+
{ "google.events": { $exists: true, $ne: [] } },
17+
{ batchSize: 100, session },
18+
);
1719

1820
let migratedCount = 0;
1921

@@ -33,22 +35,19 @@ export default class Migration implements RunnableMigration<MigrationContext> {
3335

3436
// Convert expiration string to Date
3537
let expirationDate: Date;
38+
3639
try {
3740
// Google Calendar expiration is typically a timestamp in milliseconds
3841
const expirationMs = parseInt(eventSync.expiration);
42+
3943
if (isNaN(expirationMs)) {
4044
console.warn(
41-
`Invalid expiration format for channelId ${eventSync.channelId}: ${eventSync.expiration}`,
42-
);
43-
continue;
44-
}
45-
expirationDate = new Date(expirationMs);
46-
if (isNaN(expirationDate.getTime())) {
47-
console.warn(
48-
`Invalid expiration date for channelId ${eventSync.channelId}: ${eventSync.expiration}`,
45+
`Invalid expiration ms for channelId ${eventSync.channelId}: ${eventSync.expiration}`,
4946
);
5047
continue;
5148
}
49+
50+
expirationDate = dayjs(expirationMs).toDate();
5251
} catch {
5352
// If parsing fails, skip this watch entry
5453
console.warn(
@@ -59,7 +58,7 @@ export default class Migration implements RunnableMigration<MigrationContext> {
5958

6059
const watchDoc: Watch = {
6160
_id: eventSync.channelId,
62-
userId: syncDoc.user,
61+
user: syncDoc.user,
6362
resourceId: eventSync.resourceId,
6463
expiration: expirationDate,
6564
createdAt: new Date(), // Set current time as creation time for migration
@@ -71,11 +70,13 @@ export default class Migration implements RunnableMigration<MigrationContext> {
7170
if (watchDocuments.length > 0) {
7271
try {
7372
// Use insertMany with ordered: false to continue on duplicates
74-
await mongoService.watch.insertMany(watchDocuments, {
73+
const result = await mongoService.watch.insertMany(watchDocuments, {
7574
ordered: false,
75+
session,
7676
});
77-
migratedCount += watchDocuments.length;
78-
} catch (error: any) {
77+
78+
migratedCount += result.insertedCount;
79+
} catch (error: unknown) {
7980
// Log errors but continue migration (some channels might already exist)
8081
if (error?.writeErrors) {
8182
const duplicateErrors = error.writeErrors.filter(

0 commit comments

Comments
 (0)