@terreno/api 0.20.2 → 0.21.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.
- package/.ai/guidelines/core.md +71 -0
- package/.ai/skills/mongoose-schema-safety/SKILL.md +143 -0
- package/README.md +54 -1
- package/dist/__tests__/versionCheckPlugin.test.js +29 -7
- package/dist/actions.openApi.test.js +13 -11
- package/dist/api.js +98 -11
- package/dist/api.query.test.js +31 -1
- package/dist/api.test.js +211 -0
- package/dist/auth.test.js +10 -10
- package/dist/betterAuth.d.ts +1 -1
- package/dist/consentApp.test.js +1 -0
- package/dist/example.js +4 -4
- package/dist/expressServer.d.ts +0 -22
- package/dist/expressServer.js +1 -125
- package/dist/expressServer.test.js +90 -91
- package/dist/githubAuth.test.js +22 -22
- package/dist/logger.d.ts +154 -0
- package/dist/logger.js +445 -26
- package/dist/logger.test.js +435 -0
- package/dist/middleware.d.ts +7 -0
- package/dist/middleware.js +58 -1
- package/dist/middleware.test.js +159 -0
- package/dist/openApi.test.js +10 -17
- package/dist/openApiBuilder.test.js +18 -10
- package/dist/realtime/changeStreamWatcher.d.ts +4 -4
- package/dist/realtime/changeStreamWatcher.js +2 -4
- package/dist/realtime/queryMatcher.d.ts +1 -1
- package/dist/realtime/queryMatcher.js +39 -14
- package/dist/realtime/types.d.ts +3 -3
- package/dist/requestContext.d.ts +61 -0
- package/dist/requestContext.js +74 -0
- package/dist/secretProviders.test.js +335 -0
- package/dist/terrenoApp.d.ts +27 -15
- package/dist/terrenoApp.js +24 -14
- package/dist/terrenoApp.test.js +52 -0
- package/dist/tests/bunSetup.js +61 -7
- package/dist/tests.js +27 -4
- package/package.json +1 -1
- package/src/__tests__/versionCheckPlugin.test.ts +43 -15
- package/src/actions.openApi.test.ts +12 -10
- package/src/api.query.test.ts +24 -1
- package/src/api.test.ts +169 -0
- package/src/api.ts +71 -0
- package/src/auth.test.ts +10 -10
- package/src/betterAuth.ts +1 -1
- package/src/consentApp.test.ts +1 -0
- package/src/example.ts +4 -4
- package/src/expressServer.test.ts +82 -85
- package/src/expressServer.ts +1 -213
- package/src/githubAuth.test.ts +22 -22
- package/src/logger.test.ts +466 -1
- package/src/logger.ts +477 -14
- package/src/middleware.test.ts +74 -2
- package/src/middleware.ts +57 -0
- package/src/openApi.test.ts +10 -17
- package/src/openApiBuilder.test.ts +18 -10
- package/src/realtime/changeStreamWatcher.ts +15 -10
- package/src/realtime/queryMatcher.ts +54 -27
- package/src/realtime/types.ts +4 -4
- package/src/requestContext.ts +86 -0
- package/src/secretProviders.test.ts +219 -1
- package/src/terrenoApp.test.ts +38 -0
- package/src/terrenoApp.ts +37 -15
- package/src/tests/bunSetup.ts +16 -3
- package/src/tests.ts +17 -4
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
|
+
};
|
package/src/openApi.test.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
272
|
-
|
|
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 {
|
|
@@ -145,13 +144,11 @@ describe("OpenApiMiddlewareBuilder", () => {
|
|
|
145
144
|
process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
|
|
146
145
|
process.env.ENABLE_SWAGGER = "true";
|
|
147
146
|
|
|
148
|
-
app =
|
|
149
|
-
|
|
147
|
+
app = new TerrenoApp({
|
|
148
|
+
configureApp: addRoutesWithBuilder,
|
|
150
149
|
skipListen: true,
|
|
151
150
|
userModel: UserModel as any,
|
|
152
|
-
});
|
|
153
|
-
setupAuth(app, UserModel as any);
|
|
154
|
-
addAuthRoutes(app, UserModel as any);
|
|
151
|
+
}).build();
|
|
155
152
|
});
|
|
156
153
|
|
|
157
154
|
describe("builder pattern", () => {
|
|
@@ -302,7 +299,11 @@ describe("OpenApiMiddlewareBuilder", () => {
|
|
|
302
299
|
it("stats endpoint returns correct data", async () => {
|
|
303
300
|
server = supertest(app);
|
|
304
301
|
const res = await server.get("/food/stats").expect(200);
|
|
305
|
-
expect(res.body).toEqual({
|
|
302
|
+
expect(res.body).toEqual({
|
|
303
|
+
avgCalories: 250,
|
|
304
|
+
count: 10,
|
|
305
|
+
requestId: res.headers["x-request-id"],
|
|
306
|
+
});
|
|
306
307
|
});
|
|
307
308
|
|
|
308
309
|
it("reports endpoint returns correct data", async () => {
|
|
@@ -311,7 +312,10 @@ describe("OpenApiMiddlewareBuilder", () => {
|
|
|
311
312
|
.post("/food/reports")
|
|
312
313
|
.send({endDate: "2024-12-31", startDate: "2024-01-01"})
|
|
313
314
|
.expect(201);
|
|
314
|
-
expect(res.body).toEqual({
|
|
315
|
+
expect(res.body).toEqual({
|
|
316
|
+
reportId: "report-123",
|
|
317
|
+
requestId: res.headers["x-request-id"],
|
|
318
|
+
});
|
|
315
319
|
});
|
|
316
320
|
|
|
317
321
|
it("categories endpoint returns array data", async () => {
|
|
@@ -325,7 +329,11 @@ describe("OpenApiMiddlewareBuilder", () => {
|
|
|
325
329
|
it("category by id endpoint returns correct data", async () => {
|
|
326
330
|
server = supertest(app);
|
|
327
331
|
const res = await server.get("/food/categories/cat-123").expect(200);
|
|
328
|
-
expect(res.body).toEqual({
|
|
332
|
+
expect(res.body).toEqual({
|
|
333
|
+
id: "cat-123",
|
|
334
|
+
name: "Fruits",
|
|
335
|
+
requestId: res.headers["x-request-id"],
|
|
336
|
+
});
|
|
329
337
|
});
|
|
330
338
|
});
|
|
331
339
|
|
|
@@ -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?:
|
|
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 = (
|
|
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
|
|
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:
|
|
175
|
+
doc: Record<string, unknown>,
|
|
173
176
|
method: "create" | "update" | "delete",
|
|
174
177
|
user?: User
|
|
175
|
-
): Promise<
|
|
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(
|
|
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:
|
|
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:
|
|
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);
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// biome-ignore-all lint/suspicious/noExplicitAny: MongoDB query matcher evaluates dynamic filter shapes
|
|
2
1
|
/**
|
|
3
2
|
* Simple in-memory MongoDB query matcher.
|
|
4
3
|
* Evaluates a MongoDB-style query object against a document without hitting the database.
|
|
@@ -6,35 +5,52 @@
|
|
|
6
5
|
* Supports: equality, $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $and, $or, $not.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
|
-
const getNestedValue = (doc:
|
|
8
|
+
const getNestedValue = (doc: Record<string, unknown>, path: string): unknown => {
|
|
10
9
|
const parts = path.split(".");
|
|
11
|
-
let current = doc;
|
|
10
|
+
let current: unknown = doc;
|
|
12
11
|
for (const part of parts) {
|
|
13
12
|
if (current === null || current === undefined) {
|
|
14
13
|
return undefined;
|
|
15
14
|
}
|
|
16
|
-
current = current[part];
|
|
15
|
+
current = (current as Record<string, unknown>)[part];
|
|
17
16
|
}
|
|
18
17
|
return current;
|
|
19
18
|
};
|
|
20
19
|
|
|
21
|
-
const normalize = (value:
|
|
20
|
+
const normalize = (value: unknown): unknown => {
|
|
22
21
|
if (value === null || value === undefined) {
|
|
23
22
|
return value;
|
|
24
23
|
}
|
|
25
24
|
// Handle ObjectId-like objects with toString
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return value.toString();
|
|
25
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
26
|
+
const obj = value as Record<string, unknown>;
|
|
27
|
+
const ctorName = (obj.constructor as {name?: string} | undefined)?.name;
|
|
28
|
+
if (typeof obj.toString === "function" && ctorName !== "Object") {
|
|
29
|
+
return String(value);
|
|
30
|
+
}
|
|
33
31
|
}
|
|
34
32
|
return value;
|
|
35
33
|
};
|
|
36
34
|
|
|
37
|
-
|
|
35
|
+
/**
|
|
36
|
+
* JS abstract relational comparison on unknown values.
|
|
37
|
+
* Numeric operands compare numerically; everything else compares as strings.
|
|
38
|
+
* This mirrors the coercion behaviour of `>` / `<` on the `any`-typed values
|
|
39
|
+
* that MongoDB in-memory matching historically received.
|
|
40
|
+
*/
|
|
41
|
+
const compareValues = (a: unknown, b: unknown): number => {
|
|
42
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
43
|
+
return a - b;
|
|
44
|
+
}
|
|
45
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
46
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
47
|
+
}
|
|
48
|
+
const numA = Number(a);
|
|
49
|
+
const numB = Number(b);
|
|
50
|
+
return numA - numB;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const matchesCondition = (rawValue: unknown, condition: unknown): boolean => {
|
|
38
54
|
const value = normalize(rawValue);
|
|
39
55
|
|
|
40
56
|
// Direct equality (non-object condition)
|
|
@@ -49,7 +65,7 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
|
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
// Operator object
|
|
52
|
-
for (const [op, operand] of Object.entries(condition)) {
|
|
68
|
+
for (const [op, operand] of Object.entries(condition as Record<string, unknown>)) {
|
|
53
69
|
const normOp = normalize(operand);
|
|
54
70
|
|
|
55
71
|
switch (op) {
|
|
@@ -63,32 +79,40 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
|
|
|
63
79
|
return false;
|
|
64
80
|
}
|
|
65
81
|
break;
|
|
66
|
-
case "$gt":
|
|
67
|
-
|
|
82
|
+
case "$gt": {
|
|
83
|
+
const cmp = compareValues(value, normOp);
|
|
84
|
+
if (Number.isNaN(cmp) || cmp <= 0) {
|
|
68
85
|
return false;
|
|
69
86
|
}
|
|
70
87
|
break;
|
|
71
|
-
|
|
72
|
-
|
|
88
|
+
}
|
|
89
|
+
case "$gte": {
|
|
90
|
+
const cmp = compareValues(value, normOp);
|
|
91
|
+
if (Number.isNaN(cmp) || cmp < 0) {
|
|
73
92
|
return false;
|
|
74
93
|
}
|
|
75
94
|
break;
|
|
76
|
-
|
|
77
|
-
|
|
95
|
+
}
|
|
96
|
+
case "$lt": {
|
|
97
|
+
const cmp = compareValues(value, normOp);
|
|
98
|
+
if (Number.isNaN(cmp) || cmp >= 0) {
|
|
78
99
|
return false;
|
|
79
100
|
}
|
|
80
101
|
break;
|
|
81
|
-
|
|
82
|
-
|
|
102
|
+
}
|
|
103
|
+
case "$lte": {
|
|
104
|
+
const cmp = compareValues(value, normOp);
|
|
105
|
+
if (Number.isNaN(cmp) || cmp > 0) {
|
|
83
106
|
return false;
|
|
84
107
|
}
|
|
85
108
|
break;
|
|
109
|
+
}
|
|
86
110
|
case "$in": {
|
|
87
111
|
if (!Array.isArray(operand)) {
|
|
88
112
|
return false;
|
|
89
113
|
}
|
|
90
114
|
const inValues = operand.map(normalize);
|
|
91
|
-
if (!inValues.some((v
|
|
115
|
+
if (!inValues.some((v) => v === value || String(v) === String(value))) {
|
|
92
116
|
return false;
|
|
93
117
|
}
|
|
94
118
|
break;
|
|
@@ -98,7 +122,7 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
|
|
|
98
122
|
return false;
|
|
99
123
|
}
|
|
100
124
|
const ninValues = operand.map(normalize);
|
|
101
|
-
if (ninValues.some((v
|
|
125
|
+
if (ninValues.some((v) => v === value || String(v) === String(value))) {
|
|
102
126
|
return false;
|
|
103
127
|
}
|
|
104
128
|
break;
|
|
@@ -132,14 +156,17 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
|
|
|
132
156
|
* @param query - MongoDB-style query object
|
|
133
157
|
* @returns true if the document matches all query conditions
|
|
134
158
|
*/
|
|
135
|
-
export const matchesQuery = (
|
|
159
|
+
export const matchesQuery = (
|
|
160
|
+
doc: Record<string, unknown>,
|
|
161
|
+
query: Record<string, unknown>
|
|
162
|
+
): boolean => {
|
|
136
163
|
for (const [key, condition] of Object.entries(query)) {
|
|
137
164
|
if (key === "$and") {
|
|
138
165
|
if (!Array.isArray(condition)) {
|
|
139
166
|
return false;
|
|
140
167
|
}
|
|
141
168
|
for (const subQuery of condition) {
|
|
142
|
-
if (!matchesQuery(doc, subQuery)) {
|
|
169
|
+
if (!matchesQuery(doc, subQuery as Record<string, unknown>)) {
|
|
143
170
|
return false;
|
|
144
171
|
}
|
|
145
172
|
}
|
|
@@ -152,7 +179,7 @@ export const matchesQuery = (doc: any, query: Record<string, any>): boolean => {
|
|
|
152
179
|
}
|
|
153
180
|
let matched = false;
|
|
154
181
|
for (const subQuery of condition) {
|
|
155
|
-
if (matchesQuery(doc, subQuery)) {
|
|
182
|
+
if (matchesQuery(doc, subQuery as Record<string, unknown>)) {
|
|
156
183
|
matched = true;
|
|
157
184
|
break;
|
|
158
185
|
}
|
package/src/realtime/types.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// biome-ignore-all lint/suspicious/noExplicitAny: realtime config callbacks receive dynamic document shapes
|
|
2
1
|
import type express from "express";
|
|
3
2
|
|
|
4
3
|
/**
|
|
@@ -19,9 +18,9 @@ export interface RealtimeConfig {
|
|
|
19
18
|
| "owner"
|
|
20
19
|
| "model"
|
|
21
20
|
| "broadcast"
|
|
22
|
-
| ((doc:
|
|
21
|
+
| ((doc: Record<string, unknown>, method: string, req: express.Request) => string[]);
|
|
23
22
|
/** Custom serializer for real-time events. Falls back to the modelRouter responseHandler. */
|
|
24
|
-
realtimeResponseHandler?: (doc:
|
|
23
|
+
realtimeResponseHandler?: (doc: Record<string, unknown>, method: string) => unknown;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
/**
|
|
@@ -37,6 +36,7 @@ export interface RealtimeEvent {
|
|
|
37
36
|
/** Document ID */
|
|
38
37
|
id: string;
|
|
39
38
|
/** Serialized document data (omitted for hard deletes) */
|
|
39
|
+
// biome-ignore lint/suspicious/noExplicitAny: noExplicitAny: event data is a serialized document whose shape varies by model; consumers must narrow to their specific type
|
|
40
40
|
data?: any;
|
|
41
41
|
/** Fields that were updated (for update events from change streams) */
|
|
42
42
|
updatedFields?: string[];
|
|
@@ -102,7 +102,7 @@ export interface QuerySubscription {
|
|
|
102
102
|
/** Collection tag (e.g. "todos") */
|
|
103
103
|
collection: string;
|
|
104
104
|
/** MongoDB-style query filter (e.g. {completed: false}) */
|
|
105
|
-
query: Record<string,
|
|
105
|
+
query: Record<string, unknown>;
|
|
106
106
|
/** Client-provided queryId (ignored — server computes a canonical ID) */
|
|
107
107
|
queryId?: string;
|
|
108
108
|
}
|
package/src/requestContext.ts
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request/job correlation for `@terreno/api`.
|
|
3
|
+
*
|
|
4
|
+
* Correlation is how every log line emitted while handling one request (or one background job) can
|
|
5
|
+
* be tied back together. It is built on Node's {@link AsyncLocalStorage}: a {@link RequestContext}
|
|
6
|
+
* (with `requestId`, `userId`, `traceId`, etc.) is stored for the duration of a callback, and the
|
|
7
|
+
* logger's Winston format reads it from there and merges it into each line. Nothing needs to be
|
|
8
|
+
* threaded through function arguments.
|
|
9
|
+
*
|
|
10
|
+
* Two ways a scope is established:
|
|
11
|
+
*
|
|
12
|
+
* - **HTTP**: {@link requestContextMiddleware} runs first in the middleware stack. It derives a
|
|
13
|
+
* `requestId` from incoming headers ({@link REQUEST_CONTEXT_ATTRIBUTE_NAMES}, `x-correlation-id`,
|
|
14
|
+
* Cloud Trace, or W3C `traceparent`) or generates one, echoes it back as `X-Request-ID`, and runs
|
|
15
|
+
* the rest of the request inside the scope.
|
|
16
|
+
* - **Jobs/scripts**: {@link runWithRequestContext} (or {@link runWithRequestContextAttributes})
|
|
17
|
+
* establishes the same scope manually so background work is just as traceable.
|
|
18
|
+
*
|
|
19
|
+
* The active context is also pushed to Sentry tags/context via {@link applyRequestContextToSentry},
|
|
20
|
+
* and is exposed to logging via {@link getCurrentLogContext} / {@link getCurrentRequestContext}.
|
|
21
|
+
*
|
|
22
|
+
* @see {@link runWithRequestContext}
|
|
23
|
+
* @see {@link getCurrentLogContext}
|
|
24
|
+
* @module requestContext
|
|
25
|
+
*/
|
|
1
26
|
import {AsyncLocalStorage} from "node:async_hooks";
|
|
2
27
|
import {randomUUID} from "node:crypto";
|
|
3
28
|
import * as Sentry from "@sentry/bun";
|
|
@@ -14,18 +39,35 @@ const TRACE_PARENT_HEADER = "traceparent";
|
|
|
14
39
|
const TRACE_SAMPLED_HEADER = "x-trace-sampled";
|
|
15
40
|
const USER_ID_HEADER = "x-user-id";
|
|
16
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Correlation fields stored in AsyncLocalStorage for the lifetime of a request or job. Every log
|
|
44
|
+
* line emitted inside the scope is enriched with these. `requestId` is the only required field; the
|
|
45
|
+
* rest are populated when headers, trace context, or auth supply them.
|
|
46
|
+
*/
|
|
17
47
|
export interface RequestContext {
|
|
48
|
+
/** Background job identifier (from `x-job-id` or set via {@link runWithRequestContext}). */
|
|
18
49
|
jobId?: string;
|
|
50
|
+
/** Stable id shared by all log lines for one request/job; echoed to clients as `X-Request-ID`. */
|
|
19
51
|
requestId: string;
|
|
52
|
+
/** Auth session id, resolved from the JWT/Better Auth session or `x-session-id`. */
|
|
20
53
|
sessionId?: string;
|
|
54
|
+
/** Distributed-tracing span id, parsed from Cloud Trace or W3C `traceparent`. */
|
|
21
55
|
spanId?: string;
|
|
56
|
+
/** Distributed-tracing trace id, parsed from Cloud Trace or W3C `traceparent`. */
|
|
22
57
|
traceId?: string;
|
|
58
|
+
/** Whether the trace is sampled, per the incoming trace headers. */
|
|
23
59
|
traceSampled?: boolean;
|
|
60
|
+
/** Authenticated user id, populated after auth middleware runs. */
|
|
24
61
|
userId?: string;
|
|
25
62
|
}
|
|
26
63
|
|
|
27
64
|
export type RequestContextAttributes = Record<string, string>;
|
|
28
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Canonical HTTP header names for each correlation field. Use these to propagate context to
|
|
68
|
+
* downstream services (pair with {@link getCurrentRequestContextAttributes}) or to read it from an
|
|
69
|
+
* incoming request (pair with {@link getRequestContextFromAttributes}).
|
|
70
|
+
*/
|
|
29
71
|
export const REQUEST_CONTEXT_ATTRIBUTE_NAMES = {
|
|
30
72
|
jobId: JOB_ID_HEADER,
|
|
31
73
|
requestId: "x-request-id",
|
|
@@ -161,10 +203,19 @@ export const getRequestContextFromAttributes = (
|
|
|
161
203
|
};
|
|
162
204
|
};
|
|
163
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Returns the full {@link RequestContext} for the active AsyncLocalStorage scope, or `undefined`
|
|
208
|
+
* when called outside any request/job scope. The logger uses this to enrich each line.
|
|
209
|
+
*/
|
|
164
210
|
export const getCurrentRequestContext = (): RequestContext | undefined => {
|
|
165
211
|
return requestContextStorage.getStore();
|
|
166
212
|
};
|
|
167
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Returns the active correlation fields as a plain object (empty when outside a scope). This is the
|
|
216
|
+
* shape attached to Sentry log attributes and is handy when you need to log or forward the current
|
|
217
|
+
* context yourself.
|
|
218
|
+
*/
|
|
168
219
|
export const getCurrentLogContext = (): Partial<RequestContext> => {
|
|
169
220
|
const context = getCurrentRequestContext();
|
|
170
221
|
if (!context) {
|
|
@@ -254,6 +305,11 @@ const setAttribute = (
|
|
|
254
305
|
attributes[name] = String(value);
|
|
255
306
|
};
|
|
256
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Serializes the active correlation context into HTTP header attributes (keyed by
|
|
310
|
+
* {@link REQUEST_CONTEXT_ATTRIBUTE_NAMES}) so it can be propagated on outbound requests to other
|
|
311
|
+
* services, keeping the same `requestId`/`traceId` across service boundaries.
|
|
312
|
+
*/
|
|
257
313
|
export const getCurrentRequestContextAttributes = (
|
|
258
314
|
overrides: Partial<RequestContext> = {}
|
|
259
315
|
): RequestContextAttributes => {
|
|
@@ -269,6 +325,23 @@ export const getCurrentRequestContextAttributes = (
|
|
|
269
325
|
return attributes;
|
|
270
326
|
};
|
|
271
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Runs `callback` inside a fresh correlation scope so every log line it emits shares the same
|
|
330
|
+
* identifiers — the manual equivalent of {@link requestContextMiddleware} for background jobs,
|
|
331
|
+
* cron tasks, scripts, queue consumers, etc. A `requestId` is generated when not supplied, and the
|
|
332
|
+
* context is mirrored to Sentry.
|
|
333
|
+
*
|
|
334
|
+
* @example
|
|
335
|
+
* ```typescript
|
|
336
|
+
* import {createScopedLogger, runWithRequestContext} from "@terreno/api";
|
|
337
|
+
*
|
|
338
|
+
* await runWithRequestContext({jobId: "nightly-sync"}, async () => {
|
|
339
|
+
* const log = createScopedLogger({prefix: "[NightlySync]"});
|
|
340
|
+
* log.info("started"); // includes jobId + a generated requestId on every line
|
|
341
|
+
* await sync();
|
|
342
|
+
* });
|
|
343
|
+
* ```
|
|
344
|
+
*/
|
|
272
345
|
export const runWithRequestContext = <T>(
|
|
273
346
|
context: Partial<RequestContext>,
|
|
274
347
|
callback: () => T
|
|
@@ -284,6 +357,11 @@ export const runWithRequestContext = <T>(
|
|
|
284
357
|
});
|
|
285
358
|
};
|
|
286
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Like {@link runWithRequestContext}, but seeds the scope from raw header attributes (for example
|
|
362
|
+
* those received on an incoming message or forwarded by another service). Parses Cloud Trace / W3C
|
|
363
|
+
* `traceparent` into `traceId`/`spanId` via {@link getRequestContextFromAttributes}.
|
|
364
|
+
*/
|
|
287
365
|
export const runWithRequestContextAttributes = <T>(
|
|
288
366
|
attributes: Record<string, string | undefined> = {},
|
|
289
367
|
callback: () => T
|
|
@@ -324,6 +402,14 @@ export const updateRequestContextFromRequest = (
|
|
|
324
402
|
}
|
|
325
403
|
};
|
|
326
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Express middleware that opens a correlation scope for the request. Mounted early by `TerrenoApp` /
|
|
407
|
+
* `setupServer`, it resolves a `requestId` (from request-id/correlation headers, Cloud Trace, or
|
|
408
|
+
* W3C `traceparent`, else a new UUID), captures any `jobId`/`sessionId`/trace fields, echoes
|
|
409
|
+
* `X-Request-ID` back to the client, and runs the remaining middleware inside the scope so all
|
|
410
|
+
* downstream logs are correlated. A later auth-aware pass ({@link updateRequestContextFromRequest})
|
|
411
|
+
* fills in `userId`/`sessionId`.
|
|
412
|
+
*/
|
|
327
413
|
export const requestContextMiddleware = (
|
|
328
414
|
req: express.Request,
|
|
329
415
|
res: express.Response,
|