Skip to content

Commit abb2f61

Browse files
committed
feedback
1 parent 1f3c991 commit abb2f61

File tree

10 files changed

+252
-71
lines changed

10 files changed

+252
-71
lines changed

README.md

Lines changed: 76 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,31 @@
11
# Convex TableHistory Component
22

3-
[![npm version](https://badge.fury.io/js/@convex-dev%2Ftable-history.svg)](https://badge.fury.io/js/@convex-dev%2Ftable-history)
3+
[![npm version](https://badge.fury.io/js/convex-table-history.svg)](https://badge.fury.io/js/convex-table-history)
44

55
<!-- START: Include on https://convex.dev/components -->
66

77
## History on a Convex table
88

9-
Attach a history component to your most important Convex tables to keep track of changes.
9+
Attach a history component to your Convex table to keep track of changes.
1010

1111
- View an audit log of all table changes in the Convex Dashboard or in a custom React component.
12-
- Answer questions like "did user A join the team before user B?"
12+
- Answer questions like "was document A updated before document B?"
1313
- View an audit log of changes to a single document
1414
- Answer questions like "what was the user's email address before they changed it?"
1515
- Look at a snapshot of the table at any point in time.
1616

1717
```ts
18-
// Paginate through all history on the "users" table
19-
userAuditLog.listHistory(ctx, args.maxTs, args.paginationOpts);
18+
// Paginate through all history on the "documents" table, from newest to oldest.
19+
documentAuditLog.listHistory(ctx, args.maxTs, args.paginationOpts);
2020

21-
// Paginate through all history for a specific user
22-
userAuditLog.listDocumentHistory(ctx, args.userId, args.maxTs, args.paginationOpts);
21+
// Paginate through all history for a specific document, from newest to oldest.
22+
documentAuditLog.listDocumentHistory(ctx, args.documentId, args.maxTs, args.paginationOpts);
2323

24-
// Paginate through all users in the "users" table at a specific timestamp
25-
userAuditLog.listSnapshot(ctx, args.snapshotTs, args.currentTs, args.paginationOpts);
24+
// Paginate through all documents in the "documents" table at a specific timestamp.
25+
documentAuditLog.listSnapshot(ctx, args.snapshotTs, args.currentTs, args.paginationOpts);
2626
```
2727

28-
Found a bug? Feature request? [File it here](https://github.com/get-convex/table-history/issues).
28+
Found a bug? Feature request? [File it here](https://github.com/ldanilek/table-history/issues).
2929

3030
## Pre-requisite: Convex
3131

@@ -40,18 +40,18 @@ Run `npm create convex` or follow any of the [quickstarts](https://docs.convex.d
4040
Install the component package:
4141

4242
```ts
43-
npm install @convex-dev/table-history
43+
npm install convex-table-history
4444
```
4545

4646
Create a `convex.config.ts` file in your app's `convex/` folder and install the component by calling `use`:
4747

4848
```ts
4949
// convex/convex.config.ts
5050
import { defineApp } from "convex/server";
51-
import tableHistory from "@convex-dev/table-history/convex.config";
51+
import tableHistory from "convex-table-history/convex.config";
5252

5353
const app = defineApp();
54-
app.use(tableHistory, { name: "userAuditLog" });
54+
app.use(tableHistory, { name: "documentAuditLog" });
5555

5656
export default app;
5757
```
@@ -64,28 +64,26 @@ different names. They will be available in your app as
6464

6565
```ts
6666
import { components } from "./_generated/api";
67-
import { TableHistory } from "@convex-dev/table-history";
67+
import { TableHistory } from "convex-table-history";
6868

69-
const userAuditLog = new TableHistory<DataModel, "users">(components.userAuditLog, {
70-
serializability: "wallclock",
71-
});
69+
const documentAuditLog = new TableHistory<DataModel, "documents">(components.documentAuditLog);
7270
```
7371

7472
Add an item to the history table when a document changes:
7573

7674
```ts
77-
async function patchUser(ctx: MutationCtx, userId: Id<"users">, patch: Partial<Doc<"users">>) {
78-
await ctx.db.patch(userId, patch);
79-
const userDoc = await ctx.db.get(userId);
80-
await userAuditLog.update(ctx, userDoc._id, userDoc);
75+
async function patchDocument(ctx: MutationCtx, documentId: Id<"documents">, patch: Partial<Doc<"documents">>) {
76+
await ctx.db.patch(documentId, patch);
77+
const document = await ctx.db.get(documentId);
78+
await documentAuditLog.update(ctx, documentId, document);
8179
}
8280
```
8381

8482
Or attach a [trigger](https://docs.convex.dev/triggers) to automatically write to the history table when a mutation changes a document:
8583

8684
```ts
8785
const triggers = new Triggers<DataModel>();
88-
triggers.register("users", userAuditLog.trigger());
86+
triggers.register("documents", documentAuditLog.trigger());
8987
export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));
9088
```
9189

@@ -96,8 +94,9 @@ The pages consist of `HistoryEntry` objects, which have the following fields:
9694

9795
- `id`: the id of the document that was changed
9896
- `doc`: the new version of the document that was changed, or null if the document was deleted
99-
- `ts`: the timestamp of the change
97+
- `ts`: the timestamp of the change. See [serializability](#serializability).
10098
- `isDeleted`: whether the document was deleted
99+
- `attribution`: an optional arbitrary object that will be stored with the history entry
101100

102101
See more example usage in [example.ts](./example/convex/example.ts).
103102

@@ -120,29 +119,48 @@ You can configure the serializability of the history table by setting the
120119
- `"document"` -- i.e. the latest ts for the document, plus one
121120
- history entries are serializable with other updates on the same document.
122121
- `"wallclock"` -- i.e. Date.now()
123-
- history entry timestamps don't have guarantees, but they also take no extra dependencies and are usually sufficient.
122+
- history entry timestamps don't have guarantees, but they also take no extra
123+
dependencies and are usually sufficient.
124124

125-
### `currentTs` and `maxTs` are required
125+
The default serializability is `"wallclock"`.
126126

127-
- For `listSnapshot`, `currentTs` should be a stable recent timestamp.
128-
- If the timestamp isn't recent, the queries might read too much data in
129-
a single page and throw an error.
130-
- For `listHistory` and `listDocumentHistory`, `maxTs` should be stable but
131-
doesn't need to be recent.
127+
### `currentTs` and `maxTs` are required
132128

133129
```ts
134130
const [currentTs] = useState(Date.now()); // stable and recent
135-
const [yesterday] = useState(Date.now() - 24 * 60 * 60 * 1000);
136-
const usersYesterday = usePaginatedQuery(
137-
api.users.listSnapshot,
131+
const [yesterday] = useState(Date.now() - 24 * 60 * 60 * 1000); // stable
132+
const snapshotOfUsersYesterday = usePaginatedQuery(
133+
api.documents.listSnapshot,
138134
{
139135
currentTs,
140136
snapshotTs: yesterday,
141137
},
142138
{ initialNumItems: 100 },
143139
);
140+
const auditLogBeforeYesterday = usePaginatedQuery(
141+
api.documents.listHistory,
142+
{
143+
maxTs: yesterday,
144+
},
145+
{ initialNumItems: 100 },
146+
);
144147
```
145148

149+
- For `listSnapshot`, `currentTs` should be a stable recent timestamp.
150+
- "Stable" means it should have the same value for all pages.
151+
- To keep a stable timestamp for all pages, pick a value on the client and
152+
pass it as an arg of `usePaginatedQuery`.
153+
- "Recent" is relative to how often the table gets new inserts. The amount of
154+
extra work performed by the query is proportional to the number of
155+
`ctx.db.insert(tableName, doc)` calls since the `currentTs`.
156+
- If the timestamp isn't recent, the queries might read too much data in
157+
a single page and throw an error.
158+
- Don't pick a timestamp in the future, or gaps will appear between pages
159+
as new documents are inserted. The timestamp should be `Date.now()` or
160+
slightly in the past.
161+
- For `listHistory` and `listDocumentHistory`, `maxTs` should be stable but
162+
doesn't need to be recent.
163+
146164
**Why is this necessary?**
147165

148166
The TableHistory component supports paginated queries with
@@ -157,7 +175,27 @@ Concretely, `usePaginatedQuery` results should not have gaps or duplicates.
157175
In order to implement this feature without the built-in `.paginate` method,
158176
the TableHistory component assumes its own data model is append-only (which is
159177
true, except when vacuuming), and takes in a stable recent timestamp. Then it
160-
only looks at history entries from before that timestamp.
178+
ignores history entries created after that timestamp.
179+
180+
### Attribution
181+
182+
Store an update's `attribution` to track information like which user made the
183+
change, or what mutation made the change.
184+
185+
```ts
186+
await documentAuditLog.update(ctx, documentId, document, {
187+
attribution: {
188+
actorIdentity: await ctx.auth.getUserIdentity(),
189+
mutationName: "patchDocument",
190+
source: "web",
191+
},
192+
});
193+
```
194+
195+
The default attribution when using `TableHistory.update` is `null`.
196+
197+
The default attribution when using `TableHistory.trigger` is the mutation's
198+
`ctx.auth.getUserIdentity()`.
161199

162200
### Vacuuming
163201

@@ -167,13 +205,14 @@ schedule background jobs to delete old history entries.
167205
The entries which will be deleted are those which are not visible
168206
at snapshots `>=minTsToKeep`.
169207

208+
After vacuuming up to `minTsToKeep`, you can no longer call `listSnapshot`
209+
with a snapshot timestamp less than `minTsToKeep`.
210+
170211
### Limitations
171212

172-
- No attribution: there is no way to add attribution to a history entry, e.g. which `ctx.auth`
173-
made the change.
174-
- Workaround: you can add attribution to the document itself.
175213
- No indexes: you can't use an index to change the sort order or get a subset of results.
176214
- Workaround: you can paginate until `isDone` returns true, and sort or filter
177215
the results yourself, either on the client or in an action.
216+
Consider [manual pagination](https://docs.convex.dev/database/pagination#paginating-manually).
178217

179218
<!-- END: Include on https://convex.dev/components -->

example/convex/_generated/api.d.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,13 @@ export declare const components: {
5858
{
5959
continueCursor: string;
6060
isDone: boolean;
61-
page: Array<{ doc: any; id: string; isDeleted: boolean; ts: number }>;
61+
page: Array<{
62+
attribution: any;
63+
doc: any;
64+
id: string;
65+
isDeleted: boolean;
66+
ts: number;
67+
}>;
6268
}
6369
>;
6470
listHistory: FunctionReference<
@@ -78,7 +84,13 @@ export declare const components: {
7884
{
7985
continueCursor: string;
8086
isDone: boolean;
81-
page: Array<{ doc: any; id: string; isDeleted: boolean; ts: number }>;
87+
page: Array<{
88+
attribution: any;
89+
doc: any;
90+
id: string;
91+
isDeleted: boolean;
92+
ts: number;
93+
}>;
8294
}
8395
>;
8496
listSnapshot: FunctionReference<
@@ -99,13 +111,22 @@ export declare const components: {
99111
{
100112
continueCursor: string;
101113
isDone: boolean;
102-
page: Array<{ doc: any; id: string; isDeleted: boolean; ts: number }>;
114+
page: Array<{
115+
attribution: any;
116+
doc: any;
117+
id: string;
118+
isDeleted: boolean;
119+
ts: number;
120+
}>;
121+
pageStatus?: "SplitRecommended";
122+
splitCursor?: string;
103123
}
104124
>;
105125
update: FunctionReference<
106126
"mutation",
107127
"internal",
108128
{
129+
attribution: any;
109130
doc: any | null;
110131
id: string;
111132
serializability: "table" | "document" | "wallclock";

example/convex/convex.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defineApp } from "convex/server";
2-
import tableHistory from "@convex-dev/table-history/convex.config";
2+
import tableHistory from "convex-table-history/convex.config";
33

44
const app = defineApp();
55
app.use(tableHistory, { name: "userAuditLog" });

example/convex/example.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { query, mutation as rawMutation } from "./_generated/server";
22
import { components } from "./_generated/api";
3-
import { TableHistory } from "@convex-dev/table-history";
3+
import { TableHistory } from "convex-table-history";
44
import { v } from "convex/values";
55
import { paginationOptsValidator } from "convex/server";
66
import { Triggers } from "convex-helpers/server/triggers";

example/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"lint": "tsc -p convex && eslint convex"
1111
},
1212
"dependencies": {
13-
"@convex-dev/table-history": "file:..",
13+
"convex-table-history": "file:..",
1414
"convex": "file:../node_modules/convex",
1515
"convex-helpers": "^0.1.67",
1616
"react": "^18.3.1",

src/client/index.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,38 @@ import { api } from "../component/_generated/api";
1313
import type { Serializability } from "../component/lib";
1414

1515
export class TableHistory<DataModel extends GenericDataModel, TableName extends TableNamesInDataModel<DataModel>> {
16+
public options: {
17+
serializability: Serializability;
18+
};
1619
constructor(
1720
public component: UseApi<typeof api>,
18-
public options: {
19-
serializability: Serializability;
21+
options?: {
22+
serializability?: Serializability;
2023
}
21-
) {}
24+
) {
25+
this.options = {
26+
serializability: options?.serializability ?? "wallclock",
27+
};
28+
}
2229

2330
/**
2431
* Write a new history entry.
32+
*
33+
* @argument attribution an arbitrary object that will be stored with the
34+
* history entry. Attribution can include actor/user identity, reason for
35+
* change, etc.
2536
*/
2637
async update(
2738
ctx: RunMutationCtx,
2839
id: GenericId<TableName>,
29-
doc: DocumentByName<DataModel, TableName> | null
40+
doc: DocumentByName<DataModel, TableName> | null,
41+
attribution: unknown = null,
3042
) {
3143
return ctx.runMutation(this.component.lib.update, {
3244
id,
3345
doc,
3446
serializability: this.options.serializability,
47+
attribution,
3548
});
3649
}
3750

@@ -81,7 +94,13 @@ export class TableHistory<DataModel extends GenericDataModel, TableName extends
8194
*/
8295
trigger<Ctx extends RunMutationCtx>(): Trigger<Ctx, DataModel, TableName> {
8396
return async (ctx, change) => {
84-
await this.update(ctx, change.id, change.newDoc);
97+
let attribution: unknown = null;
98+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
99+
if ((ctx as any).auth && typeof (ctx as any).auth.getUserIdentity === "function") {
100+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
101+
attribution = await (ctx as any).auth.getUserIdentity();
102+
}
103+
await this.update(ctx, change.id, change.newDoc, attribution);
85104
};
86105
}
87106
}

0 commit comments

Comments
 (0)