Skip to content

Commit

Permalink
feedback
Browse files Browse the repository at this point in the history
  • Loading branch information
ldanilek committed Jan 13, 2025
1 parent 1f3c991 commit abb2f61
Show file tree
Hide file tree
Showing 10 changed files with 252 additions and 71 deletions.
113 changes: 76 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
# Convex TableHistory Component

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

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

## History on a Convex table

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

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

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

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

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

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

## Pre-requisite: Convex

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

```ts
npm install @convex-dev/table-history
npm install convex-table-history
```

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

```ts
// convex/convex.config.ts
import { defineApp } from "convex/server";
import tableHistory from "@convex-dev/table-history/convex.config";
import tableHistory from "convex-table-history/convex.config";

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

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

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

const userAuditLog = new TableHistory<DataModel, "users">(components.userAuditLog, {
serializability: "wallclock",
});
const documentAuditLog = new TableHistory<DataModel, "documents">(components.documentAuditLog);
```

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

```ts
async function patchUser(ctx: MutationCtx, userId: Id<"users">, patch: Partial<Doc<"users">>) {
await ctx.db.patch(userId, patch);
const userDoc = await ctx.db.get(userId);
await userAuditLog.update(ctx, userDoc._id, userDoc);
async function patchDocument(ctx: MutationCtx, documentId: Id<"documents">, patch: Partial<Doc<"documents">>) {
await ctx.db.patch(documentId, patch);
const document = await ctx.db.get(documentId);
await documentAuditLog.update(ctx, documentId, document);
}
```

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

```ts
const triggers = new Triggers<DataModel>();
triggers.register("users", userAuditLog.trigger());
triggers.register("documents", documentAuditLog.trigger());
export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));
```

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

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

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

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

### `currentTs` and `maxTs` are required
The default serializability is `"wallclock"`.

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

```ts
const [currentTs] = useState(Date.now()); // stable and recent
const [yesterday] = useState(Date.now() - 24 * 60 * 60 * 1000);
const usersYesterday = usePaginatedQuery(
api.users.listSnapshot,
const [yesterday] = useState(Date.now() - 24 * 60 * 60 * 1000); // stable
const snapshotOfUsersYesterday = usePaginatedQuery(
api.documents.listSnapshot,
{
currentTs,
snapshotTs: yesterday,
},
{ initialNumItems: 100 },
);
const auditLogBeforeYesterday = usePaginatedQuery(
api.documents.listHistory,
{
maxTs: yesterday,
},
{ initialNumItems: 100 },
);
```

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

**Why is this necessary?**

The TableHistory component supports paginated queries with
Expand All @@ -157,7 +175,27 @@ Concretely, `usePaginatedQuery` results should not have gaps or duplicates.
In order to implement this feature without the built-in `.paginate` method,
the TableHistory component assumes its own data model is append-only (which is
true, except when vacuuming), and takes in a stable recent timestamp. Then it
only looks at history entries from before that timestamp.
ignores history entries created after that timestamp.

### Attribution

Store an update's `attribution` to track information like which user made the
change, or what mutation made the change.

```ts
await documentAuditLog.update(ctx, documentId, document, {
attribution: {
actorIdentity: await ctx.auth.getUserIdentity(),
mutationName: "patchDocument",
source: "web",
},
});
```

The default attribution when using `TableHistory.update` is `null`.

The default attribution when using `TableHistory.trigger` is the mutation's
`ctx.auth.getUserIdentity()`.

### Vacuuming

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

After vacuuming up to `minTsToKeep`, you can no longer call `listSnapshot`
with a snapshot timestamp less than `minTsToKeep`.

### Limitations

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

<!-- END: Include on https://convex.dev/components -->
27 changes: 24 additions & 3 deletions example/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ export declare const components: {
{
continueCursor: string;
isDone: boolean;
page: Array<{ doc: any; id: string; isDeleted: boolean; ts: number }>;
page: Array<{
attribution: any;
doc: any;
id: string;
isDeleted: boolean;
ts: number;
}>;
}
>;
listHistory: FunctionReference<
Expand All @@ -78,7 +84,13 @@ export declare const components: {
{
continueCursor: string;
isDone: boolean;
page: Array<{ doc: any; id: string; isDeleted: boolean; ts: number }>;
page: Array<{
attribution: any;
doc: any;
id: string;
isDeleted: boolean;
ts: number;
}>;
}
>;
listSnapshot: FunctionReference<
Expand All @@ -99,13 +111,22 @@ export declare const components: {
{
continueCursor: string;
isDone: boolean;
page: Array<{ doc: any; id: string; isDeleted: boolean; ts: number }>;
page: Array<{
attribution: any;
doc: any;
id: string;
isDeleted: boolean;
ts: number;
}>;
pageStatus?: "SplitRecommended";
splitCursor?: string;
}
>;
update: FunctionReference<
"mutation",
"internal",
{
attribution: any;
doc: any | null;
id: string;
serializability: "table" | "document" | "wallclock";
Expand Down
2 changes: 1 addition & 1 deletion example/convex/convex.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineApp } from "convex/server";
import tableHistory from "@convex-dev/table-history/convex.config";
import tableHistory from "convex-table-history/convex.config";

const app = defineApp();
app.use(tableHistory, { name: "userAuditLog" });
Expand Down
2 changes: 1 addition & 1 deletion example/convex/example.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { query, mutation as rawMutation } from "./_generated/server";
import { components } from "./_generated/api";
import { TableHistory } from "@convex-dev/table-history";
import { TableHistory } from "convex-table-history";
import { v } from "convex/values";
import { paginationOptsValidator } from "convex/server";
import { Triggers } from "convex-helpers/server/triggers";
Expand Down
2 changes: 1 addition & 1 deletion example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"lint": "tsc -p convex && eslint convex"
},
"dependencies": {
"@convex-dev/table-history": "file:..",
"convex-table-history": "file:..",
"convex": "file:../node_modules/convex",
"convex-helpers": "^0.1.67",
"react": "^18.3.1",
Expand Down
29 changes: 24 additions & 5 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,38 @@ import { api } from "../component/_generated/api";
import type { Serializability } from "../component/lib";

export class TableHistory<DataModel extends GenericDataModel, TableName extends TableNamesInDataModel<DataModel>> {
public options: {
serializability: Serializability;
};
constructor(
public component: UseApi<typeof api>,
public options: {
serializability: Serializability;
options?: {
serializability?: Serializability;
}
) {}
) {
this.options = {
serializability: options?.serializability ?? "wallclock",
};
}

/**
* Write a new history entry.
*
* @argument attribution an arbitrary object that will be stored with the
* history entry. Attribution can include actor/user identity, reason for
* change, etc.
*/
async update(
ctx: RunMutationCtx,
id: GenericId<TableName>,
doc: DocumentByName<DataModel, TableName> | null
doc: DocumentByName<DataModel, TableName> | null,
attribution: unknown = null,
) {
return ctx.runMutation(this.component.lib.update, {
id,
doc,
serializability: this.options.serializability,
attribution,
});
}

Expand Down Expand Up @@ -81,7 +94,13 @@ export class TableHistory<DataModel extends GenericDataModel, TableName extends
*/
trigger<Ctx extends RunMutationCtx>(): Trigger<Ctx, DataModel, TableName> {
return async (ctx, change) => {
await this.update(ctx, change.id, change.newDoc);
let attribution: unknown = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((ctx as any).auth && typeof (ctx as any).auth.getUserIdentity === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
attribution = await (ctx as any).auth.getUserIdentity();
}
await this.update(ctx, change.id, change.newDoc, attribution);
};
}
}
Expand Down
Loading

0 comments on commit abb2f61

Please sign in to comment.