@byline/db-postgres 3.10.1 → 3.11.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.
|
@@ -24,6 +24,15 @@ export declare class AuditQueries implements IAuditQueries {
|
|
|
24
24
|
page?: number;
|
|
25
25
|
page_size?: number;
|
|
26
26
|
}): Promise<AuditLogPage>;
|
|
27
|
+
findAuditLog(params: {
|
|
28
|
+
actorId?: string;
|
|
29
|
+
collectionId?: string;
|
|
30
|
+
action?: string;
|
|
31
|
+
from?: Date;
|
|
32
|
+
to?: Date;
|
|
33
|
+
page?: number;
|
|
34
|
+
page_size?: number;
|
|
35
|
+
}): Promise<AuditLogPage>;
|
|
27
36
|
}
|
|
28
37
|
export declare function createAuditQueries(db: DatabaseConnection): AuditQueries;
|
|
29
38
|
export {};
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { desc, eq, sql } from 'drizzle-orm';
|
|
9
|
-
import { auditLog } from '../../database/schema/index.js';
|
|
9
|
+
import { auditLog, documentVersions } from '../../database/schema/index.js';
|
|
10
10
|
function toEntry(row) {
|
|
11
11
|
return {
|
|
12
12
|
id: row.id,
|
|
@@ -49,6 +49,78 @@ export class AuditQueries {
|
|
|
49
49
|
meta: { total, page, pageSize, totalPages },
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
+
async findAuditLog(params) {
|
|
53
|
+
const page = params.page ?? 1;
|
|
54
|
+
const pageSize = params.page_size ?? 20;
|
|
55
|
+
const offset = (page - 1) * pageSize;
|
|
56
|
+
// The activity feed is the UNION of two disjoint event sources, normalised
|
|
57
|
+
// onto the audit-log column shape (see IAuditQueries.findAuditLog):
|
|
58
|
+
//
|
|
59
|
+
// 1. byline_document_versions — content saves. event_type maps to a
|
|
60
|
+
// `document.created` / `document.updated` action; created_by/created_at
|
|
61
|
+
// become actor_id/occurred_at; field/before/after are null. Restricted
|
|
62
|
+
// to create+update so any legacy 'delete' version rows can't surface
|
|
63
|
+
// (deletions live only in the audit log — the union double-counts
|
|
64
|
+
// nothing).
|
|
65
|
+
// 2. byline_audit_log — path/locale/status changes, deletions, and
|
|
66
|
+
// future admin-realm events, used as-is.
|
|
67
|
+
//
|
|
68
|
+
// Filters and ordering apply to the unioned result; occurred_at is the only
|
|
69
|
+
// cross-source sort key (the per-source UUIDv7 ids are separate sequences).
|
|
70
|
+
const union = sql `
|
|
71
|
+
SELECT id, document_id, collection_id, actor_id, actor_realm, action, field, before, after, occurred_at FROM (
|
|
72
|
+
SELECT
|
|
73
|
+
${documentVersions.id} AS id,
|
|
74
|
+
${documentVersions.document_id} AS document_id,
|
|
75
|
+
${documentVersions.collection_id} AS collection_id,
|
|
76
|
+
${documentVersions.created_by} AS actor_id,
|
|
77
|
+
CASE WHEN ${documentVersions.created_by} IS NULL THEN 'system' ELSE 'admin' END AS actor_realm,
|
|
78
|
+
CASE ${documentVersions.event_type}
|
|
79
|
+
WHEN 'create' THEN 'document.created'
|
|
80
|
+
WHEN 'update' THEN 'document.updated'
|
|
81
|
+
ELSE 'document.' || ${documentVersions.event_type}
|
|
82
|
+
END AS action,
|
|
83
|
+
NULL::varchar AS field,
|
|
84
|
+
NULL::jsonb AS before,
|
|
85
|
+
NULL::jsonb AS after,
|
|
86
|
+
${documentVersions.created_at} AS occurred_at
|
|
87
|
+
FROM ${documentVersions}
|
|
88
|
+
WHERE ${documentVersions.event_type} IN ('create', 'update')
|
|
89
|
+
UNION ALL
|
|
90
|
+
SELECT
|
|
91
|
+
${auditLog.id} AS id,
|
|
92
|
+
${auditLog.document_id} AS document_id,
|
|
93
|
+
${auditLog.collection_id} AS collection_id,
|
|
94
|
+
${auditLog.actor_id} AS actor_id,
|
|
95
|
+
${auditLog.actor_realm} AS actor_realm,
|
|
96
|
+
${auditLog.action} AS action,
|
|
97
|
+
${auditLog.field} AS field,
|
|
98
|
+
${auditLog.before} AS before,
|
|
99
|
+
${auditLog.after} AS after,
|
|
100
|
+
${auditLog.occurred_at} AS occurred_at
|
|
101
|
+
FROM ${auditLog}
|
|
102
|
+
) AS activity`;
|
|
103
|
+
const filters = [];
|
|
104
|
+
if (params.actorId)
|
|
105
|
+
filters.push(sql `actor_id = ${params.actorId}`);
|
|
106
|
+
if (params.collectionId)
|
|
107
|
+
filters.push(sql `collection_id = ${params.collectionId}`);
|
|
108
|
+
if (params.action)
|
|
109
|
+
filters.push(sql `action = ${params.action}`);
|
|
110
|
+
if (params.from)
|
|
111
|
+
filters.push(sql `occurred_at >= ${params.from}`);
|
|
112
|
+
if (params.to)
|
|
113
|
+
filters.push(sql `occurred_at <= ${params.to}`);
|
|
114
|
+
const whereClause = filters.length > 0 ? sql ` WHERE ${sql.join(filters, sql ` AND `)}` : sql ``;
|
|
115
|
+
const totalResult = await this.db.execute(sql `SELECT count(*) AS count FROM (${union}${whereClause}) AS filtered`);
|
|
116
|
+
const total = Number(totalResult.rows[0]?.count) || 0;
|
|
117
|
+
const totalPages = Math.ceil(total / pageSize);
|
|
118
|
+
const result = await this.db.execute(sql `${union}${whereClause} ORDER BY occurred_at DESC, id DESC LIMIT ${pageSize} OFFSET ${offset}`);
|
|
119
|
+
return {
|
|
120
|
+
entries: result.rows.map((row) => toEntry(row)),
|
|
121
|
+
meta: { total, page, pageSize, totalPages },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
52
124
|
}
|
|
53
125
|
export function createAuditQueries(db) {
|
|
54
126
|
return new AuditQueries(db);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Integration tests for the system-wide activity feed (docs/AUDIT.md — W4):
|
|
10
|
+
* `audit.findAuditLog`, the read-time UNION of the version stream (content
|
|
11
|
+
* saves → `document.created` / `document.updated`) and the audit log
|
|
12
|
+
* (everything else). Exercises both sources, the cross-source `occurred_at`
|
|
13
|
+
* ordering, every filter (actor / collection / action / date range), and
|
|
14
|
+
* pagination.
|
|
15
|
+
*
|
|
16
|
+
* The version-stream side requires real `collections` + `documents` FK rows,
|
|
17
|
+
* so the fixture seeds two collections and one document each before inserting
|
|
18
|
+
* version rows with controlled `created_at` timestamps.
|
|
19
|
+
*/
|
|
20
|
+
import { v7 as uuidv7 } from 'uuid';
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
22
|
+
import { collections, documents, documentVersions } from '../../../database/schema/index.js';
|
|
23
|
+
import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
|
|
24
|
+
import { createAuditCommands } from '../audit-commands.js';
|
|
25
|
+
import { createAuditQueries } from '../audit-queries.js';
|
|
26
|
+
let auditCommands;
|
|
27
|
+
let auditQueries;
|
|
28
|
+
let db;
|
|
29
|
+
const colA = uuidv7();
|
|
30
|
+
const colB = uuidv7();
|
|
31
|
+
const docA = uuidv7();
|
|
32
|
+
const docB = uuidv7();
|
|
33
|
+
const actor1 = uuidv7();
|
|
34
|
+
const actor2 = uuidv7();
|
|
35
|
+
// Fixed version-save timestamps in the past, so the (now()-stamped) audit rows
|
|
36
|
+
// always sort ahead of them and the version rows have a deterministic order.
|
|
37
|
+
const t1 = new Date('2026-01-01T10:00:00.000Z'); // docA create
|
|
38
|
+
const t2 = new Date('2026-01-02T10:00:00.000Z'); // docB create
|
|
39
|
+
const t3 = new Date('2026-01-03T10:00:00.000Z'); // docA update
|
|
40
|
+
describe('audit findAuditLog (system activity union)', () => {
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
const testDB = setupTestDB([]);
|
|
43
|
+
db = testDB.db;
|
|
44
|
+
auditCommands = createAuditCommands(testDB.dbManager);
|
|
45
|
+
auditQueries = createAuditQueries(testDB.db);
|
|
46
|
+
// Collection + document FK fixtures for the version-stream side.
|
|
47
|
+
await db.insert(collections).values([
|
|
48
|
+
{
|
|
49
|
+
id: colA,
|
|
50
|
+
path: 'articles',
|
|
51
|
+
singular: 'Article',
|
|
52
|
+
plural: 'Articles',
|
|
53
|
+
config: {},
|
|
54
|
+
version: 1,
|
|
55
|
+
},
|
|
56
|
+
{ id: colB, path: 'pages', singular: 'Page', plural: 'Pages', config: {}, version: 1 },
|
|
57
|
+
]);
|
|
58
|
+
await db.insert(documents).values([
|
|
59
|
+
{ id: docA, collection_id: colA, source_locale: 'en' },
|
|
60
|
+
{ id: docB, collection_id: colB, source_locale: 'en' },
|
|
61
|
+
]);
|
|
62
|
+
// Version stream: two creates and one update. event_type drives the
|
|
63
|
+
// synthesised action; created_by/created_at become actor/occurred_at.
|
|
64
|
+
await db.insert(documentVersions).values([
|
|
65
|
+
{
|
|
66
|
+
id: uuidv7(),
|
|
67
|
+
document_id: docA,
|
|
68
|
+
collection_id: colA,
|
|
69
|
+
collection_version: 1,
|
|
70
|
+
event_type: 'create',
|
|
71
|
+
status: 'draft',
|
|
72
|
+
created_by: actor1,
|
|
73
|
+
created_at: t1,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: uuidv7(),
|
|
77
|
+
document_id: docB,
|
|
78
|
+
collection_id: colB,
|
|
79
|
+
collection_version: 1,
|
|
80
|
+
event_type: 'create',
|
|
81
|
+
status: 'draft',
|
|
82
|
+
created_by: actor2,
|
|
83
|
+
created_at: t2,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: uuidv7(),
|
|
87
|
+
document_id: docA,
|
|
88
|
+
collection_id: colA,
|
|
89
|
+
collection_version: 1,
|
|
90
|
+
event_type: 'update',
|
|
91
|
+
status: 'draft',
|
|
92
|
+
created_by: actor1,
|
|
93
|
+
created_at: t3,
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
// Audit log: a status change on docA (actor1) and a system deletion of docB.
|
|
97
|
+
await auditCommands.append({
|
|
98
|
+
documentId: docA,
|
|
99
|
+
collectionId: colA,
|
|
100
|
+
actorId: actor1,
|
|
101
|
+
actorRealm: 'admin',
|
|
102
|
+
action: 'document.status.changed',
|
|
103
|
+
field: 'status',
|
|
104
|
+
before: 'draft',
|
|
105
|
+
after: 'published',
|
|
106
|
+
});
|
|
107
|
+
await auditCommands.append({
|
|
108
|
+
documentId: docB,
|
|
109
|
+
collectionId: colB,
|
|
110
|
+
actorId: null,
|
|
111
|
+
actorRealm: 'system',
|
|
112
|
+
action: 'document.deleted',
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
afterAll(async () => {
|
|
116
|
+
await teardownTestDB();
|
|
117
|
+
});
|
|
118
|
+
it('unions version-stream + audit-log rows, newest first', async () => {
|
|
119
|
+
const page = await auditQueries.findAuditLog({});
|
|
120
|
+
expect(page.meta.total).toBe(5); // 3 version rows + 2 audit rows
|
|
121
|
+
// Audit rows are now()-stamped, so they lead; version rows follow by
|
|
122
|
+
// created_at DESC (t3 update, t2 docB create, t1 docA create).
|
|
123
|
+
const actions = page.entries.map((e) => e.action);
|
|
124
|
+
expect(actions.slice(-3)).toEqual([
|
|
125
|
+
'document.updated', // t3
|
|
126
|
+
'document.created', // t2 (docB)
|
|
127
|
+
'document.created', // t1 (docA)
|
|
128
|
+
]);
|
|
129
|
+
// A content-save row surfaces with the synthesised action, its actor, and
|
|
130
|
+
// no field/before/after diff.
|
|
131
|
+
const created = page.entries.find((e) => e.action === 'document.created' && e.documentId === docA);
|
|
132
|
+
expect(created?.actorId).toBe(actor1);
|
|
133
|
+
expect(created?.actorRealm).toBe('admin');
|
|
134
|
+
expect(created?.field).toBeNull();
|
|
135
|
+
expect(created?.before).toBeNull();
|
|
136
|
+
expect(created?.after).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
it('filters by action', async () => {
|
|
139
|
+
const created = await auditQueries.findAuditLog({ action: 'document.created' });
|
|
140
|
+
expect(created.meta.total).toBe(2);
|
|
141
|
+
expect(created.entries.every((e) => e.action === 'document.created')).toBe(true);
|
|
142
|
+
const updated = await auditQueries.findAuditLog({ action: 'document.updated' });
|
|
143
|
+
expect(updated.meta.total).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
it('filters by collection across both sources', async () => {
|
|
146
|
+
// colA: docA create + docA update (versions) + status change (audit) = 3.
|
|
147
|
+
const page = await auditQueries.findAuditLog({ collectionId: colA });
|
|
148
|
+
expect(page.meta.total).toBe(3);
|
|
149
|
+
expect(page.entries.every((e) => e.collectionId === colA)).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
it('filters by actor across both sources', async () => {
|
|
152
|
+
// actor1: docA create + docA update (versions) + status change (audit) = 3.
|
|
153
|
+
const page = await auditQueries.findAuditLog({ actorId: actor1 });
|
|
154
|
+
expect(page.meta.total).toBe(3);
|
|
155
|
+
expect(page.entries.every((e) => e.actorId === actor1)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
it('filters by date range on occurred_at', async () => {
|
|
158
|
+
// Only the version rows fall in the fixed January window; the audit rows
|
|
159
|
+
// are now()-stamped and excluded.
|
|
160
|
+
const page = await auditQueries.findAuditLog({
|
|
161
|
+
from: new Date('2026-01-01T00:00:00.000Z'),
|
|
162
|
+
to: new Date('2026-01-31T23:59:59.000Z'),
|
|
163
|
+
});
|
|
164
|
+
expect(page.meta.total).toBe(3);
|
|
165
|
+
expect(page.entries.every((e) => e.action.startsWith('document.created') || e.action === 'document.updated')).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
it('paginates', async () => {
|
|
168
|
+
const page1 = await auditQueries.findAuditLog({ page: 1, page_size: 2 });
|
|
169
|
+
expect(page1.entries).toHaveLength(2);
|
|
170
|
+
expect(page1.meta.total).toBe(5);
|
|
171
|
+
expect(page1.meta.totalPages).toBe(3);
|
|
172
|
+
const page3 = await auditQueries.findAuditLog({ page: 3, page_size: 2 });
|
|
173
|
+
expect(page3.entries).toHaveLength(1);
|
|
174
|
+
});
|
|
175
|
+
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/db-postgres",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.11.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
"pg": "^8.21.0",
|
|
58
58
|
"uuid": "^14.0.0",
|
|
59
59
|
"zod": "^4.4.3",
|
|
60
|
-
"@byline/admin": "3.
|
|
61
|
-
"@byline/auth": "3.
|
|
62
|
-
"@byline/core": "3.
|
|
60
|
+
"@byline/admin": "3.11.0",
|
|
61
|
+
"@byline/auth": "3.11.0",
|
|
62
|
+
"@byline/core": "3.11.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@biomejs/biome": "2.4.15",
|