@axiom-lattice/pg-stores 5.1.1 → 5.2.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.
@@ -0,0 +1,209 @@
1
+ import type {
2
+ PlatformAuditAction,
3
+ PlatformAuditAppendInput,
4
+ PlatformAuditInitiator,
5
+ PlatformAuditQuery,
6
+ PlatformAuditRecord,
7
+ PlatformAuditSource,
8
+ PlatformAuditStatus,
9
+ PlatformAuditStore,
10
+ } from "@axiom-lattice/protocols";
11
+ import type { Pool } from "pg";
12
+
13
+ const TABLE = "lattice_platform_audit";
14
+
15
+ interface AuditRow {
16
+ id: string;
17
+ initiator_user_id: string | null;
18
+ initiator_role: string | null;
19
+ initiator: PlatformAuditInitiator | string;
20
+ source: string;
21
+ executor: PlatformAuditRecord["executor"] | string;
22
+ tenant_id: string | null;
23
+ action: string;
24
+ status: string;
25
+ summary: Record<string, unknown> | string;
26
+ before: Record<string, unknown> | string | null;
27
+ after: Record<string, unknown> | string | null;
28
+ error_code: string | null;
29
+ duration_ms: number;
30
+ created_at: Date;
31
+ }
32
+
33
+ function parseJson<T>(value: T | string): T {
34
+ return typeof value === "string" ? (JSON.parse(value) as T) : value;
35
+ }
36
+
37
+ /**
38
+ * Normalizes an optional timestamp boundary to a canonical UTC ISO string.
39
+ *
40
+ * Equivalent inputs (e.g. `2024-01-01T08:00:01+08:00`, date-only values, or
41
+ * explicit `Z`) all compare as the same instant. Empty or unparseable values
42
+ * are ignored rather than compared as raw text.
43
+ */
44
+ function normalizeBoundary(value?: string): string | undefined {
45
+ if (value === undefined) return undefined;
46
+ const trimmed = value.trim();
47
+ if (trimmed === "") return undefined;
48
+ const parsed = new Date(trimmed);
49
+ if (Number.isNaN(parsed.getTime())) return undefined;
50
+ return parsed.toISOString();
51
+ }
52
+
53
+ /**
54
+ * Normalizes a pagination value: non-finite values use the fallback, finite
55
+ * values are truncated to integers, and negatives are clamped to zero.
56
+ */
57
+ function normalizePagination(
58
+ value: number | undefined,
59
+ fallback: number,
60
+ ): number {
61
+ if (value === undefined || !Number.isFinite(value)) return fallback;
62
+ return Math.max(0, Math.trunc(value));
63
+ }
64
+
65
+ function mapRow(row: AuditRow): PlatformAuditRecord {
66
+ return {
67
+ id: row.id,
68
+ initiator: parseJson<PlatformAuditInitiator>(row.initiator),
69
+ source: row.source as PlatformAuditSource,
70
+ executor: parseJson<PlatformAuditRecord["executor"]>(row.executor),
71
+ ...(row.tenant_id !== null ? { tenantId: row.tenant_id } : {}),
72
+ action: row.action as PlatformAuditAction,
73
+ status: row.status as PlatformAuditStatus,
74
+ summary: parseJson<Record<string, unknown>>(row.summary),
75
+ ...(row.before !== null
76
+ ? { before: parseJson<Record<string, unknown>>(row.before) }
77
+ : {}),
78
+ ...(row.after !== null
79
+ ? { after: parseJson<Record<string, unknown>>(row.after) }
80
+ : {}),
81
+ ...(row.error_code !== null ? { errorCode: row.error_code } : {}),
82
+ durationMs: Number(row.duration_ms),
83
+ createdAt: row.created_at,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * PostgreSQL implementation of {@link PlatformAuditStore}.
89
+ *
90
+ * Persists platform-level audit records in the append-only
91
+ * `lattice_platform_audit` table. Records are returned newest-first by
92
+ * `createdAt` descending, then `id` descending.
93
+ */
94
+ export class PostgreSQLPlatformAuditStore implements PlatformAuditStore {
95
+ constructor(private readonly options: { pool: Pool }) {}
96
+
97
+ private get pool(): Pool {
98
+ return this.options.pool;
99
+ }
100
+
101
+ /** Appends a record, assigning a fresh id and creation timestamp. */
102
+ async append(record: PlatformAuditAppendInput): Promise<PlatformAuditRecord> {
103
+ const initiator = record.initiator;
104
+ const initiatorUserId =
105
+ initiator.type === "human" ? initiator.userId : null;
106
+ const initiatorRole = initiator.type === "human" ? initiator.role : null;
107
+ const durationMs = Number.isFinite(record.durationMs)
108
+ ? Math.max(0, Math.round(record.durationMs))
109
+ : 0;
110
+
111
+ const result = await this.pool.query<AuditRow>(
112
+ `INSERT INTO ${TABLE}
113
+ (initiator_user_id, initiator_role, initiator, source, executor,
114
+ tenant_id, action, status, summary, before, after, error_code,
115
+ duration_ms)
116
+ VALUES ($1,$2,$3::jsonb,$4,$5::jsonb,$6,$7,$8,$9::jsonb,$10::jsonb,
117
+ $11::jsonb,$12,$13)
118
+ RETURNING *`,
119
+ [
120
+ initiatorUserId,
121
+ initiatorRole,
122
+ JSON.stringify(initiator),
123
+ record.source,
124
+ JSON.stringify(record.executor),
125
+ record.tenantId ?? null,
126
+ record.action,
127
+ record.status,
128
+ JSON.stringify(record.summary),
129
+ record.before === undefined ? null : JSON.stringify(record.before),
130
+ record.after === undefined ? null : JSON.stringify(record.after),
131
+ record.errorCode ?? null,
132
+ durationMs,
133
+ ],
134
+ );
135
+
136
+ const row = result.rows[0];
137
+ if (!row) throw new Error("Platform audit record was not persisted");
138
+ return mapRow(row);
139
+ }
140
+
141
+ /** Returns a record by id, or null when it does not exist. */
142
+ async get(id: string): Promise<PlatformAuditRecord | null> {
143
+ const result = await this.pool.query<AuditRow>(
144
+ `SELECT * FROM ${TABLE} WHERE id = $1`,
145
+ [id],
146
+ );
147
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
148
+ }
149
+
150
+ /**
151
+ * Returns matching records newest-first.
152
+ *
153
+ * `initiatorUserId` only matches human initiators. `from` is inclusive and
154
+ * `to` is exclusive after both are normalized to instants; invalid or empty
155
+ * boundaries are ignored. Records are ordered by `createdAt` descending,
156
+ * then by `id` descending. Results are paginated with a default limit of 100;
157
+ * non-finite `offset`/`limit` fall back to defaults, finite values are
158
+ * truncated to integers, and negatives are clamped to 0.
159
+ */
160
+ async query(params: PlatformAuditQuery): Promise<PlatformAuditRecord[]> {
161
+ const clauses: string[] = [];
162
+ const values: unknown[] = [];
163
+
164
+ if (params.initiatorUserId !== undefined) {
165
+ values.push(params.initiatorUserId);
166
+ clauses.push(`initiator_user_id = $${values.length}`);
167
+ }
168
+ if (params.source !== undefined) {
169
+ values.push(params.source);
170
+ clauses.push(`source = $${values.length}`);
171
+ }
172
+ if (params.tenantId !== undefined) {
173
+ values.push(params.tenantId);
174
+ clauses.push(`tenant_id = $${values.length}`);
175
+ }
176
+ if (params.action !== undefined) {
177
+ values.push(params.action);
178
+ clauses.push(`action = $${values.length}`);
179
+ }
180
+ if (params.status !== undefined) {
181
+ values.push(params.status);
182
+ clauses.push(`status = $${values.length}`);
183
+ }
184
+ const from = normalizeBoundary(params.from);
185
+ if (from !== undefined) {
186
+ values.push(from);
187
+ clauses.push(`created_at >= $${values.length}`);
188
+ }
189
+ const to = normalizeBoundary(params.to);
190
+ if (to !== undefined) {
191
+ values.push(to);
192
+ clauses.push(`created_at < $${values.length}`);
193
+ }
194
+
195
+ const limit = normalizePagination(params.limit, 100);
196
+ const offset = normalizePagination(params.offset, 0);
197
+ values.push(limit, offset);
198
+
199
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
200
+ const { rows } = await this.pool.query<AuditRow>(
201
+ `SELECT * FROM ${TABLE}${where}
202
+ ORDER BY created_at DESC, id DESC
203
+ LIMIT $${values.length - 1} OFFSET $${values.length}`,
204
+ values,
205
+ );
206
+
207
+ return rows.map(mapRow);
208
+ }
209
+ }
@@ -1,6 +1,7 @@
1
1
  import { Pool } from "pg";
2
2
  import type { PoolConfig } from "pg";
3
3
  import {
4
+ assertNotPlatformTenant,
4
5
  UserTenantLinkStore,
5
6
  UserTenantLink,
6
7
  CreateUserTenantLinkRequest,
@@ -132,6 +133,7 @@ export class PostgreSQLUserTenantLinkStore implements UserTenantLinkStore {
132
133
  }
133
134
 
134
135
  async createLink(data: CreateUserTenantLinkRequest): Promise<UserTenantLink> {
136
+ assertNotPlatformTenant(data.tenantId);
135
137
  await this.ensureInitialized();
136
138
 
137
139
  const now = new Date();