@terreno/api 0.20.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/.ai/guidelines/core.md +71 -0
  2. package/.ai/skills/mongoose-schema-safety/SKILL.md +143 -0
  3. package/README.md +54 -1
  4. package/bunfig.toml +1 -1
  5. package/dist/__tests__/versionCheckPlugin.test.js +29 -7
  6. package/dist/actions.openApi.test.js +13 -11
  7. package/dist/api.js +98 -11
  8. package/dist/api.query.test.js +31 -1
  9. package/dist/api.test.js +211 -0
  10. package/dist/auth.test.js +418 -43
  11. package/dist/betterAuth.d.ts +1 -1
  12. package/dist/consentApp.test.js +1 -0
  13. package/dist/example.js +4 -4
  14. package/dist/expressServer.d.ts +0 -22
  15. package/dist/expressServer.js +1 -125
  16. package/dist/expressServer.test.js +90 -91
  17. package/dist/githubAuth.test.js +22 -22
  18. package/dist/logger.d.ts +154 -0
  19. package/dist/logger.js +445 -26
  20. package/dist/logger.test.js +435 -0
  21. package/dist/middleware.d.ts +7 -0
  22. package/dist/middleware.js +58 -1
  23. package/dist/middleware.test.js +159 -0
  24. package/dist/models/consentForm.js +2 -1
  25. package/dist/models/consentResponse.js +2 -1
  26. package/dist/models/versionConfig.js +2 -1
  27. package/dist/openApi.test.js +10 -17
  28. package/dist/openApiBuilder.d.ts +18 -0
  29. package/dist/openApiBuilder.js +21 -0
  30. package/dist/openApiBuilder.test.js +34 -10
  31. package/dist/permissions.test.js +10 -43
  32. package/dist/populate.test.js +10 -42
  33. package/dist/realtime/changeStreamWatcher.d.ts +4 -4
  34. package/dist/realtime/changeStreamWatcher.js +2 -4
  35. package/dist/realtime/queryMatcher.d.ts +1 -1
  36. package/dist/realtime/queryMatcher.js +39 -14
  37. package/dist/realtime/types.d.ts +3 -3
  38. package/dist/requestContext.d.ts +61 -0
  39. package/dist/requestContext.js +74 -0
  40. package/dist/secretProviders.test.js +335 -0
  41. package/dist/syncConsents.test.js +2 -2
  42. package/dist/terrenoApp.d.ts +27 -15
  43. package/dist/terrenoApp.js +24 -14
  44. package/dist/terrenoApp.test.js +52 -0
  45. package/dist/tests/bunSetup.js +66 -262
  46. package/dist/tests/createTestData.d.ts +9 -0
  47. package/dist/tests/createTestData.js +272 -0
  48. package/dist/tests/models.d.ts +71 -0
  49. package/dist/tests/models.js +134 -0
  50. package/dist/tests/mongoTestSetup.d.ts +7 -0
  51. package/dist/tests/mongoTestSetup.js +150 -0
  52. package/dist/tests/testEnv.d.ts +0 -0
  53. package/dist/tests/testEnv.js +6 -0
  54. package/dist/tests/testHelper.d.ts +22 -0
  55. package/dist/tests/testHelper.js +115 -0
  56. package/dist/tests/types.d.ts +29 -0
  57. package/dist/tests/types.js +2 -0
  58. package/dist/tests.d.ts +10 -78
  59. package/dist/tests.js +24 -241
  60. package/dist/transformers.test.js +14 -50
  61. package/package.json +18 -4
  62. package/src/__snapshots__/openApiBuilder.test.ts.snap +1 -0
  63. package/src/__tests__/versionCheckPlugin.test.ts +43 -15
  64. package/src/actions.openApi.test.ts +12 -10
  65. package/src/api.query.test.ts +24 -1
  66. package/src/api.test.ts +169 -0
  67. package/src/api.ts +71 -0
  68. package/src/auth.test.ts +287 -39
  69. package/src/betterAuth.ts +1 -1
  70. package/src/consentApp.test.ts +1 -0
  71. package/src/example.ts +4 -4
  72. package/src/expressServer.test.ts +82 -85
  73. package/src/expressServer.ts +1 -213
  74. package/src/githubAuth.test.ts +22 -22
  75. package/src/logger.test.ts +466 -1
  76. package/src/logger.ts +477 -14
  77. package/src/middleware.test.ts +74 -2
  78. package/src/middleware.ts +57 -0
  79. package/src/models/consentForm.ts +3 -4
  80. package/src/models/consentResponse.ts +6 -4
  81. package/src/models/versionConfig.ts +3 -4
  82. package/src/openApi.test.ts +10 -17
  83. package/src/openApiBuilder.test.ts +27 -10
  84. package/src/openApiBuilder.ts +24 -0
  85. package/src/permissions.test.ts +8 -23
  86. package/src/populate.test.ts +7 -22
  87. package/src/realtime/changeStreamWatcher.ts +15 -10
  88. package/src/realtime/queryMatcher.ts +54 -27
  89. package/src/realtime/types.ts +4 -4
  90. package/src/requestContext.ts +86 -0
  91. package/src/secretProviders.test.ts +219 -1
  92. package/src/syncConsents.test.ts +1 -1
  93. package/src/terrenoApp.test.ts +38 -0
  94. package/src/terrenoApp.ts +37 -15
  95. package/src/tests/bunSetup.ts +22 -236
  96. package/src/tests/createTestData.ts +176 -0
  97. package/src/tests/models.ts +164 -0
  98. package/src/tests/mongoTestSetup.ts +69 -0
  99. package/src/tests/testEnv.ts +4 -0
  100. package/src/tests/testHelper.ts +57 -0
  101. package/src/tests/types.ts +35 -0
  102. package/src/tests.ts +40 -231
  103. package/src/transformers.test.ts +11 -30
  104. package/tsconfig.typedoc.json +4 -0
  105. package/dist/tests/index.d.ts +0 -1
  106. package/dist/tests/index.js +0 -17
  107. package/src/tests/index.ts +0 -1
package/src/middleware.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as Sentry from "@sentry/bun";
2
2
  import type {NextFunction, Request, Response} from "express";
3
3
 
4
+ import {getCurrentRequestContext} from "./requestContext";
5
+
4
6
  /**
5
7
  * Express middleware that captures the app version from the request header
6
8
  * and adds it as a tag to the current Sentry scope.
@@ -20,3 +22,58 @@ export const sentryAppVersionMiddleware = (
20
22
  }
21
23
  next();
22
24
  };
25
+
26
+ /**
27
+ * OpenAPI vendor routes that must return pristine JSON (no injected requestId).
28
+ * Matches @wesleytodd/openapi: main spec, per-component JSON, and validate payload.
29
+ */
30
+ const isOpenApiToolingJsonRequest = (req: Request): boolean => {
31
+ if (req.method !== "GET") {
32
+ return false;
33
+ }
34
+ const {path} = req;
35
+ if (path === "/openapi.json") {
36
+ return true;
37
+ }
38
+ if (path === "/openapi/validate") {
39
+ return true;
40
+ }
41
+ if (path.startsWith("/openapi/components/") && path.endsWith(".json")) {
42
+ return true;
43
+ }
44
+ return false;
45
+ };
46
+
47
+ /**
48
+ * TerrenoApp middleware: augments `res.json` so plain-object payloads include
49
+ * `requestId` for client correlation. Skips OpenAPI tooling GET JSON routes
50
+ * (`/openapi.json`, `/openapi/components/...json`, `/openapi/validate`) so
51
+ * machine-consumed payloads stay valid. Does not wrap arrays or primitives.
52
+ */
53
+ export const jsonResponseRequestIdMiddleware = (
54
+ req: Request,
55
+ res: Response,
56
+ next: NextFunction
57
+ ): void => {
58
+ const originalJson = res.json.bind(res);
59
+ res.json = (body?: unknown): Response => {
60
+ if (isOpenApiToolingJsonRequest(req)) {
61
+ return originalJson(body);
62
+ }
63
+
64
+ const requestId =
65
+ (req as Request & {requestId?: string}).requestId ?? getCurrentRequestContext()?.requestId;
66
+
67
+ if (!requestId) {
68
+ return originalJson(body);
69
+ }
70
+
71
+ if (body !== null && body !== undefined && typeof body === "object" && !Array.isArray(body)) {
72
+ return originalJson({...(body as Record<string, unknown>), requestId});
73
+ }
74
+
75
+ return originalJson(body);
76
+ };
77
+
78
+ next();
79
+ };
@@ -126,7 +126,6 @@ consentFormSchema.plugin(isDeletedPlugin);
126
126
  consentFormSchema.plugin(findOneOrNone);
127
127
  consentFormSchema.plugin(findExactlyOne);
128
128
 
129
- export const ConsentForm = mongoose.model<ConsentFormDocument, ConsentFormModel>(
130
- "ConsentForm",
131
- consentFormSchema
132
- );
129
+ export const ConsentForm =
130
+ (mongoose.models.ConsentForm as ConsentFormModel | undefined) ??
131
+ mongoose.model<ConsentFormDocument, ConsentFormModel>("ConsentForm", consentFormSchema);
@@ -72,7 +72,9 @@ consentResponseSchema.plugin(isDeletedPlugin);
72
72
  consentResponseSchema.plugin(findOneOrNone);
73
73
  consentResponseSchema.plugin(findExactlyOne);
74
74
 
75
- export const ConsentResponse = mongoose.model<ConsentResponseDocument, ConsentResponseModel>(
76
- "ConsentResponse",
77
- consentResponseSchema
78
- );
75
+ export const ConsentResponse =
76
+ (mongoose.models.ConsentResponse as ConsentResponseModel | undefined) ??
77
+ mongoose.model<ConsentResponseDocument, ConsentResponseModel>(
78
+ "ConsentResponse",
79
+ consentResponseSchema
80
+ );
@@ -97,7 +97,6 @@ versionConfigSchema.plugin(isDeletedPlugin);
97
97
  versionConfigSchema.plugin(findOneOrNone);
98
98
  versionConfigSchema.plugin(findExactlyOne);
99
99
 
100
- export const VersionConfig = mongoose.model<VersionConfigDocument, VersionConfigModel>(
101
- "VersionConfig",
102
- versionConfigSchema
103
- );
100
+ export const VersionConfig =
101
+ (mongoose.models.VersionConfig as VersionConfigModel | undefined) ??
102
+ mongoose.model<VersionConfigDocument, VersionConfigModel>("VersionConfig", versionConfigSchema);
@@ -6,8 +6,6 @@ import supertest from "supertest";
6
6
  import type TestAgent from "supertest/lib/agent";
7
7
 
8
8
  import {type ModelRouterOptions, modelRouter} from "./api";
9
- import {addAuthRoutes, setupAuth} from "./auth";
10
- import {setupServer} from "./expressServer";
11
9
  import {
12
10
  createOpenApiMiddleware,
13
11
  deleteOpenApiMiddleware,
@@ -17,6 +15,7 @@ import {
17
15
  readOpenApiMiddleware,
18
16
  } from "./openApi";
19
17
  import {Permissions} from "./permissions";
18
+ import {TerrenoApp} from "./terrenoApp";
20
19
  import {FoodModel, setupDb, UserModel} from "./tests";
21
20
 
22
21
  function getMessageSummaryOpenApiMiddleware(options: Partial<ModelRouterOptions<any>>): any {
@@ -90,13 +89,11 @@ describe("openApi", () => {
90
89
  process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
91
90
  process.env.ENABLE_SWAGGER = "true";
92
91
 
93
- app = setupServer({
94
- addRoutes,
92
+ app = new TerrenoApp({
93
+ configureApp: addRoutes,
95
94
  skipListen: true,
96
95
  userModel: UserModel as any,
97
- });
98
- setupAuth(app, UserModel as any);
99
- addAuthRoutes(app, UserModel as any);
96
+ }).build();
100
97
  });
101
98
 
102
99
  it("gets the openapi.json", async () => {
@@ -246,13 +243,11 @@ describe("openApi without swagger", () => {
246
243
  process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
247
244
  process.env.ENABLE_SWAGGER = "false";
248
245
 
249
- app = setupServer({
250
- addRoutes,
246
+ app = new TerrenoApp({
247
+ configureApp: addRoutes,
251
248
  skipListen: true,
252
249
  userModel: UserModel as any,
253
- });
254
- setupAuth(app, UserModel as any);
255
- addAuthRoutes(app, UserModel as any);
250
+ }).build();
256
251
  });
257
252
 
258
253
  it("does not have the swagger ui", async () => {
@@ -268,13 +263,11 @@ describe("openApi populate", () => {
268
263
  beforeEach(async () => {
269
264
  process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
270
265
 
271
- app = setupServer({
272
- addRoutes: addRoutesPopulate,
266
+ app = new TerrenoApp({
267
+ configureApp: addRoutesPopulate,
273
268
  skipListen: true,
274
269
  userModel: UserModel as any,
275
- });
276
- setupAuth(app, UserModel as any);
277
- addAuthRoutes(app, UserModel as any);
270
+ }).build();
278
271
  });
279
272
 
280
273
  it("gets the openapi.json with populate", async () => {
@@ -6,10 +6,9 @@ import supertest from "supertest";
6
6
  import type TestAgent from "supertest/lib/agent";
7
7
 
8
8
  import {type ModelRouterOptions, modelRouter} from "./api";
9
- import {addAuthRoutes, setupAuth} from "./auth";
10
- import {setupServer} from "./expressServer";
11
9
  import {createOpenApiBuilder, OpenApiMiddlewareBuilder} from "./openApiBuilder";
12
10
  import {Permissions} from "./permissions";
11
+ import {TerrenoApp} from "./terrenoApp";
13
12
  import {FoodModel, UserModel} from "./tests";
14
13
 
15
14
  function addRoutesWithBuilder(router: Router, options?: Partial<ModelRouterOptions<any>>): void {
@@ -17,6 +16,7 @@ function addRoutesWithBuilder(router: Router, options?: Partial<ModelRouterOptio
17
16
  const statsMiddleware = createOpenApiBuilder(options ?? {})
18
17
  .withTags(["Stats"])
19
18
  .withSummary("Get food statistics")
19
+ .withOperationId("getFoodStats")
20
20
  .withDescription("Returns aggregated statistics about food items")
21
21
  .withQueryParameter(
22
22
  "category",
@@ -145,13 +145,11 @@ describe("OpenApiMiddlewareBuilder", () => {
145
145
  process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
146
146
  process.env.ENABLE_SWAGGER = "true";
147
147
 
148
- app = setupServer({
149
- addRoutes: addRoutesWithBuilder,
148
+ app = new TerrenoApp({
149
+ configureApp: addRoutesWithBuilder,
150
150
  skipListen: true,
151
151
  userModel: UserModel as any,
152
- });
153
- setupAuth(app, UserModel as any);
154
- addAuthRoutes(app, UserModel as any);
152
+ }).build();
155
153
  });
156
154
 
157
155
  describe("builder pattern", () => {
@@ -198,6 +196,14 @@ describe("OpenApiMiddlewareBuilder", () => {
198
196
  expect(categoryParam.required).toBe(false);
199
197
  });
200
198
 
199
+ it("includes the explicit operationId in OpenAPI spec", async () => {
200
+ server = supertest(app);
201
+ const res = await server.get("/openapi.json").expect(200);
202
+
203
+ const statsPath = res.body.paths["/food/stats"];
204
+ expect(statsPath.get.operationId).toBe("getFoodStats");
205
+ });
206
+
201
207
  it("includes request body schema in OpenAPI spec", async () => {
202
208
  server = supertest(app);
203
209
  const res = await server.get("/openapi.json").expect(200);
@@ -302,7 +308,11 @@ describe("OpenApiMiddlewareBuilder", () => {
302
308
  it("stats endpoint returns correct data", async () => {
303
309
  server = supertest(app);
304
310
  const res = await server.get("/food/stats").expect(200);
305
- expect(res.body).toEqual({avgCalories: 250, count: 10});
311
+ expect(res.body).toEqual({
312
+ avgCalories: 250,
313
+ count: 10,
314
+ requestId: res.headers["x-request-id"],
315
+ });
306
316
  });
307
317
 
308
318
  it("reports endpoint returns correct data", async () => {
@@ -311,7 +321,10 @@ describe("OpenApiMiddlewareBuilder", () => {
311
321
  .post("/food/reports")
312
322
  .send({endDate: "2024-12-31", startDate: "2024-01-01"})
313
323
  .expect(201);
314
- expect(res.body).toEqual({reportId: "report-123"});
324
+ expect(res.body).toEqual({
325
+ reportId: "report-123",
326
+ requestId: res.headers["x-request-id"],
327
+ });
315
328
  });
316
329
 
317
330
  it("categories endpoint returns array data", async () => {
@@ -325,7 +338,11 @@ describe("OpenApiMiddlewareBuilder", () => {
325
338
  it("category by id endpoint returns correct data", async () => {
326
339
  server = supertest(app);
327
340
  const res = await server.get("/food/categories/cat-123").expect(200);
328
- expect(res.body).toEqual({id: "cat-123", name: "Fruits"});
341
+ expect(res.body).toEqual({
342
+ id: "cat-123",
343
+ name: "Fruits",
344
+ requestId: res.headers["x-request-id"],
345
+ });
329
346
  });
330
347
  });
331
348
 
@@ -213,6 +213,8 @@ interface OpenApiConfig {
213
213
  summary?: string;
214
214
  /** Detailed description of the operation */
215
215
  description?: string;
216
+ /** Explicit operationId for the operation */
217
+ operationId?: string;
216
218
  /** Operation parameters (query, path, header) */
217
219
  parameters?: OpenApiParameter[];
218
220
  /** Request body configuration */
@@ -359,6 +361,28 @@ export class OpenApiMiddlewareBuilder {
359
361
  return this;
360
362
  }
361
363
 
364
+ /**
365
+ * Sets an explicit `operationId` for the OpenAPI operation.
366
+ *
367
+ * The `operationId` is a unique string used to identify an operation. Client and SDK
368
+ * generators (e.g. RTK Query codegen) derive generated function and hook names from it,
369
+ * so setting it keeps generated names stable and readable for routes whose URL path would
370
+ * otherwise produce unwieldy names (e.g. deeply nested routes). It must be unique across
371
+ * the whole OpenAPI document.
372
+ *
373
+ * @param operationId - Unique operation identifier (e.g. "getUserStats")
374
+ * @returns The builder instance for chaining
375
+ *
376
+ * @example
377
+ * ```typescript
378
+ * builder.withOperationId("getUserStats");
379
+ * ```
380
+ */
381
+ withOperationId(operationId: string): this {
382
+ this.config.operationId = operationId;
383
+ return this;
384
+ }
385
+
362
386
  /**
363
387
  * Sets the description for the OpenAPI operation.
364
388
  *
@@ -13,7 +13,7 @@ import {
13
13
  FoodModel,
14
14
  getBaseServer,
15
15
  RequiredModel,
16
- setupDb,
16
+ setupTestData,
17
17
  UserModel,
18
18
  } from "./tests";
19
19
 
@@ -24,22 +24,7 @@ describe("permissions", () => {
24
24
  beforeEach(async () => {
25
25
  process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
26
26
 
27
- const [admin, notAdmin] = await setupDb();
28
-
29
- await Promise.all([
30
- FoodModel.create({
31
- calories: 1,
32
- created: new Date(),
33
- name: "Spinach",
34
- ownerId: notAdmin._id,
35
- }),
36
- FoodModel.create({
37
- calories: 100,
38
- created: Date.now() - 10,
39
- name: "Apple",
40
- ownerId: admin._id,
41
- }),
42
- ]);
27
+ await setupTestData();
43
28
  app = getBaseServer();
44
29
  setupAuth(app, UserModel as any);
45
30
  addAuthRoutes(app, UserModel as any);
@@ -74,12 +59,12 @@ describe("permissions", () => {
74
59
  describe("anonymous food", () => {
75
60
  it("list", async () => {
76
61
  const res = await server.get("/food").expect(200);
77
- expect(res.body.data).toHaveLength(2);
62
+ expect(res.body.data).toHaveLength(4);
78
63
  });
79
64
 
80
65
  it("get", async () => {
81
66
  const res = await server.get("/food").expect(200);
82
- expect(res.body.data).toHaveLength(2);
67
+ expect(res.body.data).toHaveLength(4);
83
68
  const res2 = await server.get(`/food/${res.body.data[0]._id}`).expect(200);
84
69
  expect(res.body.data[0]._id).toBe(res2.body.data._id);
85
70
  });
@@ -116,12 +101,12 @@ describe("permissions", () => {
116
101
 
117
102
  it("list", async () => {
118
103
  const res = await agent.get("/food").expect(200);
119
- expect(res.body.data).toHaveLength(2);
104
+ expect(res.body.data).toHaveLength(4);
120
105
  });
121
106
 
122
107
  it("get", async () => {
123
108
  const res = await agent.get("/food").expect(200);
124
- expect(res.body.data).toHaveLength(2);
109
+ expect(res.body.data).toHaveLength(4);
125
110
  const res2 = await server.get(`/food/${res.body.data[0]._id}`).expect(200);
126
111
  expect(res.body.data[0]._id).toBe(res2.body.data._id);
127
112
  });
@@ -175,12 +160,12 @@ describe("permissions", () => {
175
160
 
176
161
  it("list", async () => {
177
162
  const res = await agent.get("/food");
178
- expect(res.body.data).toHaveLength(2);
163
+ expect(res.body.data).toHaveLength(4);
179
164
  });
180
165
 
181
166
  it("get", async () => {
182
167
  const res = await agent.get("/food");
183
- expect(res.body.data).toHaveLength(2);
168
+ expect(res.body.data).toHaveLength(4);
184
169
  const res2 = await agent.get(`/food/${res.body.data[0]._id}`);
185
170
  expect(res.body.data[0]._id).toBe(res2.body.data._id);
186
171
  });
@@ -3,7 +3,7 @@ import {beforeEach, describe, expect, it} from "bun:test";
3
3
  import mongoose, {type Document, type HydratedDocument, Schema} from "mongoose";
4
4
 
5
5
  import {fixMixedFields, getOpenApiSpecForModel, unpopulate} from "./populate";
6
- import {FoodModel, setupDb, type User, UserModel} from "./tests";
6
+ import {FoodModel, setupTestData, type User, UserModel} from "./tests";
7
7
 
8
8
  describe("populate functions", () => {
9
9
  let admin: HydratedDocument<User>;
@@ -13,32 +13,17 @@ describe("populate functions", () => {
13
13
  let spinach: any;
14
14
 
15
15
  beforeEach(async () => {
16
- [admin, notAdmin] = await setupDb();
17
-
18
- [spinach] = await Promise.all([
19
- FoodModel.create({
20
- calories: 1,
21
- created: new Date("2021-12-03T00:00:20.000Z"),
22
- eatenBy: [admin._id],
23
- hidden: false,
24
- likesIds: [
25
- {likes: true, userId: admin._id},
26
- {likes: false, userId: notAdmin._id},
27
- ],
28
- name: "Spinach",
29
- ownerId: admin._id,
30
- source: {
31
- name: "Brand",
32
- },
33
- }),
34
- ]);
16
+ const testData = await setupTestData();
17
+ admin = testData.users.admin;
18
+ notAdmin = testData.users.notAdmin;
19
+ spinach = testData.foods.spinach;
35
20
  });
36
21
 
37
22
  it("unpopulate", async () => {
38
23
  let populated = await spinach.populate("ownerId");
39
24
  populated = await populated.populate("eatenBy");
40
25
  populated = await populated.populate("likesIds.userId");
41
- expect(populated.ownerId.name).toBe("Admin");
26
+ expect(populated.ownerId.name).toBe("Not Admin");
42
27
  expect(populated.eatenBy[0].id).toBe(admin.id);
43
28
  expect(populated.eatenBy[0].name).toBe("Admin");
44
29
  expect(populated.likesIds[0].userId.id).toBe(admin.id);
@@ -49,7 +34,7 @@ describe("populate functions", () => {
49
34
  // noExplicitAny: unpopulate returns Document<T> which doesn't expose model properties; would require refactoring the return type
50
35
  let unpopulated: any = unpopulate(populated, "ownerId");
51
36
  expect(spinach.ownerId.name).toBeUndefined();
52
- expect(unpopulated.ownerId.toString()).toBe(admin.id);
37
+ expect(unpopulated.ownerId.toString()).toBe(notAdmin.id);
53
38
  // Ensure nothing else was touched.
54
39
  expect(populated.likesIds[0].userId.id).toBe(admin.id);
55
40
  expect(populated.likesIds[0].userId.name).toBe("Admin");
@@ -1,4 +1,3 @@
1
- // biome-ignore-all lint/suspicious/noExplicitAny: change stream and socket handlers use dynamic document shapes
2
1
  import * as Sentry from "@sentry/bun";
3
2
  import type express from "express";
4
3
  import {DateTime} from "luxon";
@@ -98,7 +97,7 @@ const getSocketsInRoom = (io: Server, room: string): RealtimeSocketWithAuth[] =>
98
97
  const canReadDocument = async (
99
98
  entry: RealtimeRegistryEntry,
100
99
  user?: User,
101
- doc?: any
100
+ doc?: Record<string, unknown>
102
101
  ): Promise<boolean> => {
103
102
  return checkPermissions("read", entry.options.permissions.read, user, doc);
104
103
  };
@@ -107,7 +106,11 @@ const canReadDocument = async (
107
106
  * Determine which Socket.io rooms to emit to based on the room strategy.
108
107
  * Exported for testing.
109
108
  */
110
- export const resolveRooms = (entry: RealtimeRegistryEntry, doc: any, method: string): string[] => {
109
+ export const resolveRooms = (
110
+ entry: RealtimeRegistryEntry,
111
+ doc: Record<string, unknown>,
112
+ method: string
113
+ ): string[] => {
111
114
  const {roomStrategy} = entry.config;
112
115
  // Use the collection tag (e.g. "todos") for model rooms, matching what the frontend subscribes to
113
116
  const collectionTag = getCollectionTag(entry.routePath);
@@ -120,7 +123,7 @@ export const resolveRooms = (entry: RealtimeRegistryEntry, doc: any, method: str
120
123
 
121
124
  switch (roomStrategy) {
122
125
  case "owner": {
123
- const ownerId = doc?.ownerId?.toString?.() ?? doc?.ownerId;
126
+ const ownerId = doc?.ownerId != null ? String(doc.ownerId) : undefined;
124
127
  if (ownerId) {
125
128
  return [`user:${ownerId}`];
126
129
  }
@@ -169,10 +172,10 @@ export const ensureApiId = (data: unknown): unknown => {
169
172
  */
170
173
  export const serializeDoc = async (
171
174
  entry: RealtimeRegistryEntry,
172
- doc: any,
175
+ doc: Record<string, unknown>,
173
176
  method: "create" | "update" | "delete",
174
177
  user?: User
175
- ): Promise<any> => {
178
+ ): Promise<unknown> => {
176
179
  if (entry.config.realtimeResponseHandler) {
177
180
  try {
178
181
  return ensureApiId(await entry.config.realtimeResponseHandler(doc, method));
@@ -203,7 +206,9 @@ export const serializeDoc = async (
203
206
  }
204
207
  }
205
208
 
206
- return ensureApiId(typeof doc.toJSON === "function" ? doc.toJSON() : doc);
209
+ return ensureApiId(
210
+ typeof doc.toJSON === "function" ? (doc as {toJSON: () => unknown}).toJSON() : doc
211
+ );
207
212
  };
208
213
 
209
214
  export const emitToAuthorizedRoom = async (
@@ -211,7 +216,7 @@ export const emitToAuthorizedRoom = async (
211
216
  room: string,
212
217
  event: RealtimeEvent,
213
218
  entry: RealtimeRegistryEntry,
214
- fullDocument: any,
219
+ fullDocument: Record<string, unknown> | undefined,
215
220
  logDebug: (msg: string) => void
216
221
  ): Promise<void> => {
217
222
  const sockets = getSocketsInRoom(io, room);
@@ -263,7 +268,7 @@ export const emitToDocumentAndQueryRooms = async (
263
268
  io: Server,
264
269
  collection: string,
265
270
  event: RealtimeEvent,
266
- fullDocument: any,
271
+ fullDocument: Record<string, unknown> | undefined,
267
272
  logDebug: (msg: string) => void,
268
273
  entry?: RealtimeRegistryEntry
269
274
  ): Promise<void> => {
@@ -495,7 +500,7 @@ export const startChangeStreamWatcher = (
495
500
  rooms = [`model:${collectionTag}`];
496
501
  }
497
502
  } else {
498
- rooms = resolveRooms(entry, fullDocument, method);
503
+ rooms = resolveRooms(entry, fullDocument ?? {}, method);
499
504
  }
500
505
 
501
506
  const collection = getCollectionTag(entry.routePath);