@open-rlb/nestjs-amqp 2.0.4 → 2.0.5

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 (69) hide show
  1. package/README.md +4 -2
  2. package/amqp-lib/config/rabbitmq.config.d.ts +6 -0
  3. package/modules/acl/const.d.ts +0 -1
  4. package/modules/acl/const.js +0 -1
  5. package/modules/acl/const.js.map +1 -1
  6. package/modules/acl/models.d.ts +5 -7
  7. package/modules/acl/repository/acl-action.repository.d.ts +1 -5
  8. package/modules/acl/repository/acl-action.repository.js.map +1 -1
  9. package/modules/acl/repository/acl-role.repository.d.ts +1 -5
  10. package/modules/acl/repository/acl-role.repository.js.map +1 -1
  11. package/modules/acl/services/acl-management.service.d.ts +2 -2
  12. package/modules/acl/services/acl-management.service.js +17 -20
  13. package/modules/acl/services/acl-management.service.js.map +1 -1
  14. package/modules/acl/services/acl.service.d.ts +1 -2
  15. package/modules/acl/services/acl.service.js +5 -21
  16. package/modules/acl/services/acl.service.js.map +1 -1
  17. package/modules/broker/broker.module.d.ts +2 -4
  18. package/modules/broker/broker.module.js +23 -5
  19. package/modules/broker/broker.module.js.map +1 -1
  20. package/modules/broker/config/route-discovery.config.d.ts +2 -0
  21. package/modules/broker/const.d.ts +1 -0
  22. package/modules/broker/const.js +2 -1
  23. package/modules/broker/const.js.map +1 -1
  24. package/modules/broker/services/broker.service.js +1 -1
  25. package/modules/broker/services/broker.service.js.map +1 -1
  26. package/modules/broker/services/route-discovery-publisher.service.js +7 -5
  27. package/modules/broker/services/route-discovery-publisher.service.js.map +1 -1
  28. package/modules/gateway-admin/config/gateway-admin.config.d.ts +4 -0
  29. package/modules/gateway-admin/const.d.ts +1 -1
  30. package/modules/gateway-admin/const.js +1 -1
  31. package/modules/gateway-admin/const.js.map +1 -1
  32. package/modules/gateway-admin/gateway-admin.module.d.ts +7 -1
  33. package/modules/gateway-admin/gateway-admin.module.js +13 -0
  34. package/modules/gateway-admin/gateway-admin.module.js.map +1 -1
  35. package/modules/gateway-admin/repository/auth-provider.repository.d.ts +3 -4
  36. package/modules/gateway-admin/repository/auth-provider.repository.js.map +1 -1
  37. package/modules/gateway-admin/services/gateway-auth.service.d.ts +3 -4
  38. package/modules/gateway-admin/services/gateway-auth.service.js +15 -23
  39. package/modules/gateway-admin/services/gateway-auth.service.js.map +1 -1
  40. package/modules/gateway-admin/services/gateway-metrics.service.d.ts +3 -0
  41. package/modules/gateway-admin/services/gateway-metrics.service.js +9 -0
  42. package/modules/gateway-admin/services/gateway-metrics.service.js.map +1 -1
  43. package/modules/gateway-admin/services/route-sync.service.d.ts +3 -1
  44. package/modules/gateway-admin/services/route-sync.service.js +14 -8
  45. package/modules/gateway-admin/services/route-sync.service.js.map +1 -1
  46. package/modules/proxy/services/http-handler.service.d.ts +3 -0
  47. package/modules/proxy/services/http-handler.service.js +27 -3
  48. package/modules/proxy/services/http-handler.service.js.map +1 -1
  49. package/package.json +5 -1
  50. package/schematics/nest-add/files/acl/src/cache/in-memory-acl-store.ts +54 -0
  51. package/schematics/nest-add/files/acl/src/modules/database/repository/acl.repository.ts +74 -0
  52. package/schematics/nest-add/files/db-core/src/modules/database/repository/in-memory-collection.ts +122 -0
  53. package/schematics/nest-add/files/gateway-admin/src/modules/database/repository/gateway.repository.ts +151 -0
  54. package/schematics/nest-add/files/gateway-admin/src/modules/database/repository/route-sync.repository.ts +15 -0
  55. package/schematics/nest-add/files/skills/rlb-amqp/SKILL.md +30 -5
  56. package/schematics/nest-add/files/skills/rlb-amqp/references/config-schema.md +182 -93
  57. package/schematics/nest-add/files/skills/rlb-amqp/references/gotchas.md +120 -79
  58. package/schematics/nest-add/files/skills/rlb-amqp-acl/SKILL.md +185 -0
  59. package/schematics/nest-add/files/skills/rlb-amqp-add-action/SKILL.md +49 -2
  60. package/schematics/nest-add/files/skills/rlb-amqp-add-route/SKILL.md +82 -19
  61. package/schematics/nest-add/files/skills/rlb-amqp-add-ws-event/SKILL.md +40 -18
  62. package/schematics/nest-add/files/skills/rlb-amqp-gateway-admin/SKILL.md +233 -0
  63. package/schematics/nest-add/files/skills/rlb-amqp-scaffold/SKILL.md +172 -42
  64. package/schematics/nest-add/index.js +612 -142
  65. package/schematics/nest-add/index.js.map +1 -1
  66. package/schematics/nest-add/index.ts +673 -241
  67. package/schematics/nest-add/init.schema.d.ts +10 -1
  68. package/schematics/nest-add/init.schema.ts +29 -3
  69. package/schematics/nest-add/schema.json +37 -8
@@ -0,0 +1,54 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { AclCacheStore } from '@open-rlb/nestjs-amqp';
3
+
4
+ interface Entry { value: string; exp: number; }
5
+
6
+ /**
7
+ * In-memory L2 ACL cache for the example app. Replaces the private Redis-backed store so
8
+ * the example builds without external/private packages. It implements the same
9
+ * RLB_ACL_CACHE_STORE contract (string '1'/'0' decisions with a TTL) using a plain Map.
10
+ *
11
+ * NOT for production / multi-instance: the map is per-process and not shared, so cache
12
+ * invalidations on one instance won't reach the others. For a real deployment, plug a
13
+ * shared store (Redis, etc.) under RLB_ACL_CACHE_STORE instead.
14
+ */
15
+ @Injectable()
16
+ export class InMemoryAclStore implements AclCacheStore {
17
+ private readonly store = new Map<string, Entry>();
18
+
19
+ async get(key: string): Promise<string | null | undefined> {
20
+ const entry = this.store.get(key);
21
+ if (!entry) return null;
22
+ if (entry.exp <= Date.now()) {
23
+ this.store.delete(key);
24
+ return null;
25
+ }
26
+ return entry.value;
27
+ }
28
+
29
+ async set(key: string, value: string, ttlSeconds: number): Promise<void> {
30
+ this.store.set(key, { value, exp: Date.now() + ttlSeconds * 1000 });
31
+ }
32
+
33
+ async del(keys: string[]): Promise<void> {
34
+ for (const key of keys) this.store.delete(key);
35
+ }
36
+
37
+ async keys(pattern: string): Promise<string[]> {
38
+ // AclCacheService only uses simple globs like `acl/<userId>/*` (or `acl/*`).
39
+ const regExp = this.globToRegExp(pattern);
40
+ const out: string[] = [];
41
+ for (const key of this.store.keys()) {
42
+ if (regExp.test(key)) out.push(key);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ private globToRegExp(pattern: string): RegExp {
48
+ const escaped = pattern
49
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
50
+ .replace(/\*/g, '.*')
51
+ .replace(/\?/g, '.');
52
+ return new RegExp(`^${escaped}$`);
53
+ }
54
+ }
@@ -0,0 +1,74 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import {
3
+ AclAction,
4
+ AclActionRepository,
5
+ AclGrant,
6
+ AclGrantRepository,
7
+ AclRole,
8
+ AclRoleRepository,
9
+ PaginationModel,
10
+ } from '@open-rlb/nestjs-amqp';
11
+ import { InMemoryCollection } from './in-memory-collection';
12
+
13
+ const page1 = (p?: number) => Number(p) || 1;
14
+ const lim10 = (l?: number) => Number(l) || 10;
15
+
16
+ @Injectable()
17
+ export class InMemoryAclActionRepository extends AclActionRepository {
18
+ // Storage keeps an internal _id (the collection's key); the public AclAction has none.
19
+ private readonly col = new InMemoryCollection<AclAction & { _id?: string }>();
20
+
21
+ async insert(model: AclAction): Promise<AclAction> { return this.col.insert(model); }
22
+ async insertMany(models: AclAction[]): Promise<AclAction[]> { return this.col.insertMany(models); }
23
+ async findByName(name: string): Promise<AclAction> { return this.col.findOne({ name })!; }
24
+ async findOne(filter: Record<string, any>): Promise<AclAction> { return this.col.findOne(filter)!; }
25
+ async upsertOne(filter: Record<string, any>, model: Partial<AclAction>): Promise<AclAction> { return this.col.upsertOne(filter, model); }
26
+ async updateOne(filter: Record<string, any>, model: Partial<AclAction>): Promise<AclAction> { return this.col.updateOne(filter, model)!; }
27
+ async mergeOne(filter: Record<string, any>, model: Partial<AclAction>): Promise<AclAction> { return this.col.updateOne(filter, model)!; }
28
+ async removeOne(filter: Record<string, any>): Promise<AclAction> { return this.col.removeOne(filter)!; }
29
+ async removeMany(filter: Record<string, any>): Promise<number> { return this.col.removeMany(filter); }
30
+ async filter(filter: Record<string, any>): Promise<AclAction[]> { return this.col.filter(filter); }
31
+ async filterPaginated(filter: Record<string, any>, page?: number, limit?: number): Promise<PaginationModel<AclAction>> { return this.col.paginate(filter, page1(page), lim10(limit)); }
32
+ async retrieveAll(): Promise<AclAction[]> { return this.col.all(); }
33
+ async retrieveAllPaginated(page: number, limit: number): Promise<PaginationModel<AclAction>> { return this.col.paginate({}, page1(page), lim10(limit)); }
34
+ }
35
+
36
+ @Injectable()
37
+ export class InMemoryAclRoleRepository extends AclRoleRepository {
38
+ // Storage keeps an internal _id (the collection's key); the public AclRole has none.
39
+ private readonly col = new InMemoryCollection<AclRole & { _id?: string }>();
40
+
41
+ async insert(model: AclRole): Promise<AclRole> { return this.col.insert(model); }
42
+ async insertMany(models: AclRole[]): Promise<AclRole[]> { return this.col.insertMany(models); }
43
+ async findByName(name: string): Promise<AclRole> { return this.col.findOne({ name })!; }
44
+ async findOne(filter: Record<string, any>): Promise<AclRole> { return this.col.findOne(filter)!; }
45
+ async upsertOne(filter: Record<string, any>, model: Partial<AclRole>): Promise<AclRole> { return this.col.upsertOne(filter, model); }
46
+ async updateOne(filter: Record<string, any>, model: Partial<AclRole>): Promise<AclRole> { return this.col.updateOne(filter, model)!; }
47
+ async removeOne(filter: Record<string, any>): Promise<AclRole> { return this.col.removeOne(filter)!; }
48
+ async filter(filter: Record<string, any>): Promise<AclRole[]> { return this.col.filter(filter); }
49
+ async filterPaginated(filter: Record<string, any>, page?: number, limit?: number): Promise<PaginationModel<AclRole>> { return this.col.paginate(filter, page1(page), lim10(limit)); }
50
+ async list(): Promise<AclRole[]> { return this.col.all(); }
51
+ async listPaginated(page: number, limit: number): Promise<PaginationModel<AclRole>> { return this.col.paginate({}, page1(page), lim10(limit)); }
52
+
53
+ async getActionsByNames(names: string[]): Promise<string[]> {
54
+ if (!names?.length) return [];
55
+ const roles = this.col.filter({ name: { $in: names } });
56
+ return [...new Set(roles.flatMap((r) => r.actions || []))];
57
+ }
58
+ }
59
+
60
+ @Injectable()
61
+ export class InMemoryAclGrantRepository extends AclGrantRepository {
62
+ private readonly col = new InMemoryCollection<AclGrant>();
63
+
64
+ async insert(model: AclGrant): Promise<AclGrant> { return this.col.insert(model); }
65
+ async findById(id: string): Promise<AclGrant> { return this.col.findById(id)!; }
66
+ async findOne(filter: Record<string, any>): Promise<AclGrant> { return this.col.findOne(filter)!; }
67
+ async updateById(id: string, model: Partial<AclGrant>): Promise<AclGrant> { return this.col.updateById(id, model)!; }
68
+ async updateOne(filter: Record<string, any>, model: Partial<AclGrant>): Promise<AclGrant> { return this.col.updateOne(filter, model)!; }
69
+ async mergeById(id: string, model: Partial<AclGrant>): Promise<AclGrant> { return this.col.updateById(id, model)!; }
70
+ async removeById(id: string): Promise<AclGrant> { return this.col.removeById(id)!; }
71
+ async removeOne(filter: Record<string, any>): Promise<AclGrant> { return this.col.removeOne(filter)!; }
72
+ async filter(filter: Record<string, any>): Promise<AclGrant[]> { return this.col.filter(filter); }
73
+ async filterPaginated(filter: Record<string, any>, page?: number, limit?: number): Promise<PaginationModel<AclGrant>> { return this.col.paginate(filter, page1(page), lim10(limit)); }
74
+ }
@@ -0,0 +1,122 @@
1
+ import { PaginationModel } from '@open-rlb/nestjs-amqp';
2
+
3
+ let __seq = 0;
4
+ /** Monotonic, ObjectId-looking id (24 hex chars) — no randomness needed. */
5
+ function nextId(): string {
6
+ return (++__seq).toString(16).padStart(24, '0');
7
+ }
8
+
9
+ /** True when `item` matches a simple filter: field equality and `{ $in: [...] }`. */
10
+ function matches(item: any, filter: Record<string, any>): boolean {
11
+ for (const key of Object.keys(filter || {})) {
12
+ const cond = filter[key];
13
+ if (cond === undefined) continue;
14
+ if (cond && typeof cond === 'object' && Array.isArray(cond.$in)) {
15
+ if (!cond.$in.includes(item[key])) return false;
16
+ } else if (item[key] !== cond) {
17
+ return false;
18
+ }
19
+ }
20
+ return true;
21
+ }
22
+
23
+ /**
24
+ * Tiny in-memory collection that mimics the slice of Mongo behavior the repositories
25
+ * need (CRUD by id/filter, `$in`, pagination). Returns shallow copies so callers can't
26
+ * mutate stored documents. Used by the example's RAM repositories instead of Mongoose.
27
+ */
28
+ export class InMemoryCollection<T extends { _id?: string }> {
29
+ private readonly items = new Map<string, T>();
30
+
31
+ insert(model: T): T {
32
+ const _id = model._id ?? nextId();
33
+ const stored = { ...model, _id } as T;
34
+ this.items.set(_id, stored);
35
+ return { ...stored };
36
+ }
37
+
38
+ insertMany(models: T[]): T[] {
39
+ return (models || []).map((m) => this.insert(m));
40
+ }
41
+
42
+ private findId(filter: Record<string, any>): string | undefined {
43
+ for (const [id, v] of this.items) if (matches(v, filter)) return id;
44
+ return undefined;
45
+ }
46
+
47
+ upsertById(id: string, patch: Partial<T>): T {
48
+ return this.items.has(id) ? this.updateById(id, patch)! : this.insert({ ...(patch as object), _id: id } as T);
49
+ }
50
+
51
+ upsertOne(filter: Record<string, any>, patch: Partial<T>): T {
52
+ const id = this.findId(filter);
53
+ return id ? this.updateById(id, patch)! : this.insert(patch as T);
54
+ }
55
+
56
+ removeMany(filter: Record<string, any>): number {
57
+ let removed = 0;
58
+ for (const [id, v] of [...this.items]) if (matches(v, filter)) { this.items.delete(id); removed++; }
59
+ return removed;
60
+ }
61
+
62
+ findById(id: string): T | undefined {
63
+ const v = this.items.get(id);
64
+ return v ? { ...v } : undefined;
65
+ }
66
+
67
+ findOne(filter: Record<string, any>): T | undefined {
68
+ for (const v of this.items.values()) if (matches(v, filter)) return { ...v };
69
+ return undefined;
70
+ }
71
+
72
+ filter(filter: Record<string, any>): T[] {
73
+ return [...this.items.values()].filter((v) => matches(v, filter)).map((v) => ({ ...v }));
74
+ }
75
+
76
+ updateById(id: string, patch: Partial<T>): T | undefined {
77
+ const v = this.items.get(id);
78
+ if (!v) return undefined;
79
+ const next = { ...v, ...patch, _id: id } as T;
80
+ this.items.set(id, next);
81
+ return { ...next };
82
+ }
83
+
84
+ updateOne(filter: Record<string, any>, patch: Partial<T>): T | undefined {
85
+ for (const [id, v] of this.items) {
86
+ if (matches(v, filter)) {
87
+ const next = { ...v, ...patch, _id: id } as T;
88
+ this.items.set(id, next);
89
+ return { ...next };
90
+ }
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ removeById(id: string): T | undefined {
96
+ const v = this.items.get(id);
97
+ if (!v) return undefined;
98
+ this.items.delete(id);
99
+ return { ...v };
100
+ }
101
+
102
+ removeOne(filter: Record<string, any>): T | undefined {
103
+ for (const [id, v] of this.items) {
104
+ if (matches(v, filter)) {
105
+ this.items.delete(id);
106
+ return { ...v };
107
+ }
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ all(): T[] {
113
+ return [...this.items.values()].map((v) => ({ ...v }));
114
+ }
115
+
116
+ paginate(filter: Record<string, any>, page = 1, limit = 10): PaginationModel<T> {
117
+ const data = this.filter(filter);
118
+ const p = page < 1 ? 1 : page;
119
+ const start = (p - 1) * limit;
120
+ return { page: p, limit, total: data.length, data: data.slice(start, start + limit) };
121
+ }
122
+ }
@@ -0,0 +1,151 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import {
3
+ AuthProviderRepository,
4
+ HandlerAuthConfig,
5
+ HttpMetric,
6
+ HttpMetricPoint,
7
+ HttpMetricRepository,
8
+ HttpPathRepository,
9
+ MetricQuery,
10
+ MetricSeriesBucket,
11
+ MetricSeriesQuery,
12
+ PaginationModel,
13
+ PathDefinition,
14
+ StoredAuthProvider,
15
+ StoredHttpPath,
16
+ TrackCallInput,
17
+ } from '@open-rlb/nestjs-amqp';
18
+ import { InMemoryCollection } from './in-memory-collection';
19
+
20
+ /** True when a metric point matches the query's method/route/name + [from,to] time window. */
21
+ function matchPoint(p: HttpMetricPoint, q: MetricQuery): boolean {
22
+ if (q.method && p.method !== q.method) return false;
23
+ if (q.route && p.route !== q.route) return false;
24
+ if (q.name && p.name !== q.name) return false;
25
+ if (q.from != null && p.ts < q.from) return false;
26
+ if (q.to != null && p.ts > q.to) return false;
27
+ return true;
28
+ }
29
+
30
+ /** Drops persistence-only fields (_id, enabled) from a stored doc. */
31
+ function toPlain<T extends { _id?: string; enabled?: boolean }>(doc: T): Omit<T, '_id' | 'enabled'> {
32
+ const { _id, enabled, ...rest } = doc;
33
+ return rest;
34
+ }
35
+
36
+ @Injectable()
37
+ export class InMemoryHttpPathRepository extends HttpPathRepository {
38
+ private readonly col = new InMemoryCollection<StoredHttpPath>();
39
+
40
+ async insert(model: StoredHttpPath): Promise<StoredHttpPath> { return this.col.insert(model); }
41
+ async findById(id: string): Promise<StoredHttpPath> { return this.col.findById(id)!; }
42
+ async findOne(filter: Record<string, any>): Promise<StoredHttpPath> { return this.col.findOne(filter)!; }
43
+ async updateById(id: string, model: StoredHttpPath): Promise<StoredHttpPath> { return this.col.updateById(id, model)!; }
44
+ async removeById(id: string): Promise<StoredHttpPath> { return this.col.removeById(id)!; }
45
+ async filterPaginated(filter: Record<string, any>, page?: number, limit?: number): Promise<PaginationModel<StoredHttpPath>> {
46
+ return this.col.paginate(filter, Number(page) || 1, Number(limit) || 10);
47
+ }
48
+
49
+ async listEnabled(): Promise<PathDefinition[]> {
50
+ return this.col.all()
51
+ .filter((p) => p.enabled !== false)
52
+ .map((p) => toPlain(p) as PathDefinition);
53
+ }
54
+
55
+ async filter(filter: Record<string, any>): Promise<StoredHttpPath[]> {
56
+ return this.col.filter(filter);
57
+ }
58
+ }
59
+
60
+ @Injectable()
61
+ export class InMemoryAuthProviderRepository extends AuthProviderRepository {
62
+ // Storage keeps an internal _id (the collection's key); the public StoredAuthProvider has none.
63
+ private readonly col = new InMemoryCollection<StoredAuthProvider & { _id?: string }>();
64
+
65
+ async insert(model: StoredAuthProvider): Promise<StoredAuthProvider> { return this.col.insert(model); }
66
+ async findByName(name: string): Promise<StoredAuthProvider> { return this.col.findOne({ name })!; }
67
+ async findOne(filter: Record<string, any>): Promise<StoredAuthProvider> { return this.col.findOne(filter)!; }
68
+ async upsertByName(name: string, model: StoredAuthProvider): Promise<StoredAuthProvider> { return this.col.upsertOne({ name }, model); }
69
+ async removeByName(name: string): Promise<StoredAuthProvider> { return this.col.removeOne({ name })!; }
70
+ async filterPaginated(filter: Record<string, any>, page?: number, limit?: number): Promise<PaginationModel<StoredAuthProvider>> {
71
+ return this.col.paginate(filter, Number(page) || 1, Number(limit) || 10);
72
+ }
73
+
74
+ async listEnabled(): Promise<HandlerAuthConfig[]> {
75
+ return this.col.all()
76
+ .filter((p) => p.enabled !== false)
77
+ .map((p) => toPlain(p) as HandlerAuthConfig);
78
+ }
79
+ }
80
+
81
+ @Injectable()
82
+ export class InMemoryHttpMetricRepository extends HttpMetricRepository {
83
+ private readonly col = new InMemoryCollection<HttpMetric>();
84
+ private readonly pointsCol = new InMemoryCollection<HttpMetricPoint>();
85
+
86
+ async increment(input: TrackCallInput): Promise<void> {
87
+ if (!input?.method || !input?.route) return;
88
+ const existing = this.col.findOne({ method: input.method, route: input.route });
89
+ const isError = (input.status ?? 0) >= 400;
90
+ if (!existing) {
91
+ this.col.insert({
92
+ method: input.method,
93
+ route: input.route,
94
+ name: input.name,
95
+ topic: input.topic,
96
+ action: input.action,
97
+ count: 1,
98
+ errorCount: isError ? 1 : 0,
99
+ lastStatus: input.status,
100
+ lastCalledAt: Date.now(),
101
+ totalDurationMs: input.durationMs || 0,
102
+ });
103
+ return;
104
+ }
105
+ this.col.updateById(existing._id!, {
106
+ name: input.name ?? existing.name,
107
+ topic: input.topic ?? existing.topic,
108
+ action: input.action ?? existing.action,
109
+ count: existing.count + 1,
110
+ errorCount: existing.errorCount + (isError ? 1 : 0),
111
+ lastStatus: input.status,
112
+ lastCalledAt: Date.now(),
113
+ totalDurationMs: existing.totalDurationMs + (input.durationMs || 0),
114
+ });
115
+ }
116
+
117
+ async list(route?: string): Promise<(HttpMetric & { avgDurationMs: number; })[]> {
118
+ const rows = route ? this.col.filter({ route }) : this.col.all();
119
+ return rows.map((m) => ({ ...m, avgDurationMs: m.count > 0 ? Math.round(m.totalDurationMs / m.count) : 0 }));
120
+ }
121
+
122
+ async record(point: HttpMetricPoint): Promise<void> {
123
+ if (!point?.method || !point?.route) return;
124
+ this.pointsCol.insert({ ...point, ts: point.ts ?? Date.now() });
125
+ }
126
+
127
+ async points(query: MetricQuery): Promise<HttpMetricPoint[]> {
128
+ const rows = this.pointsCol.all().filter((p) => matchPoint(p, query)).sort((a, b) => b.ts - a.ts);
129
+ return query.limit ? rows.slice(0, query.limit) : rows;
130
+ }
131
+
132
+ async series(query: MetricSeriesQuery): Promise<MetricSeriesBucket[]> {
133
+ const width = query.bucketMs > 0 ? query.bucketMs : 60_000;
134
+ const buckets = new Map<number, MetricSeriesBucket>();
135
+ for (const p of this.pointsCol.all()) {
136
+ if (!matchPoint(p, query)) continue;
137
+ const start = Math.floor(p.ts / width) * width;
138
+ let b = buckets.get(start);
139
+ if (!b) { b = { bucketStart: start, count: 0, errorCount: 0, totalDurationMs: 0, avgDurationMs: 0 }; buckets.set(start, b); }
140
+ b.count++;
141
+ if ((p.status ?? 0) >= 400) b.errorCount++;
142
+ const d = p.durationMs ?? 0;
143
+ b.totalDurationMs += d;
144
+ b.minDurationMs = b.minDurationMs == null ? d : Math.min(b.minDurationMs, d);
145
+ b.maxDurationMs = b.maxDurationMs == null ? d : Math.max(b.maxDurationMs, d);
146
+ }
147
+ const out = [...buckets.values()].sort((a, b) => a.bucketStart - b.bucketStart);
148
+ for (const b of out) b.avgDurationMs = b.count > 0 ? Math.round(b.totalDurationMs / b.count) : 0;
149
+ return out;
150
+ }
151
+ }
@@ -0,0 +1,15 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { RouteSyncLogEntry, RouteSyncLogRepository } from '@open-rlb/nestjs-amqp';
3
+ import { InMemoryCollection } from './in-memory-collection';
4
+
5
+ /** In-RAM journal collection for route-sync events (added/updated/removed/collision/...). */
6
+ @Injectable()
7
+ export class InMemoryRouteSyncLogRepository extends RouteSyncLogRepository {
8
+ private readonly col = new InMemoryCollection<RouteSyncLogEntry>();
9
+
10
+ async insert(entry: RouteSyncLogEntry): Promise<RouteSyncLogEntry> { return this.col.insert(entry); }
11
+ async list(limit = 100): Promise<RouteSyncLogEntry[]> {
12
+ // newest first
13
+ return this.col.all().sort((a, b) => (b.ts || 0) - (a.ts || 0)).slice(0, limit);
14
+ }
15
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: rlb-amqp
3
- description: Reference, schema and gotchas for the @open-rlb/nestjs-amqp library (NestJS + RabbitMQ/AMQP + HTTP/WebSocket gateway). Use when answering questions about its YAML config (broker, topics, auth-providers, gateway, ws), AMQP rpc/handle/broadcast/event semantics, the @BrokerAction/@BrokerParam decorators, the BrokerService API, or when debugging wiring/timeout/auth/websocket errors. Also use as the shared knowledge base for the rlb-amqp-add-action, rlb-amqp-add-route, rlb-amqp-add-ws-event and rlb-amqp-scaffold skills.
3
+ description: Reference, schema and gotchas for the @open-rlb/nestjs-amqp library (NestJS + RabbitMQ/AMQP + HTTP/WebSocket gateway). Use when answering questions about its YAML config (broker incl. routeDiscovery, topics, auth-providers, gateway paths/ws/events), AMQP rpc/handle/broadcast/event semantics, the @BrokerAction/@BrokerParam/@BrokerHTTP decorators, the BrokerService API, route auto-discovery, gateway-admin (routes/auth-providers/metrics/health), the name-keyed ACL, or when debugging wiring/timeout/auth/websocket errors. Shared knowledge base for the rlb-amqp-add-action, rlb-amqp-add-route, rlb-amqp-add-ws-event, rlb-amqp-scaffold, rlb-amqp-acl and rlb-amqp-gateway-admin skills.
4
4
  ---
5
5
 
6
6
  # @open-rlb/nestjs-amqp — reference
@@ -23,17 +23,32 @@ HTTP/WS → Gateway (gateway.paths / gateway.events) → topic+action → Rabbit
23
23
  topic dispatches by `action`.
24
24
  - The same method serves both **RPC** (`requestData`, waits for the reply) and **event**
25
25
  (`publishMessage`, waits only for the broker's publisher confirm).
26
+ - A microservice can announce its `@BrokerHTTP` routes to a gateway over AMQP
27
+ (**route auto-discovery**) so the gateway registers them without YAML edits.
26
28
 
27
29
  ## When to use this skill
28
30
 
29
31
  Load the bundled reference files before editing config, handlers or the gateway:
30
32
 
31
- - **`references/config-schema.md`** — full YAML schema for every section, field by field.
33
+ - **`references/config-schema.md`** — full YAML schema for every section, field by field
34
+ (broker incl. `routeDiscovery`, topics 4 modes, auth-providers name-keyed, gateway
35
+ paths + ws + events).
32
36
  - **`references/gotchas.md`** — the checklist of bug-prone cases. ALWAYS scan it before
33
37
  adding/changing a topic, queue, exchange, action, route, auth provider or WS event.
34
38
 
35
- The human-facing docs live in the repo `README.md`; these files are the terse,
36
- rules-first version for editing tasks.
39
+ The authoritative human-facing docs live under the repo `docs/` directory; these two files
40
+ are the terse, rules-first version for editing tasks:
41
+
42
+ - `docs/README.md` — index. `docs/getting-started.md` — bootstrap a ms + gateway.
43
+ - `docs/broker.md` — `@BrokerAction`/`@BrokerParam`, topic modes, RPC, `BrokerService`.
44
+ - `docs/gateway.md` — `gateway.paths[]`, `gateway.events[]`, auth gate, status mapping, ws.
45
+ - `docs/acl.md` — name-keyed actions/roles, dual grant/revoke, `acl-can-user-do*` checks.
46
+ - `docs/gateway-admin.md` — DB routes, name-keyed auth-providers, metrics, health, route-sync.
47
+ - `docs/gotchas.md` — the canonical source `references/gotchas.md` is ported from.
48
+
49
+ Runnable examples live under `sample/config-sample/` (`gateway-in-memory`, `gateway-db`,
50
+ `calculator.ms`, plus the annotated `broker/gateway/acl/gateway-admin.yaml` reference
51
+ configs). The retired `apps/gateway-2` is gone — do not cite it.
37
52
 
38
53
  ## Sibling task skills
39
54
 
@@ -41,6 +56,8 @@ rules-first version for editing tasks.
41
56
  - `rlb-amqp-add-route` — expose an action over HTTP (`gateway.paths[]`).
42
57
  - `rlb-amqp-add-ws-event` — add a secure WebSocket/webhook event (`gateway.events[]`).
43
58
  - `rlb-amqp-scaffold` — bootstrap a new microservice/gateway (module, main, config).
59
+ - `rlb-amqp-acl` — name-keyed actions/roles, grant/revoke, the `acl-can-user-do*` checks.
60
+ - `rlb-amqp-gateway-admin` — DB routes/auth-providers, metrics, health, route auto-discovery.
44
61
 
45
62
  ## Golden rules (summary — full list in references/gotchas.md)
46
63
 
@@ -49,7 +66,8 @@ rules-first version for editing tasks.
49
66
  2. `mode: rpc`/`handle` need `topics[].queue` present in `broker.queues[]`, whose
50
67
  `exchange` exists in `broker.exchanges[]`.
51
68
  3. Exchange `type: topic` → the queue MUST have a `routingKey`.
52
- 4. `broadcast` and the WebSocket gateway require `connection_name`.
69
+ 4. `broadcast` and the WebSocket gateway require a **distinct** `connection_name` per
70
+ instance (set it, or let `broker.routeDiscovery.serviceName` fill it in).
53
71
  5. `(topic, action)` must be unique — duplicates overwrite silently.
54
72
  6. No destructuring / default values in `@BrokerAction` method parameters; always pass an
55
73
  explicit `name` to `@BrokerParam`.
@@ -57,3 +75,10 @@ rules-first version for editing tasks.
57
75
  8. `roles` (HTTP or WS) require an `IAclRoleService` registered via
58
76
  `RLB_GTW_ACL_ROLE_SERVICE` in `ProxyModule.forRootAsync({ providers: [...] })`.
59
77
  Auth-providers + gateway config are passed to `ProxyModule` (not `BrokerModule`).
78
+ 9. Topic names `rlb-acl` / `rlb-gateway-admin` / `rlb-gateway-control` and ALL action
79
+ strings (`acl-*`, `gw-path-*`, `gw-auth-*`, `gw-metrics-*`, `gw-health`, `gw-reload`)
80
+ are decorator-bound and NOT configurable. Only exchange/queue/routingKey and the
81
+ route-discovery exchange/queue are.
82
+ 10. ACL actions/roles and gateway-admin auth-providers are **name-keyed**: `PUT` upserts,
83
+ `GET` lists, `GET .../get?name=`, `DELETE` by name. There is no POST and no id-based
84
+ ACL CRUD. Boolean checks (`/acl/check*`) return `200` with `true`/`false`.