@forestadmin/agent 1.95.2 → 1.96.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/dist/agent.d.ts +4 -0
- package/dist/agent.js +56 -5
- package/dist/audit-trail/action-capture.d.ts +17 -0
- package/dist/audit-trail/action-capture.js +97 -0
- package/dist/audit-trail/index.d.ts +8 -0
- package/dist/audit-trail/index.js +44 -0
- package/dist/audit-trail/instrument.d.ts +12 -0
- package/dist/audit-trail/instrument.js +393 -0
- package/dist/audit-trail/migrations.d.ts +34 -0
- package/dist/audit-trail/migrations.js +213 -0
- package/dist/audit-trail/revert.d.ts +8 -0
- package/dist/audit-trail/revert.js +81 -0
- package/dist/audit-trail/scope.d.ts +20 -0
- package/dist/audit-trail/scope.js +33 -0
- package/dist/audit-trail/sql-store.d.ts +30 -0
- package/dist/audit-trail/sql-store.js +316 -0
- package/dist/audit-trail/types.d.ts +129 -0
- package/dist/audit-trail/types.js +3 -0
- package/dist/routes/access/audit-trail-correlation.d.ts +18 -0
- package/dist/routes/access/audit-trail-correlation.js +84 -0
- package/dist/routes/access/audit-trail.d.ts +23 -0
- package/dist/routes/access/audit-trail.js +293 -0
- package/dist/routes/capabilities.js +5 -1
- package/dist/routes/index.js +14 -1
- package/dist/routes/modification/action/action.d.ts +3 -0
- package/dist/routes/modification/action/action.js +106 -2
- package/dist/types.d.ts +39 -1
- package/dist/types.js +1 -1
- package/dist/utils/correlation-id.d.ts +5 -0
- package/dist/utils/correlation-id.js +26 -0
- package/dist/utils/options-validator.js +19 -1
- package/dist/utils/query-string.d.ts +3 -1
- package/dist/utils/query-string.js +5 -5
- package/package.json +4 -3
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildUmzug = buildUmzug;
|
|
4
|
+
exports.isSchemaAlreadyExistsError = isSchemaAlreadyExistsError;
|
|
5
|
+
exports.ensureSchema = ensureSchema;
|
|
6
|
+
exports.runAuditMigrations = runAuditMigrations;
|
|
7
|
+
const sequelize_1 = require("sequelize");
|
|
8
|
+
const umzug_1 = require("umzug");
|
|
9
|
+
// Arbitrary but stable pair, only needs to be unique across the advisory locks the application
|
|
10
|
+
// takes — the audit-trail migration is the only critical section we hold this lock for.
|
|
11
|
+
const ADVISORY_LOCK = [0x464f, 0x5254];
|
|
12
|
+
// Scoped by `tableName` (not a single fixed name) so two stores configured with different table
|
|
13
|
+
// names in the same schema don't share migration state — otherwise Umzug would see the second
|
|
14
|
+
// store's migrations as already applied and never create its table.
|
|
15
|
+
function migrationsTableName(tableName) {
|
|
16
|
+
return `${tableName}_migration`;
|
|
17
|
+
}
|
|
18
|
+
// Qualifies the migration name with the schema/table it targets, so a name is self-descriptive
|
|
19
|
+
// when read directly out of the migrations table — e.g. `forest.audit_logs:001-create-audit-logs`.
|
|
20
|
+
function qualifiedMigrationName(schema, tableName) {
|
|
21
|
+
return schema
|
|
22
|
+
? `${schema}.${tableName}:001-create-audit-logs`
|
|
23
|
+
: `${tableName}:001-create-audit-logs`;
|
|
24
|
+
}
|
|
25
|
+
// Every table name claimed by an audit-trail store configured in this process, keyed by
|
|
26
|
+
// `schema\0name` — both its own data table and the migration table Umzug derives from it.
|
|
27
|
+
// A second store's data table can otherwise land on the exact name a first store's migration
|
|
28
|
+
// table derives to (e.g. `tableName: 'audit'` derives `audit_migration`, which collides with a
|
|
29
|
+
// second store's `tableName: 'audit_migration'`), silently adopting that tracking table as its
|
|
30
|
+
// own audit table and aborting startup via assertOwnsTable — confusing, since neither store's
|
|
31
|
+
// configuration is wrong in isolation. Catching it here, at store-creation time, names the actual
|
|
32
|
+
// collision instead of leaving it to that unrelated-looking failure deep inside migration 001.
|
|
33
|
+
const claimedTableNames = new Map();
|
|
34
|
+
function claimTableName(schema, name, claimedBy) {
|
|
35
|
+
const key = `${schema ?? ''}\0${name}`;
|
|
36
|
+
const existingOwner = claimedTableNames.get(key);
|
|
37
|
+
if (existingOwner !== undefined && existingOwner !== claimedBy) {
|
|
38
|
+
const target = schema ? `${schema}.${name}` : name;
|
|
39
|
+
throw new Error(`[ForestAdmin] Audit trail: "${target}" is claimed by both "tableName: ${claimedBy}" and ` +
|
|
40
|
+
`"tableName: ${existingOwner}" (as either store's own table, or the migration-tracking ` +
|
|
41
|
+
'table Umzug derives from it) — use distinct "tableName" values for each store.');
|
|
42
|
+
}
|
|
43
|
+
claimedTableNames.set(key, claimedBy);
|
|
44
|
+
}
|
|
45
|
+
async function indexNames(queryInterface, table, transaction) {
|
|
46
|
+
const indexes = (await queryInterface.showIndex(table, { transaction }));
|
|
47
|
+
return new Set(indexes.map(index => index.name));
|
|
48
|
+
}
|
|
49
|
+
async function columnNames(queryInterface, table) {
|
|
50
|
+
const columns = await queryInterface.describeTable(table);
|
|
51
|
+
return new Set(Object.keys(columns));
|
|
52
|
+
}
|
|
53
|
+
// The migration's own idempotency check (`tableExists`) only asks "is there a table with this
|
|
54
|
+
// name", not "is it ours" — so an unrelated customer table happening to share the name would be
|
|
55
|
+
// silently adopted. Verifying every column this migration creates catches that.
|
|
56
|
+
const AUDIT_LOG_COLUMNS = [
|
|
57
|
+
'id',
|
|
58
|
+
'timestamp',
|
|
59
|
+
'operation',
|
|
60
|
+
'collection',
|
|
61
|
+
'record_id',
|
|
62
|
+
'user_id',
|
|
63
|
+
'user_first_name',
|
|
64
|
+
'user_last_name',
|
|
65
|
+
'user_email',
|
|
66
|
+
'action_name',
|
|
67
|
+
'correlation_key',
|
|
68
|
+
'previous_values',
|
|
69
|
+
'new_values',
|
|
70
|
+
'status',
|
|
71
|
+
];
|
|
72
|
+
async function assertOwnsTable(queryInterface, table) {
|
|
73
|
+
const existing = await columnNames(queryInterface, table);
|
|
74
|
+
const missing = AUDIT_LOG_COLUMNS.filter(column => !existing.has(column));
|
|
75
|
+
if (missing.length > 0) {
|
|
76
|
+
const target = table.schema ? `${table.schema}.${table.tableName}` : table.tableName;
|
|
77
|
+
throw new Error(`[ForestAdmin] Audit trail: "${target}" already exists but is missing column(s) ` +
|
|
78
|
+
`${missing.join(', ')} — it does not look like a Forest audit-trail table. Point ` +
|
|
79
|
+
`auditTrail at a different table via the "tableName" option instead of reusing this one.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function ensureIndexes(queryInterface, table, transaction) {
|
|
83
|
+
const existing = await indexNames(queryInterface, table, transaction);
|
|
84
|
+
const ensureIndex = async (fields, name) => {
|
|
85
|
+
if (existing.has(name))
|
|
86
|
+
return;
|
|
87
|
+
await queryInterface.addIndex(table, { fields, name, transaction });
|
|
88
|
+
};
|
|
89
|
+
// Length-prefixed so it stays valid on MySQL/MariaDB, which cannot index an unbounded TEXT
|
|
90
|
+
// column directly — `record_id` is TEXT (not VARCHAR) since a packed composite id can be long.
|
|
91
|
+
await ensureIndex([{ name: 'record_id', length: 191 }], `${table.tableName}_record_id`);
|
|
92
|
+
await ensureIndex(['correlation_key'], `${table.tableName}_correlation_key`);
|
|
93
|
+
await ensureIndex(['user_id'], `${table.tableName}_user_id`);
|
|
94
|
+
}
|
|
95
|
+
function buildMigrations(schema, tableName) {
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
name: qualifiedMigrationName(schema, tableName),
|
|
99
|
+
up: async ({ context }) => {
|
|
100
|
+
const table = { tableName: context.tableName, schema: context.schema };
|
|
101
|
+
// Idempotent: lets a process that loses a concurrent-boot race retry cleanly instead of
|
|
102
|
+
// failing on a "relation already exists" error from the winner's DDL. Guards against
|
|
103
|
+
// reusing an unrelated table sharing the name, too — see assertOwnsTable.
|
|
104
|
+
if (await context.queryInterface.tableExists(table, { transaction: context.transaction })) {
|
|
105
|
+
await assertOwnsTable(context.queryInterface, table);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await context.queryInterface.createTable(table, {
|
|
109
|
+
id: { type: sequelize_1.DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
|
|
110
|
+
// Millisecond precision: a bare DATE truncates to whole seconds on MySQL, silently
|
|
111
|
+
// dropping the fractional part the `/state` inclusive boundary and the `id` ordering
|
|
112
|
+
// tiebreaker both rely on.
|
|
113
|
+
timestamp: { type: sequelize_1.DataTypes.DATE(3), allowNull: false },
|
|
114
|
+
operation: { type: sequelize_1.DataTypes.STRING, allowNull: false },
|
|
115
|
+
collection: { type: sequelize_1.DataTypes.STRING, allowNull: false },
|
|
116
|
+
// Nullable: a pending create's row has no id yet, since the record doesn't exist
|
|
117
|
+
// until the write resolves.
|
|
118
|
+
record_id: { type: sequelize_1.DataTypes.TEXT, allowNull: true },
|
|
119
|
+
user_id: { type: sequelize_1.DataTypes.INTEGER, allowNull: true },
|
|
120
|
+
// Denormalised from the caller at write time — who acted then, not who holds that id
|
|
121
|
+
// today.
|
|
122
|
+
user_first_name: { type: sequelize_1.DataTypes.TEXT, allowNull: true },
|
|
123
|
+
user_last_name: { type: sequelize_1.DataTypes.TEXT, allowNull: true },
|
|
124
|
+
user_email: { type: sequelize_1.DataTypes.TEXT, allowNull: true },
|
|
125
|
+
// `action` / `action_failed` rows only.
|
|
126
|
+
action_name: { type: sequelize_1.DataTypes.TEXT, allowNull: true },
|
|
127
|
+
correlation_key: { type: sequelize_1.DataTypes.STRING, allowNull: true },
|
|
128
|
+
previous_values: { type: sequelize_1.DataTypes.JSON, allowNull: false, defaultValue: {} },
|
|
129
|
+
new_values: { type: sequelize_1.DataTypes.JSON, allowNull: false, defaultValue: {} },
|
|
130
|
+
// A row is inserted `pending` before the write runs and confirmed `done` after.
|
|
131
|
+
status: { type: sequelize_1.DataTypes.STRING, allowNull: false, defaultValue: 'done' },
|
|
132
|
+
}, { transaction: context.transaction });
|
|
133
|
+
await ensureIndexes(context.queryInterface, table, context.transaction);
|
|
134
|
+
},
|
|
135
|
+
down: async ({ context }) => {
|
|
136
|
+
await context.queryInterface.dropTable({ tableName: context.tableName, schema: context.schema }, { transaction: context.transaction });
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
function buildUmzug(sequelize, options) {
|
|
142
|
+
claimTableName(options.schema, options.tableName, options.tableName);
|
|
143
|
+
claimTableName(options.schema, migrationsTableName(options.tableName), options.tableName);
|
|
144
|
+
return new umzug_1.Umzug({
|
|
145
|
+
migrations: buildMigrations(options.schema, options.tableName),
|
|
146
|
+
context: {
|
|
147
|
+
queryInterface: sequelize.getQueryInterface(),
|
|
148
|
+
schema: options.schema,
|
|
149
|
+
tableName: options.tableName,
|
|
150
|
+
transaction: options.transaction,
|
|
151
|
+
},
|
|
152
|
+
storage: new umzug_1.SequelizeStorage({
|
|
153
|
+
sequelize,
|
|
154
|
+
schema: options.schema,
|
|
155
|
+
tableName: migrationsTableName(options.tableName),
|
|
156
|
+
// Sequelize caches model definitions by name on the `Sequelize` instance: without a distinct
|
|
157
|
+
// name here, a second store sharing that instance (a different `tableName`, same connection)
|
|
158
|
+
// would reuse the first store's model and read/write the wrong migration-state table.
|
|
159
|
+
modelName: migrationsTableName(options.tableName),
|
|
160
|
+
}),
|
|
161
|
+
logger: undefined,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// 42P06 = duplicate_schema (Postgres ≥ 9.2), 23505 = unique_violation on pg_namespace.
|
|
165
|
+
const SCHEMA_ALREADY_EXISTS_CODES = new Set(['42P06', '23505']);
|
|
166
|
+
function isSchemaAlreadyExistsError(error) {
|
|
167
|
+
const code = error?.original?.code;
|
|
168
|
+
return code !== undefined && SCHEMA_ALREADY_EXISTS_CODES.has(code);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Idempotent CREATE SCHEMA. Must run (and commit) on its own before Umzug, because Umzug's storage
|
|
172
|
+
* opens its own connection to create the meta table and would not see a CREATE SCHEMA still
|
|
173
|
+
* pending in a transaction. Not covered by the advisory lock; the existence check + tolerated
|
|
174
|
+
* "already exists" error makes it safe under concurrent boots instead.
|
|
175
|
+
*/
|
|
176
|
+
async function ensureSchema(sequelize, schema) {
|
|
177
|
+
if (!schema)
|
|
178
|
+
return;
|
|
179
|
+
const queryInterface = sequelize.getQueryInterface();
|
|
180
|
+
const schemas = (await queryInterface.showAllSchemas());
|
|
181
|
+
if (schemas.includes(schema))
|
|
182
|
+
return;
|
|
183
|
+
try {
|
|
184
|
+
await queryInterface.createSchema(schema);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (!isSchemaAlreadyExistsError(error))
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Creates the schema then applies every pending audit-table migration. On Postgres the migrations
|
|
193
|
+
* run inside a transaction-scoped advisory lock, and every DDL statement runs on that same
|
|
194
|
+
* transaction, so concurrent agent boots serialize on the DDL and a failure partway through a
|
|
195
|
+
* migration rolls back cleanly instead of leaving partial DDL behind. Other dialects have no
|
|
196
|
+
* equivalent distributed lock; each migration instead checks what already exists before creating
|
|
197
|
+
* it, which closes most — but not all — of the race window on a truly simultaneous boot.
|
|
198
|
+
*/
|
|
199
|
+
async function runAuditMigrations(sequelize, options) {
|
|
200
|
+
await ensureSchema(sequelize, options.schema);
|
|
201
|
+
if (sequelize.getDialect() !== 'postgres') {
|
|
202
|
+
await buildUmzug(sequelize, options).up();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
await sequelize.transaction(async (transaction) => {
|
|
206
|
+
await sequelize.query('SELECT pg_advisory_xact_lock(:a, :b)', {
|
|
207
|
+
transaction,
|
|
208
|
+
replacements: { a: ADVISORY_LOCK[0], b: ADVISORY_LOCK[1] },
|
|
209
|
+
});
|
|
210
|
+
await buildUmzug(sequelize, { ...options, transaction }).up();
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibWlncmF0aW9ucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9hdWRpdC10cmFpbC9taWdyYXRpb25zLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7O0FBc01BLGdDQTBCQztBQUtELGdFQUlDO0FBUUQsb0NBZ0JDO0FBVUQsZ0RBb0JDO0FBN1JELHlDQUFzQztBQUN0QyxpQ0FBZ0Q7QUFTaEQsK0ZBQStGO0FBQy9GLHdGQUF3RjtBQUN4RixNQUFNLGFBQWEsR0FBOEIsQ0FBQyxNQUFNLEVBQUUsTUFBTSxDQUFDLENBQUM7QUFFbEUsZ0dBQWdHO0FBQ2hHLDhGQUE4RjtBQUM5RixvRUFBb0U7QUFDcEUsU0FBUyxtQkFBbUIsQ0FBQyxTQUFpQjtJQUM1QyxPQUFPLEdBQUcsU0FBUyxZQUFZLENBQUM7QUFDbEMsQ0FBQztBQUVELCtGQUErRjtBQUMvRixtR0FBbUc7QUFDbkcsU0FBUyxzQkFBc0IsQ0FBQyxNQUEwQixFQUFFLFNBQWlCO0lBQzNFLE9BQU8sTUFBTTtRQUNYLENBQUMsQ0FBQyxHQUFHLE1BQU0sSUFBSSxTQUFTLHdCQUF3QjtRQUNoRCxDQUFDLENBQUMsR0FBRyxTQUFTLHdCQUF3QixDQUFDO0FBQzNDLENBQUM7QUFFRCx3RkFBd0Y7QUFDeEYsMEZBQTBGO0FBQzFGLDZGQUE2RjtBQUM3RiwrRkFBK0Y7QUFDL0YsK0ZBQStGO0FBQy9GLDhGQUE4RjtBQUM5RixrR0FBa0c7QUFDbEcsK0ZBQStGO0FBQy9GLE1BQU0saUJBQWlCLEdBQUcsSUFBSSxHQUFHLEVBQWtCLENBQUM7QUFFcEQsU0FBUyxjQUFjLENBQUMsTUFBMEIsRUFBRSxJQUFZLEVBQUUsU0FBaUI7SUFDakYsTUFBTSxHQUFHLEdBQUcsR0FBRyxNQUFNLElBQUksRUFBRSxLQUFLLElBQUksRUFBRSxDQUFDO0lBQ3ZDLE1BQU0sYUFBYSxHQUFHLGlCQUFpQixDQUFDLEdBQUcsQ0FBQyxHQUFHLENBQUMsQ0FBQztJQUVqRCxJQUFJLGFBQWEsS0FBSyxTQUFTLElBQUksYUFBYSxLQUFLLFNBQVMsRUFBRSxDQUFDO1FBQy9ELE1BQU0sTUFBTSxHQUFHLE1BQU0sQ0FBQyxDQUFDLENBQUMsR0FBRyxNQUFNLElBQUksSUFBSSxFQUFFLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQztRQUVuRCxNQUFNLElBQUksS0FBSyxDQUNiLCtCQUErQixNQUFNLG9DQUFvQyxTQUFTLFFBQVE7WUFDeEYsZUFBZSxhQUFhLDREQUE0RDtZQUN4RixnRkFBZ0YsQ0FDbkYsQ0FBQztJQUNKLENBQUM7SUFFRCxpQkFBaUIsQ0FBQyxHQUFHLENBQUMsR0FBRyxFQUFFLFNBQVMsQ0FBQyxDQUFDO0FBQ3hDLENBQUM7QUFFRCxLQUFLLFVBQVUsVUFBVSxDQUN2QixjQUE4QixFQUM5QixLQUE2QyxFQUM3QyxXQUF5QjtJQUV6QixNQUFNLE9BQU8sR0FBRyxDQUFDLE1BQU0sY0FBYyxDQUFDLFNBQVMsQ0FBQyxLQUFLLEVBQUUsRUFBRSxXQUFXLEVBQUUsQ0FBQyxDQUVyRSxDQUFDO0lBRUgsT0FBTyxJQUFJLEdBQUcsQ0FBQyxPQUFPLENBQUMsR0FBRyxDQUFDLEtBQUssQ0FBQyxFQUFFLENBQUMsS0FBSyxDQUFDLElBQUksQ0FBQyxDQUFDLENBQUM7QUFDbkQsQ0FBQztBQUVELEtBQUssVUFBVSxXQUFXLENBQ3hCLGNBQThCLEVBQzlCLEtBQTZDO0lBRTdDLE1BQU0sT0FBTyxHQUFHLE1BQU0sY0FBYyxDQUFDLGFBQWEsQ0FBQyxLQUFLLENBQUMsQ0FBQztJQUUxRCxPQUFPLElBQUksR0FBRyxDQUFDLE1BQU0sQ0FBQyxJQUFJLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQztBQUN2QyxDQUFDO0FBRUQsOEZBQThGO0FBQzlGLGdHQUFnRztBQUNoRyxnRkFBZ0Y7QUFDaEYsTUFBTSxpQkFBaUIsR0FBRztJQUN4QixJQUFJO0lBQ0osV0FBVztJQUNYLFdBQVc7SUFDWCxZQUFZO0lBQ1osV0FBVztJQUNYLFNBQVM7SUFDVCxpQkFBaUI7SUFDakIsZ0JBQWdCO0lBQ2hCLFlBQVk7SUFDWixhQUFhO0lBQ2IsaUJBQWlCO0lBQ2pCLGlCQUFpQjtJQUNqQixZQUFZO0lBQ1osUUFBUTtDQUNULENBQUM7QUFFRixLQUFLLFVBQVUsZUFBZSxDQUM1QixjQUE4QixFQUM5QixLQUE2QztJQUU3QyxNQUFNLFFBQVEsR0FBRyxNQUFNLFdBQVcsQ0FBQyxjQUFjLEVBQUUsS0FBSyxDQUFDLENBQUM7SUFDMUQsTUFBTSxPQUFPLEdBQUcsaUJBQWlCLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBQyxFQUFFLENBQUMsQ0FBQyxRQUFRLENBQUMsR0FBRyxDQUFDLE1BQU0sQ0FBQyxDQUFDLENBQUM7SUFFMUUsSUFBSSxPQUFPLENBQUMsTUFBTSxHQUFHLENBQUMsRUFBRSxDQUFDO1FBQ3ZCLE1BQU0sTUFBTSxHQUFHLEtBQUssQ0FBQyxNQUFNLENBQUMsQ0FBQyxDQUFDLEdBQUcsS0FBSyxDQUFDLE1BQU0sSUFBSSxLQUFLLENBQUMsU0FBUyxFQUFFLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxTQUFTLENBQUM7UUFFckYsTUFBTSxJQUFJLEtBQUssQ0FDYiwrQkFBK0IsTUFBTSw0Q0FBNEM7WUFDL0UsR0FBRyxPQUFPLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyw2REFBNkQ7WUFDbEYseUZBQXlGLENBQzVGLENBQUM7SUFDSixDQUFDO0FBQ0gsQ0FBQztBQUVELEtBQUssVUFBVSxhQUFhLENBQzFCLGNBQThCLEVBQzlCLEtBQTZDLEVBQzdDLFdBQXlCO0lBRXpCLE1BQU0sUUFBUSxHQUFHLE1BQU0sVUFBVSxDQUFDLGNBQWMsRUFBRSxLQUFLLEVBQUUsV0FBVyxDQUFDLENBQUM7SUFFdEUsTUFBTSxXQUFXLEdBQUcsS0FBSyxFQUN2QixNQUF3RCxFQUN4RCxJQUFZLEVBQ1osRUFBRTtRQUNGLElBQUksUUFBUSxDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUM7WUFBRSxPQUFPO1FBRS9CLE1BQU0sY0FBYyxDQUFDLFFBQVEsQ0FBQyxLQUFLLEVBQUUsRUFBRSxNQUFNLEVBQUUsSUFBSSxFQUFFLFdBQVcsRUFBRSxDQUFDLENBQUM7SUFDdEUsQ0FBQyxDQUFDO0lBRUYsMkZBQTJGO0lBQzNGLCtGQUErRjtJQUMvRixNQUFNLFdBQVcsQ0FBQyxDQUFDLEVBQUUsSUFBSSxFQUFFLFdBQVcsRUFBRSxNQUFNLEVBQUUsR0FBRyxFQUFFLENBQUMsRUFBRSxHQUFHLEtBQUssQ0FBQyxTQUFTLFlBQVksQ0FBQyxDQUFDO0lBQ3hGLE1BQU0sV0FBVyxDQUFDLENBQUMsaUJBQWlCLENBQUMsRUFBRSxHQUFHLEtBQUssQ0FBQyxTQUFTLGtCQUFrQixDQUFDLENBQUM7SUFDN0UsTUFBTSxXQUFXLENBQUMsQ0FBQyxTQUFTLENBQUMsRUFBRSxHQUFHLEtBQUssQ0FBQyxTQUFTLFVBQVUsQ0FBQyxDQUFDO0FBQy9ELENBQUM7QUFFRCxTQUFTLGVBQWUsQ0FBQyxNQUEwQixFQUFFLFNBQWlCO0lBQ3BFLE9BQU87UUFDTDtZQUNFLElBQUksRUFBRSxzQkFBc0IsQ0FBQyxNQUFNLEVBQUUsU0FBUyxDQUFDO1lBQy9DLEVBQUUsRUFBRSxLQUFLLEVBQUUsRUFBRSxPQUFPLEVBQWlDLEVBQUUsRUFBRTtnQkFDdkQsTUFBTSxLQUFLLEdBQUcsRUFBRSxTQUFTLEVBQUUsT0FBTyxDQUFDLFNBQVMsRUFBRSxNQUFNLEVBQUUsT0FBTyxDQUFDLE1BQU0sRUFBRSxDQUFDO2dCQUV2RSx3RkFBd0Y7Z0JBQ3hGLHFGQUFxRjtnQkFDckYsMEVBQTBFO2dCQUMxRSxJQUFJLE1BQU0sT0FBTyxDQUFDLGNBQWMsQ0FBQyxXQUFXLENBQUMsS0FBSyxFQUFFLEVBQUUsV0FBVyxFQUFFLE9BQU8sQ0FBQyxXQUFXLEVBQUUsQ0FBQyxFQUFFLENBQUM7b0JBQzFGLE1BQU0sZUFBZSxDQUFDLE9BQU8sQ0FBQyxjQUFjLEVBQUUsS0FBSyxDQUFDLENBQUM7b0JBRXJELE9BQU87Z0JBQ1QsQ0FBQztnQkFFRCxNQUFNLE9BQU8sQ0FBQyxjQUFjLENBQUMsV0FBVyxDQUN0QyxLQUFLLEVBQ0w7b0JBQ0UsRUFBRSxFQUFFLEVBQUUsSUFBSSxFQUFFLHFCQUFTLENBQUMsTUFBTSxFQUFFLFVBQVUsRUFBRSxJQUFJLEVBQUUsYUFBYSxFQUFFLElBQUksRUFBRTtvQkFDckUsbUZBQW1GO29CQUNuRixxRkFBcUY7b0JBQ3JGLDJCQUEyQjtvQkFDM0IsU0FBUyxFQUFFLEVBQUUsSUFBSSxFQUFFLHFCQUFTLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxFQUFFLFNBQVMsRUFBRSxLQUFLLEVBQUU7b0JBQ3hELFNBQVMsRUFBRSxFQUFFLElBQUksRUFBRSxxQkFBUyxDQUFDLE1BQU0sRUFBRSxTQUFTLEVBQUUsS0FBSyxFQUFFO29CQUN2RCxVQUFVLEVBQUUsRUFBRSxJQUFJLEVBQUUscUJBQVMsQ0FBQyxNQUFNLEVBQUUsU0FBUyxFQUFFLEtBQUssRUFBRTtvQkFDeEQsaUZBQWlGO29CQUNqRiw0QkFBNEI7b0JBQzVCLFNBQVMsRUFBRSxFQUFFLElBQUksRUFBRSxxQkFBUyxDQUFDLElBQUksRUFBRSxTQUFTLEVBQUUsSUFBSSxFQUFFO29CQUNwRCxPQUFPLEVBQUUsRUFBRSxJQUFJLEVBQUUscUJBQVMsQ0FBQyxPQUFPLEVBQUUsU0FBUyxFQUFFLElBQUksRUFBRTtvQkFDckQscUZBQXFGO29CQUNyRixTQUFTO29CQUNULGVBQWUsRUFBRSxFQUFFLElBQUksRUFBRSxxQkFBUyxDQUFDLElBQUksRUFBRSxTQUFTLEVBQUUsSUFBSSxFQUFFO29CQUMxRCxjQUFjLEVBQUUsRUFBRSxJQUFJLEVBQUUscUJBQVMsQ0FBQyxJQUFJLEVBQUUsU0FBUyxFQUFFLElBQUksRUFBRTtvQkFDekQsVUFBVSxFQUFFLEVBQUUsSUFBSSxFQUFFLHFCQUFTLENBQUMsSUFBSSxFQUFFLFNBQVMsRUFBRSxJQUFJLEVBQUU7b0JBQ3JELHdDQUF3QztvQkFDeEMsV0FBVyxFQUFFLEVBQUUsSUFBSSxFQUFFLHFCQUFTLENBQUMsSUFBSSxFQUFFLFNBQVMsRUFBRSxJQUFJLEVBQUU7b0JBQ3RELGVBQWUsRUFBRSxFQUFFLElBQUksRUFBRSxxQkFBUyxDQUFDLE1BQU0sRUFBRSxTQUFTLEVBQUUsSUFBSSxFQUFFO29CQUM1RCxlQUFlLEVBQUUsRUFBRSxJQUFJLEVBQUUscUJBQVMsQ0FBQyxJQUFJLEVBQUUsU0FBUyxFQUFFLEtBQUssRUFBRSxZQUFZLEVBQUUsRUFBRSxFQUFFO29CQUM3RSxVQUFVLEVBQUUsRUFBRSxJQUFJLEVBQUUscUJBQVMsQ0FBQyxJQUFJLEVBQUUsU0FBUyxFQUFFLEtBQUssRUFBRSxZQUFZLEVBQUUsRUFBRSxFQUFFO29CQUN4RSxnRkFBZ0Y7b0JBQ2hGLE1BQU0sRUFBRSxFQUFFLElBQUksRUFBRSxxQkFBUyxDQUFDLE1BQU0sRUFBRSxTQUFTLEVBQUUsS0FBSyxFQUFFLFlBQVksRUFBRSxNQUFNLEVBQUU7aUJBQzNFLEVBQ0QsRUFBRSxXQUFXLEVBQUUsT0FBTyxDQUFDLFdBQVcsRUFBRSxDQUNyQyxDQUFDO2dCQUVGLE1BQU0sYUFBYSxDQUFDLE9BQU8sQ0FBQyxjQUFjLEVBQUUsS0FBSyxFQUFFLE9BQU8sQ0FBQyxXQUFXLENBQUMsQ0FBQztZQUMxRSxDQUFDO1lBQ0QsSUFBSSxFQUFFLEtBQUssRUFBRSxFQUFFLE9BQU8sRUFBaUMsRUFBRSxFQUFFO2dCQUN6RCxNQUFNLE9BQU8sQ0FBQyxjQUFjLENBQUMsU0FBUyxDQUNwQyxFQUFFLFNBQVMsRUFBRSxPQUFPLENBQUMsU0FBUyxFQUFFLE1BQU0sRUFBRSxPQUFPLENBQUMsTUFBTSxFQUFFLEVBQ3hELEVBQUUsV0FBVyxFQUFFLE9BQU8sQ0FBQyxXQUFXLEVBQUUsQ0FDckMsQ0FBQztZQUNKLENBQUM7U0FDRjtLQUNGLENBQUM7QUFDSixDQUFDO0FBRUQsU0FBZ0IsVUFBVSxDQUN4QixTQUFvQixFQUNwQixPQUEwRTtJQUUxRSxjQUFjLENBQUMsT0FBTyxDQUFDLE1BQU0sRUFBRSxPQUFPLENBQUMsU0FBUyxFQUFFLE9BQU8sQ0FBQyxTQUFTLENBQUMsQ0FBQztJQUNyRSxjQUFjLENBQUMsT0FBTyxDQUFDLE1BQU0sRUFBRSxtQkFBbUIsQ0FBQyxPQUFPLENBQUMsU0FBUyxDQUFDLEVBQUUsT0FBTyxDQUFDLFNBQVMsQ0FBQyxDQUFDO0lBRTFGLE9BQU8sSUFBSSxhQUFLLENBQUM7UUFDZixVQUFVLEVBQUUsZUFBZSxDQUFDLE9BQU8sQ0FBQyxNQUFNLEVBQUUsT0FBTyxDQUFDLFNBQVMsQ0FBQztRQUM5RCxPQUFPLEVBQUU7WUFDUCxjQUFjLEVBQUUsU0FBUyxDQUFDLGlCQUFpQixFQUFFO1lBQzdDLE1BQU0sRUFBRSxPQUFPLENBQUMsTUFBTTtZQUN0QixTQUFTLEVBQUUsT0FBTyxDQUFDLFNBQVM7WUFDNUIsV0FBVyxFQUFFLE9BQU8sQ0FBQyxXQUFXO1NBQ2pDO1FBQ0QsT0FBTyxFQUFFLElBQUksd0JBQWdCLENBQUM7WUFDNUIsU0FBUztZQUNULE1BQU0sRUFBRSxPQUFPLENBQUMsTUFBTTtZQUN0QixTQUFTLEVBQUUsbUJBQW1CLENBQUMsT0FBTyxDQUFDLFNBQVMsQ0FBQztZQUNqRCw2RkFBNkY7WUFDN0YsNkZBQTZGO1lBQzdGLHNGQUFzRjtZQUN0RixTQUFTLEVBQUUsbUJBQW1CLENBQUMsT0FBTyxDQUFDLFNBQVMsQ0FBQztTQUNsRCxDQUFDO1FBQ0YsTUFBTSxFQUFFLFNBQVM7S0FDbEIsQ0FBQyxDQUFDO0FBQ0wsQ0FBQztBQUVELHVGQUF1RjtBQUN2RixNQUFNLDJCQUEyQixHQUFHLElBQUksR0FBRyxDQUFDLENBQUMsT0FBTyxFQUFFLE9BQU8sQ0FBQyxDQUFDLENBQUM7QUFFaEUsU0FBZ0IsMEJBQTBCLENBQUMsS0FBYztJQUN2RCxNQUFNLElBQUksR0FBSSxLQUEwQyxFQUFFLFFBQVEsRUFBRSxJQUFJLENBQUM7SUFFekUsT0FBTyxJQUFJLEtBQUssU0FBUyxJQUFJLDJCQUEyQixDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUMsQ0FBQztBQUNyRSxDQUFDO0FBRUQ7Ozs7O0dBS0c7QUFDSSxLQUFLLFVBQVUsWUFBWSxDQUNoQyxTQUFvQixFQUNwQixNQUEwQjtJQUUxQixJQUFJLENBQUMsTUFBTTtRQUFFLE9BQU87SUFFcEIsTUFBTSxjQUFjLEdBQUcsU0FBUyxDQUFDLGlCQUFpQixFQUFFLENBQUM7SUFDckQsTUFBTSxPQUFPLEdBQUcsQ0FBQyxNQUFNLGNBQWMsQ0FBQyxjQUFjLEVBQUUsQ0FBd0IsQ0FBQztJQUUvRSxJQUFJLE9BQU8sQ0FBQyxRQUFRLENBQUMsTUFBTSxDQUFDO1FBQUUsT0FBTztJQUVyQyxJQUFJLENBQUM7UUFDSCxNQUFNLGNBQWMsQ0FBQyxZQUFZLENBQUMsTUFBTSxDQUFDLENBQUM7SUFDNUMsQ0FBQztJQUFDLE9BQU8sS0FBSyxFQUFFLENBQUM7UUFDZixJQUFJLENBQUMsMEJBQTBCLENBQUMsS0FBSyxDQUFDO1lBQUUsTUFBTSxLQUFLLENBQUM7SUFDdEQsQ0FBQztBQUNILENBQUM7QUFFRDs7Ozs7OztHQU9HO0FBQ0ksS0FBSyxVQUFVLGtCQUFrQixDQUN0QyxTQUFvQixFQUNwQixPQUErQztJQUUvQyxNQUFNLFlBQVksQ0FBQyxTQUFTLEVBQUUsT0FBTyxDQUFDLE1BQU0sQ0FBQyxDQUFDO0lBRTlDLElBQUksU0FBUyxDQUFDLFVBQVUsRUFBRSxLQUFLLFVBQVUsRUFBRSxDQUFDO1FBQzFDLE1BQU0sVUFBVSxDQUFDLFNBQVMsRUFBRSxPQUFPLENBQUMsQ0FBQyxFQUFFLEVBQUUsQ0FBQztRQUUxQyxPQUFPO0lBQ1QsQ0FBQztJQUVELE1BQU0sU0FBUyxDQUFDLFdBQVcsQ0FBQyxLQUFLLEVBQUMsV0FBVyxFQUFDLEVBQUU7UUFDOUMsTUFBTSxTQUFTLENBQUMsS0FBSyxDQUFDLHNDQUFzQyxFQUFFO1lBQzVELFdBQVc7WUFDWCxZQUFZLEVBQUUsRUFBRSxDQUFDLEVBQUUsYUFBYSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUMsRUFBRSxhQUFhLENBQUMsQ0FBQyxDQUFDLEVBQUU7U0FDM0QsQ0FBQyxDQUFDO1FBRUgsTUFBTSxVQUFVLENBQUMsU0FBUyxFQUFFLEVBQUUsR0FBRyxPQUFPLEVBQUUsV0FBVyxFQUFFLENBQUMsQ0FBQyxFQUFFLEVBQUUsQ0FBQztJQUNoRSxDQUFDLENBQUMsQ0FBQztBQUNMLENBQUMifQ==
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type AuditEntry = {
|
|
2
|
+
operation: 'create' | 'update' | 'delete';
|
|
3
|
+
previousValues: Record<string, unknown>;
|
|
4
|
+
newValues: Record<string, unknown>;
|
|
5
|
+
};
|
|
6
|
+
export default function revertRecord(current: Record<string, unknown> | null, entries: AuditEntry[]): Record<string, unknown> | null;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=revert.d.ts.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.default = revertRecord;
|
|
4
|
+
const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date);
|
|
5
|
+
// Plain object on both sides ⇒ nested diff, walking the union of both sides' keys so an added key
|
|
6
|
+
// (present only in `next`) is caught too, not just a removed one. Anything else — including a key
|
|
7
|
+
// present in `next` but not `previous` at this exact level — is a leaf: `previous` is the value to
|
|
8
|
+
// write back as-is (this also covers "the whole field was replaced with something else", where
|
|
9
|
+
// `previous`/`next` at this level aren't a matching object pair at all).
|
|
10
|
+
const revertValue = (current, previous, next) => {
|
|
11
|
+
if (!isPlainObject(previous) || !isPlainObject(next))
|
|
12
|
+
return previous;
|
|
13
|
+
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]);
|
|
14
|
+
if (Array.isArray(current)) {
|
|
15
|
+
const result = [...current];
|
|
16
|
+
const removedIndices = [];
|
|
17
|
+
for (const key of keys) {
|
|
18
|
+
if (!(key in previous)) {
|
|
19
|
+
// Present only in `next`: this index was appended by the change, remove it on revert.
|
|
20
|
+
removedIndices.push(Number(key));
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
result[Number(key)] = revertValue(result[Number(key)], previous[key], next[key]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Highest index first so removing one doesn't shift the position of the next one to remove.
|
|
27
|
+
removedIndices
|
|
28
|
+
.sort((a, b) => b - a)
|
|
29
|
+
.forEach(index => {
|
|
30
|
+
result.splice(index, 1);
|
|
31
|
+
});
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
const base = isPlainObject(current) ? current : {};
|
|
35
|
+
const result = { ...base };
|
|
36
|
+
for (const key of keys) {
|
|
37
|
+
if (!(key in previous)) {
|
|
38
|
+
// Present only in `next`: this key was added by the change, remove it on revert.
|
|
39
|
+
delete result[key];
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
result[key] = revertValue(result[key], previous[key], next[key]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
};
|
|
47
|
+
const revertOne = (current, entry) => {
|
|
48
|
+
const result = { ...current };
|
|
49
|
+
const columns = new Set([...Object.keys(entry.previousValues), ...Object.keys(entry.newValues)]);
|
|
50
|
+
for (const column of columns) {
|
|
51
|
+
if (!(column in entry.previousValues)) {
|
|
52
|
+
// The change added this column (e.g. a smart action wrote a new field for the first time);
|
|
53
|
+
// reverting removes it rather than writing back a bogus `null`.
|
|
54
|
+
delete result[column];
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
result[column] = revertValue(result[column], entry.previousValues[column], entry.newValues[column]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
};
|
|
62
|
+
// `entries` must arrive newest-first. `create`/`delete` restore their terminal snapshot (`null` /
|
|
63
|
+
// `previousValues`) and walking continues into older entries, so a target timestamp that predates
|
|
64
|
+
// a delete-then-recreate (or update-then-delete) cycle still resolves to the earlier incarnation
|
|
65
|
+
// instead of stopping dead on the first terminal event encountered.
|
|
66
|
+
function revertRecord(current, entries) {
|
|
67
|
+
let state = current;
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (entry.operation === 'create') {
|
|
70
|
+
state = null;
|
|
71
|
+
}
|
|
72
|
+
else if (entry.operation === 'delete') {
|
|
73
|
+
state = { ...entry.previousValues };
|
|
74
|
+
}
|
|
75
|
+
else if (state) {
|
|
76
|
+
state = revertOne(state, entry);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return state;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmV2ZXJ0LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL2F1ZGl0LXRyYWlsL3JldmVydC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOztBQXFGQSwrQkFpQkM7QUFoR0QsTUFBTSxhQUFhLEdBQUcsQ0FBQyxLQUFjLEVBQW9DLEVBQUUsQ0FDekUsT0FBTyxDQUFDLEtBQUssQ0FBQyxJQUFJLE9BQU8sS0FBSyxLQUFLLFFBQVEsSUFBSSxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsS0FBSyxDQUFDLElBQUksQ0FBQyxDQUFDLEtBQUssWUFBWSxJQUFJLENBQUMsQ0FBQztBQUVuRyxrR0FBa0c7QUFDbEcsa0dBQWtHO0FBQ2xHLG1HQUFtRztBQUNuRywrRkFBK0Y7QUFDL0YseUVBQXlFO0FBQ3pFLE1BQU0sV0FBVyxHQUFHLENBQUMsT0FBZ0IsRUFBRSxRQUFpQixFQUFFLElBQWEsRUFBVyxFQUFFO0lBQ2xGLElBQUksQ0FBQyxhQUFhLENBQUMsUUFBUSxDQUFDLElBQUksQ0FBQyxhQUFhLENBQUMsSUFBSSxDQUFDO1FBQUUsT0FBTyxRQUFRLENBQUM7SUFFdEUsTUFBTSxJQUFJLEdBQUcsSUFBSSxHQUFHLENBQUMsQ0FBQyxHQUFHLE1BQU0sQ0FBQyxJQUFJLENBQUMsUUFBUSxDQUFDLEVBQUUsR0FBRyxNQUFNLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxDQUFDLENBQUMsQ0FBQztJQUV2RSxJQUFJLEtBQUssQ0FBQyxPQUFPLENBQUMsT0FBTyxDQUFDLEVBQUUsQ0FBQztRQUMzQixNQUFNLE1BQU0sR0FBRyxDQUFDLEdBQUcsT0FBTyxDQUFDLENBQUM7UUFDNUIsTUFBTSxjQUFjLEdBQWEsRUFBRSxDQUFDO1FBRXBDLEtBQUssTUFBTSxHQUFHLElBQUksSUFBSSxFQUFFLENBQUM7WUFDdkIsSUFBSSxDQUFDLENBQUMsR0FBRyxJQUFJLFFBQVEsQ0FBQyxFQUFFLENBQUM7Z0JBQ3ZCLHNGQUFzRjtnQkFDdEYsY0FBYyxDQUFDLElBQUksQ0FBQyxNQUFNLENBQUMsR0FBRyxDQUFDLENBQUMsQ0FBQztZQUNuQyxDQUFDO2lCQUFNLENBQUM7Z0JBQ04sTUFBTSxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsQ0FBQyxHQUFHLFdBQVcsQ0FBQyxNQUFNLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBQyxDQUFDLEVBQUUsUUFBUSxDQUFDLEdBQUcsQ0FBQyxFQUFFLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUFDO1lBQ25GLENBQUM7UUFDSCxDQUFDO1FBRUQsNEZBQTRGO1FBQzVGLGNBQWM7YUFDWCxJQUFJLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxFQUFFLEVBQUUsQ0FBQyxDQUFDLEdBQUcsQ0FBQyxDQUFDO2FBQ3JCLE9BQU8sQ0FBQyxLQUFLLENBQUMsRUFBRTtZQUNmLE1BQU0sQ0FBQyxNQUFNLENBQUMsS0FBSyxFQUFFLENBQUMsQ0FBQyxDQUFDO1FBQzFCLENBQUMsQ0FBQyxDQUFDO1FBRUwsT0FBTyxNQUFNLENBQUM7SUFDaEIsQ0FBQztJQUVELE1BQU0sSUFBSSxHQUFHLGFBQWEsQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUM7SUFDbkQsTUFBTSxNQUFNLEdBQTRCLEVBQUUsR0FBRyxJQUFJLEVBQUUsQ0FBQztJQUVwRCxLQUFLLE1BQU0sR0FBRyxJQUFJLElBQUksRUFBRSxDQUFDO1FBQ3ZCLElBQUksQ0FBQyxDQUFDLEdBQUcsSUFBSSxRQUFRLENBQUMsRUFBRSxDQUFDO1lBQ3ZCLGlGQUFpRjtZQUNqRixPQUFPLE1BQU0sQ0FBQyxHQUFHLENBQUMsQ0FBQztRQUNyQixDQUFDO2FBQU0sQ0FBQztZQUNOLE1BQU0sQ0FBQyxHQUFHLENBQUMsR0FBRyxXQUFXLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBQyxFQUFFLFFBQVEsQ0FBQyxHQUFHLENBQUMsRUFBRSxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUMsQ0FBQztRQUNuRSxDQUFDO0lBQ0gsQ0FBQztJQUVELE9BQU8sTUFBTSxDQUFDO0FBQ2hCLENBQUMsQ0FBQztBQUVGLE1BQU0sU0FBUyxHQUFHLENBQ2hCLE9BQWdDLEVBQ2hDLEtBQWlCLEVBQ1EsRUFBRTtJQUMzQixNQUFNLE1BQU0sR0FBRyxFQUFFLEdBQUcsT0FBTyxFQUFFLENBQUM7SUFDOUIsTUFBTSxPQUFPLEdBQUcsSUFBSSxHQUFHLENBQUMsQ0FBQyxHQUFHLE1BQU0sQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLGNBQWMsQ0FBQyxFQUFFLEdBQUcsTUFBTSxDQUFDLElBQUksQ0FBQyxLQUFLLENBQUMsU0FBUyxDQUFDLENBQUMsQ0FBQyxDQUFDO0lBRWpHLEtBQUssTUFBTSxNQUFNLElBQUksT0FBTyxFQUFFLENBQUM7UUFDN0IsSUFBSSxDQUFDLENBQUMsTUFBTSxJQUFJLEtBQUssQ0FBQyxjQUFjLENBQUMsRUFBRSxDQUFDO1lBQ3RDLDJGQUEyRjtZQUMzRixnRUFBZ0U7WUFDaEUsT0FBTyxNQUFNLENBQUMsTUFBTSxDQUFDLENBQUM7UUFDeEIsQ0FBQzthQUFNLENBQUM7WUFDTixNQUFNLENBQUMsTUFBTSxDQUFDLEdBQUcsV0FBVyxDQUMxQixNQUFNLENBQUMsTUFBTSxDQUFDLEVBQ2QsS0FBSyxDQUFDLGNBQWMsQ0FBQyxNQUFNLENBQUMsRUFDNUIsS0FBSyxDQUFDLFNBQVMsQ0FBQyxNQUFNLENBQUMsQ0FDeEIsQ0FBQztRQUNKLENBQUM7SUFDSCxDQUFDO0lBRUQsT0FBTyxNQUFNLENBQUM7QUFDaEIsQ0FBQyxDQUFDO0FBRUYsa0dBQWtHO0FBQ2xHLGtHQUFrRztBQUNsRyxpR0FBaUc7QUFDakcsb0VBQW9FO0FBQ3BFLFNBQXdCLFlBQVksQ0FDbEMsT0FBdUMsRUFDdkMsT0FBcUI7SUFFckIsSUFBSSxLQUFLLEdBQUcsT0FBTyxDQUFDO0lBRXBCLEtBQUssTUFBTSxLQUFLLElBQUksT0FBTyxFQUFFLENBQUM7UUFDNUIsSUFBSSxLQUFLLENBQUMsU0FBUyxLQUFLLFFBQVEsRUFBRSxDQUFDO1lBQ2pDLEtBQUssR0FBRyxJQUFJLENBQUM7UUFDZixDQUFDO2FBQU0sSUFBSSxLQUFLLENBQUMsU0FBUyxLQUFLLFFBQVEsRUFBRSxDQUFDO1lBQ3hDLEtBQUssR0FBRyxFQUFFLEdBQUcsS0FBSyxDQUFDLGNBQWMsRUFBRSxDQUFDO1FBQ3RDLENBQUM7YUFBTSxJQUFJLEtBQUssRUFBRSxDQUFDO1lBQ2pCLEtBQUssR0FBRyxTQUFTLENBQUMsS0FBSyxFQUFFLEtBQUssQ0FBQyxDQUFDO1FBQ2xDLENBQUM7SUFDSCxDQUFDO0lBRUQsT0FBTyxLQUFLLENBQUM7QUFDZixDQUFDIn0=
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ForestAdminHttpDriverServices } from '../services';
|
|
2
|
+
import type { Collection, ConditionTree } from '@forestadmin/datasource-toolkit';
|
|
3
|
+
import type { Context } from 'koa';
|
|
4
|
+
export declare function recordExists(collection: Collection, packedId: string, context: Context, scope: ConditionTree | null): Promise<boolean>;
|
|
5
|
+
export type RecordVisibility = {
|
|
6
|
+
/** False → the id still exists and fails the scope; the caller must be denied entirely (404). */
|
|
7
|
+
visible: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* True once the id no longer exists at all, under any scope. `visible` is then true for a
|
|
10
|
+
* different reason than "the record matches the scope": there is nothing left to check
|
|
11
|
+
* existence against. It doesn't mean every value the audit trail holds for that record is safe
|
|
12
|
+
* to hand back to this caller — a scoped-down field value captured while the record still
|
|
13
|
+
* existed can still fall outside the caller's scope, and callers should check that separately
|
|
14
|
+
* (e.g. by re-evaluating the scope's `ConditionTree` against a delete row's `previousValues`)
|
|
15
|
+
* before deciding how much of the record's captured data to surface.
|
|
16
|
+
*/
|
|
17
|
+
goneEntirely: boolean;
|
|
18
|
+
};
|
|
19
|
+
export default function checkRecordVisibility(services: ForestAdminHttpDriverServices, collection: Collection, packedId: string, context: Context): Promise<RecordVisibility>;
|
|
20
|
+
//# sourceMappingURL=scope.d.ts.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.recordExists = recordExists;
|
|
7
|
+
exports.default = checkRecordVisibility;
|
|
8
|
+
const datasource_toolkit_1 = require("@forestadmin/datasource-toolkit");
|
|
9
|
+
const id_1 = __importDefault(require("../utils/id"));
|
|
10
|
+
const query_string_1 = __importDefault(require("../utils/query-string"));
|
|
11
|
+
async function recordExists(collection, packedId, context, scope) {
|
|
12
|
+
const id = id_1.default.unpackId(collection.schema, packedId);
|
|
13
|
+
const filter = new datasource_toolkit_1.PaginatedFilter({
|
|
14
|
+
conditionTree: datasource_toolkit_1.ConditionTreeFactory.intersect(datasource_toolkit_1.ConditionTreeFactory.matchIds(collection.schema, [id]), scope),
|
|
15
|
+
});
|
|
16
|
+
const records = await collection.list(query_string_1.default.parseCaller(context, { defaultTimezone: 'UTC' }), filter, new datasource_toolkit_1.Projection(...datasource_toolkit_1.SchemaUtils.getPrimaryKeys(collection.schema)));
|
|
17
|
+
return records.length > 0;
|
|
18
|
+
}
|
|
19
|
+
// A record-level scope can't be evaluated against a record that no longer exists, so a caller whose
|
|
20
|
+
// access is scoped down is only denied when the id currently exists and fails that scope — once it's
|
|
21
|
+
// genuinely gone there is nothing left to scope against, and showing that it existed (including that
|
|
22
|
+
// it was deleted, by whom and when) is much of the point of an audit trail.
|
|
23
|
+
async function checkRecordVisibility(services, collection, packedId, context) {
|
|
24
|
+
const scope = await services.authorization.getScope(collection, context);
|
|
25
|
+
if (!scope)
|
|
26
|
+
return { visible: true, goneEntirely: false };
|
|
27
|
+
if (await recordExists(collection, packedId, context, scope)) {
|
|
28
|
+
return { visible: true, goneEntirely: false };
|
|
29
|
+
}
|
|
30
|
+
const existsOutsideScope = await recordExists(collection, packedId, context, null);
|
|
31
|
+
return { visible: !existsOutsideScope, goneEntirely: !existsOutsideScope };
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2NvcGUuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zcmMvYXVkaXQtdHJhaWwvc2NvcGUudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6Ijs7Ozs7QUFjQSxvQ0FxQkM7QUFxQkQsd0NBaUJDO0FBckVELHdFQUt5QztBQUV6QyxxREFBa0M7QUFDbEMseUVBQXNEO0FBRS9DLEtBQUssVUFBVSxZQUFZLENBQ2hDLFVBQXNCLEVBQ3RCLFFBQWdCLEVBQ2hCLE9BQWdCLEVBQ2hCLEtBQTJCO0lBRTNCLE1BQU0sRUFBRSxHQUFHLFlBQU8sQ0FBQyxRQUFRLENBQUMsVUFBVSxDQUFDLE1BQU0sRUFBRSxRQUFRLENBQUMsQ0FBQztJQUN6RCxNQUFNLE1BQU0sR0FBRyxJQUFJLG9DQUFlLENBQUM7UUFDakMsYUFBYSxFQUFFLHlDQUFvQixDQUFDLFNBQVMsQ0FDM0MseUNBQW9CLENBQUMsUUFBUSxDQUFDLFVBQVUsQ0FBQyxNQUFNLEVBQUUsQ0FBQyxFQUFFLENBQUMsQ0FBQyxFQUN0RCxLQUFLLENBQ047S0FDRixDQUFDLENBQUM7SUFFSCxNQUFNLE9BQU8sR0FBRyxNQUFNLFVBQVUsQ0FBQyxJQUFJLENBQ25DLHNCQUFpQixDQUFDLFdBQVcsQ0FBQyxPQUFPLEVBQUUsRUFBRSxlQUFlLEVBQUUsS0FBSyxFQUFFLENBQUMsRUFDbEUsTUFBTSxFQUNOLElBQUksK0JBQVUsQ0FBQyxHQUFHLGdDQUFXLENBQUMsY0FBYyxDQUFDLFVBQVUsQ0FBQyxNQUFNLENBQUMsQ0FBQyxDQUNqRSxDQUFDO0lBRUYsT0FBTyxPQUFPLENBQUMsTUFBTSxHQUFHLENBQUMsQ0FBQztBQUM1QixDQUFDO0FBaUJELG9HQUFvRztBQUNwRyxxR0FBcUc7QUFDckcscUdBQXFHO0FBQ3JHLDRFQUE0RTtBQUM3RCxLQUFLLFVBQVUscUJBQXFCLENBQ2pELFFBQXVDLEVBQ3ZDLFVBQXNCLEVBQ3RCLFFBQWdCLEVBQ2hCLE9BQWdCO0lBRWhCLE1BQU0sS0FBSyxHQUFHLE1BQU0sUUFBUSxDQUFDLGFBQWEsQ0FBQyxRQUFRLENBQUMsVUFBVSxFQUFFLE9BQU8sQ0FBQyxDQUFDO0lBRXpFLElBQUksQ0FBQyxLQUFLO1FBQUUsT0FBTyxFQUFFLE9BQU8sRUFBRSxJQUFJLEVBQUUsWUFBWSxFQUFFLEtBQUssRUFBRSxDQUFDO0lBRTFELElBQUksTUFBTSxZQUFZLENBQUMsVUFBVSxFQUFFLFFBQVEsRUFBRSxPQUFPLEVBQUUsS0FBSyxDQUFDLEVBQUUsQ0FBQztRQUM3RCxPQUFPLEVBQUUsT0FBTyxFQUFFLElBQUksRUFBRSxZQUFZLEVBQUUsS0FBSyxFQUFFLENBQUM7SUFDaEQsQ0FBQztJQUVELE1BQU0sa0JBQWtCLEdBQUcsTUFBTSxZQUFZLENBQUMsVUFBVSxFQUFFLFFBQVEsRUFBRSxPQUFPLEVBQUUsSUFBSSxDQUFDLENBQUM7SUFFbkYsT0FBTyxFQUFFLE9BQU8sRUFBRSxDQUFDLGtCQUFrQixFQUFFLFlBQVksRUFBRSxDQUFDLGtCQUFrQixFQUFFLENBQUM7QUFDN0UsQ0FBQyJ9
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AuditRecord, AuditStatus, AuditStorageOptions, AuditStore, PendingAuditRecord } from './types';
|
|
2
|
+
import type { Model, ModelStatic } from 'sequelize';
|
|
3
|
+
import { Sequelize } from 'sequelize';
|
|
4
|
+
export declare const DEFAULT_SCHEMA = "forest";
|
|
5
|
+
export declare const DEFAULT_TABLE = "audit_logs";
|
|
6
|
+
export declare function defineAuditLogModel(sequelize: Sequelize, options: {
|
|
7
|
+
schema?: string;
|
|
8
|
+
tableName: string;
|
|
9
|
+
}): ModelStatic<Model>;
|
|
10
|
+
export declare function ensureAuditStorage(sequelize: Sequelize, options: {
|
|
11
|
+
schema?: string;
|
|
12
|
+
tableName: string;
|
|
13
|
+
}): Promise<ModelStatic<Model>>;
|
|
14
|
+
export declare function toRow(record: PendingAuditRecord & {
|
|
15
|
+
status: AuditStatus;
|
|
16
|
+
}): Record<string, unknown>;
|
|
17
|
+
export declare function fieldsChangedCondition(sequelize: Sequelize, fields: string[]): import("sequelize/types/utils").Literal;
|
|
18
|
+
export declare function searchCondition(sequelize: Sequelize, term: string): import("sequelize/types/utils").Literal;
|
|
19
|
+
export declare function fromRow(row: Model): AuditRecord;
|
|
20
|
+
/**
|
|
21
|
+
* Returns a SQL-backed audit store. Construction is synchronous so the store can be passed to
|
|
22
|
+
* `createAgent` at module top level; the connection (and pending migrations) only run when
|
|
23
|
+
* `store.init()` is awaited during `agent.start()`. The store also self-initializes on the first
|
|
24
|
+
* append or read so tests and manual scripts work without an explicit init.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createSqlAuditStore(options: AuditStorageOptions): {
|
|
27
|
+
store: AuditStore;
|
|
28
|
+
close: () => Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
//# sourceMappingURL=sql-store.d.ts.map
|