@convex-dev/table-history 0.1.3

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.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +231 -0
  3. package/dist/esm/client/index.d.ts +116 -0
  4. package/dist/esm/client/index.d.ts.map +1 -0
  5. package/dist/esm/client/index.js +93 -0
  6. package/dist/esm/client/index.js.map +1 -0
  7. package/dist/esm/component/_generated/api.d.ts +34 -0
  8. package/dist/esm/component/_generated/api.d.ts.map +1 -0
  9. package/dist/esm/component/_generated/api.js +31 -0
  10. package/dist/esm/component/_generated/api.js.map +1 -0
  11. package/dist/esm/component/_generated/component.d.ts +105 -0
  12. package/dist/esm/component/_generated/component.d.ts.map +1 -0
  13. package/dist/esm/component/_generated/component.js +11 -0
  14. package/dist/esm/component/_generated/component.js.map +1 -0
  15. package/dist/esm/component/_generated/dataModel.d.ts +46 -0
  16. package/dist/esm/component/_generated/dataModel.d.ts.map +1 -0
  17. package/dist/esm/component/_generated/dataModel.js +11 -0
  18. package/dist/esm/component/_generated/dataModel.js.map +1 -0
  19. package/dist/esm/component/_generated/server.d.ts +121 -0
  20. package/dist/esm/component/_generated/server.d.ts.map +1 -0
  21. package/dist/esm/component/_generated/server.js +78 -0
  22. package/dist/esm/component/_generated/server.js.map +1 -0
  23. package/dist/esm/component/convex.config.d.ts +3 -0
  24. package/dist/esm/component/convex.config.d.ts.map +1 -0
  25. package/dist/esm/component/convex.config.js +3 -0
  26. package/dist/esm/component/convex.config.js.map +1 -0
  27. package/dist/esm/component/lib.d.ts +137 -0
  28. package/dist/esm/component/lib.d.ts.map +1 -0
  29. package/dist/esm/component/lib.js +316 -0
  30. package/dist/esm/component/lib.js.map +1 -0
  31. package/dist/esm/component/schema.d.ts +25 -0
  32. package/dist/esm/component/schema.d.ts.map +1 -0
  33. package/dist/esm/component/schema.js +17 -0
  34. package/dist/esm/component/schema.js.map +1 -0
  35. package/dist/esm/react/index.d.ts +2 -0
  36. package/dist/esm/react/index.d.ts.map +1 -0
  37. package/dist/esm/react/index.js +8 -0
  38. package/dist/esm/react/index.js.map +1 -0
  39. package/package.json +84 -0
  40. package/src/client/index.ts +174 -0
  41. package/src/component/_generated/api.ts +50 -0
  42. package/src/component/_generated/component.ts +136 -0
  43. package/src/component/_generated/dataModel.ts +60 -0
  44. package/src/component/_generated/server.ts +161 -0
  45. package/src/component/convex.config.ts +3 -0
  46. package/src/component/lib.test.ts +285 -0
  47. package/src/component/lib.ts +337 -0
  48. package/src/component/schema.ts +17 -0
  49. package/src/react/index.ts +8 -0
@@ -0,0 +1,337 @@
1
+ import { v, Infer, Validator } from "convex/values";
2
+ import { internalMutation, mutation, query, QueryCtx } from "./_generated/server";
3
+ import { paginator } from "convex-helpers/server/pagination";
4
+ import schema from "./schema.js";
5
+ import { paginationOptsValidator } from "convex/server";
6
+ import { Doc } from "./_generated/dataModel";
7
+ import { internal } from "./_generated/api";
8
+
9
+ export const serializabilityValidator = v.union(
10
+ /// "table" serializability means all writes to the table are serialized,
11
+ /// so the timestamps are in causal order. This gives the strictest guarantees
12
+ /// but can cause OCC conflicts if the table updates frequently.
13
+ /// Writes to different tables are not serialized.
14
+ v.literal("table"),
15
+ /// "document" serializability means all writes to the same document are serialized,
16
+ /// but writes to different documents may have out-of-order timestamps.
17
+ v.literal("document"),
18
+ /// "wallclock" serializability means the timestamp is set to the current time
19
+ /// according to the server's clock. This provides no guarantees, but it's
20
+ /// usually in causal order and causes no OCC conflicts.
21
+ /// Wallclock serializability is the default.
22
+ v.literal("wallclock"),
23
+ );
24
+ export type Serializability = Infer<typeof serializabilityValidator>;
25
+
26
+ export const historyEntryValidator = v.object({
27
+ id: v.string(),
28
+ doc: v.any(),
29
+ ts: v.number(),
30
+ isDeleted: v.boolean(),
31
+ attribution: v.any(),
32
+ });
33
+ export type HistoryEntry = Infer<typeof historyEntryValidator>;
34
+
35
+ async function newTimestamp(
36
+ ctx: QueryCtx,
37
+ serializability: Serializability,
38
+ id: string,
39
+ ) {
40
+ switch (serializability) {
41
+ case "table": {
42
+ const latest = await ctx.db.query("history").withIndex("ts").order("desc").first();
43
+ if (latest) {
44
+ return Math.max(latest.ts + 1, Date.now());
45
+ } else {
46
+ return Date.now();
47
+ }
48
+ }
49
+ case "document": {
50
+ const latest = await ctx.db.query("history").withIndex("id", (q) => q.eq("id", id)).order("desc").first();
51
+ if (latest) {
52
+ return Math.max(latest.ts + 1, Date.now());
53
+ } else {
54
+ return Date.now();
55
+ }
56
+ }
57
+ case "wallclock": {
58
+ return Date.now();
59
+ }
60
+ }
61
+ }
62
+
63
+ export const update = mutation({
64
+ args: {
65
+ id: v.string(),
66
+ doc: v.union(v.any(), v.null()),
67
+ serializability: serializabilityValidator,
68
+ attribution: v.any(),
69
+ },
70
+ returns: v.number(),
71
+ handler: async (ctx, args) => {
72
+ let ts = await newTimestamp(ctx, args.serializability, args.id);
73
+ const existing = await ctx.db.query("history").withIndex("id", (q) => q.eq("id", args.id).eq("ts", ts)).first();
74
+ if (existing) {
75
+ ts = existing.ts + 1;
76
+ }
77
+ await ctx.db.insert("history", {
78
+ id: args.id,
79
+ doc: args.doc,
80
+ ts,
81
+ isDeleted: args.doc === null,
82
+ attribution: args.attribution,
83
+ });
84
+ return ts;
85
+ },
86
+ });
87
+
88
+ function paginationResultValidator<T>(itemValidator: Validator<T, "required", string>) {
89
+ return v.object({
90
+ continueCursor: v.string(),
91
+ isDone: v.boolean(),
92
+ page: v.array(itemValidator),
93
+ pageStatus: v.optional(v.union(v.null(), v.literal("SplitRequired"), v.literal("SplitRecommended"))),
94
+ splitCursor: v.optional(v.union(v.null(), v.string())),
95
+ });
96
+ }
97
+ export type PaginationResult<T> = Infer<ReturnType<typeof paginationResultValidator<T>>>;
98
+
99
+ export const listHistory = query({
100
+ args: {
101
+ maxTs: v.number(),
102
+ paginationOpts: paginationOptsValidator,
103
+ },
104
+ returns: paginationResultValidator(historyEntryValidator),
105
+ handler: async (ctx, args) => {
106
+ const results = await paginator(ctx.db, schema)
107
+ .query("history")
108
+ .withIndex("ts", (q) => q.lte("ts", args.maxTs))
109
+ .order("desc")
110
+ .paginate(args.paginationOpts);
111
+ return {
112
+ ...results,
113
+ page: results.page.map(extractHistoryEntry),
114
+ };
115
+ },
116
+ });
117
+
118
+ function extractHistoryEntry(h: Doc<"history">): HistoryEntry {
119
+ return {
120
+ id: h.id,
121
+ doc: h.doc,
122
+ ts: h.ts,
123
+ isDeleted: h.isDeleted,
124
+ attribution: h.attribution,
125
+ };
126
+ }
127
+
128
+ export const listDocumentHistory = query({
129
+ args: {
130
+ id: v.string(),
131
+ maxTs: v.number(),
132
+ paginationOpts: paginationOptsValidator,
133
+ },
134
+ returns: paginationResultValidator(historyEntryValidator),
135
+ handler: async (ctx, args) => {
136
+ const results = await paginator(ctx.db, schema)
137
+ .query("history")
138
+ .withIndex("id", (q) => q.eq("id", args.id).lte("ts", args.maxTs))
139
+ .order("desc")
140
+ .paginate(args.paginationOpts);
141
+ return {
142
+ ...results,
143
+ page: results.page.map(extractHistoryEntry),
144
+ };
145
+ },
146
+ });
147
+
148
+ // Sentinel value for end of cursor.
149
+ const END_CURSOR = "END_CURSOR";
150
+
151
+ export const listSnapshot = query({
152
+ args: {
153
+ snapshotTs: v.number(),
154
+ currentTs: v.number(),
155
+ paginationOpts: paginationOptsValidator,
156
+ },
157
+ returns: paginationResultValidator(historyEntryValidator),
158
+ handler: async (ctx, args) => {
159
+ const pageSize = args.paginationOpts.numItems;
160
+ const page: HistoryEntry[] = [];
161
+ if (args.paginationOpts.cursor === END_CURSOR) {
162
+ return {
163
+ continueCursor: END_CURSOR,
164
+ isDone: true,
165
+ page: [],
166
+ };
167
+ }
168
+ if (pageSize <= 0) {
169
+ throw new Error("pageSize must be positive");
170
+ }
171
+ if (args.currentTs < args.snapshotTs) {
172
+ throw new Error("currentTs must be >= snapshotTs");
173
+ }
174
+ const vacuumed = await ctx.db.query("vacuumed").first();
175
+ if (vacuumed && vacuumed.minTsToKeep > args.snapshotTs) {
176
+ throw new Error("invalid snapshotTs, snapshot has been vacuumed");
177
+ }
178
+ const targetEndCursor = args.paginationOpts.endCursor ?? null;
179
+ let prevId = args.paginationOpts.cursor;
180
+ const allIdsSeen: string[] = [];
181
+ const allIdsBeforeCurrentTs: string[] = [];
182
+ while (allIdsBeforeCurrentTs.length < pageSize || targetEndCursor !== null) {
183
+ const itemWithNextId = await ctx.db.query("history").withIndex("id", (q) =>
184
+ prevId !== null ? q.lt("id", prevId) : q
185
+ ).order("desc").first();
186
+ if (itemWithNextId === null) {
187
+ return {
188
+ continueCursor: END_CURSOR,
189
+ isDone: true,
190
+ page,
191
+ ...maybeSplit(allIdsSeen, pageSize),
192
+ };
193
+ }
194
+ allIdsSeen.push(itemWithNextId.id);
195
+ prevId = itemWithNextId.id;
196
+ if (targetEndCursor !== null && targetEndCursor !== END_CURSOR && itemWithNextId.id < targetEndCursor) {
197
+ // We've reached the end of the page.
198
+ return {
199
+ continueCursor: targetEndCursor,
200
+ isDone: targetEndCursor === END_CURSOR,
201
+ page,
202
+ ...maybeSplit(allIdsSeen, pageSize),
203
+ };
204
+ }
205
+ let revision: Doc<"history"> | null = itemWithNextId;
206
+ if (itemWithNextId.ts > args.snapshotTs) {
207
+ // Find the revision as it existed at args.ts
208
+ const itemAtSnapshotTs = await ctx.db.query("history").withIndex("id", (q) => q.eq("id", itemWithNextId.id).lte("ts", args.snapshotTs)).order("desc").first();
209
+ if (itemAtSnapshotTs === null) {
210
+ // The item doesn't exist in the snapshotTs snapshot.
211
+ // Check if it exists as of currentTs
212
+ const itemAtCurrentTs = await ctx.db.query("history").withIndex("id", (q) => q.eq("id", itemWithNextId.id).lte("ts", args.currentTs)).order("desc").first();
213
+ if (itemAtCurrentTs === null) {
214
+ // It was created after currentTs, so we should treat it like it doesn't exist.
215
+ // prevId has advanced, but it never gets returned.
216
+ continue;
217
+ } else {
218
+ // It was created between snapshotTs and currentTs, so it counts toward the limit and can be in the cursor, but it's not in the page.
219
+ revision = null;
220
+ }
221
+ } else {
222
+ revision = itemAtSnapshotTs;
223
+ }
224
+ }
225
+ if (revision && revision.isDeleted) {
226
+ // If it's deleted, we don't want to include it in the page, but it counts toward the limit and can be in the cursor.
227
+ revision = null;
228
+ }
229
+ allIdsBeforeCurrentTs.push(itemWithNextId.id);
230
+ if (revision) {
231
+ page.push(extractHistoryEntry(revision));
232
+ }
233
+ }
234
+ const output: PaginationResult<HistoryEntry> = {
235
+ continueCursor: allIdsBeforeCurrentTs[allIdsBeforeCurrentTs.length - 1],
236
+ isDone: false,
237
+ page,
238
+ ...maybeSplit(allIdsSeen, pageSize),
239
+ };
240
+ return output;
241
+ },
242
+ });
243
+
244
+ function maybeSplit(allIdsSeen: string[], pageSize: number): {
245
+ splitCursor?: string;
246
+ pageStatus?: "SplitRecommended";
247
+ } {
248
+ if (allIdsSeen.length >= pageSize * 2) {
249
+ return {
250
+ splitCursor: allIdsSeen[pageSize-1],
251
+ pageStatus: "SplitRecommended",
252
+ };
253
+ }
254
+ return {};
255
+ }
256
+
257
+ /**
258
+ * Deletes history of state that was gone (overwritten or deleted) before
259
+ * minTsToKeep.
260
+ *
261
+ * After `vacuumHistory` is called, `listSnapshot` with `ts` before `minTsToKeep` will not
262
+ * necessarily be correct.
263
+ *
264
+ * This mutation does not delete history atomically. It may take a while with
265
+ * async operations.
266
+ *
267
+ * NOTE: `usePaginatedQuery` on `listSnapshot` may yield pages that have gaps or
268
+ * overlap if a reactive query is subscribed when `vacuumHistory` runs.
269
+ */
270
+ export const vacuumHistory = mutation({
271
+ args: {
272
+ minTsToKeep: v.number(),
273
+ },
274
+ handler: async (ctx, args) => {
275
+ // Ensure that no one relies on vacuuming running immediately by waiting
276
+ // 100ms.
277
+ // This also avoids race conditions where `args.minTsToKeep` is so recent
278
+ // that new entries with earlier timestamps are still being added to the
279
+ // history table.
280
+ await ctx.scheduler.runAfter(100, internal.lib.vacuumHistoryRecursive, {
281
+ minTsToKeep: args.minTsToKeep,
282
+ paginationOpts: {
283
+ numItems: 100,
284
+ cursor: null,
285
+ },
286
+ });
287
+ },
288
+ });
289
+
290
+ export const vacuumHistoryRecursive = internalMutation({
291
+ args: {
292
+ minTsToKeep: v.number(),
293
+ paginationOpts: paginationOptsValidator,
294
+ },
295
+ handler: async (ctx, args) => {
296
+ const vacuumed = await ctx.db.query("vacuumed").first();
297
+ const startTs = vacuumed?.minTsToKeep ?? 0;
298
+ if (startTs >= args.minTsToKeep) {
299
+ return;
300
+ }
301
+ const toDelete = await paginator(ctx.db, schema)
302
+ .query("history")
303
+ .withIndex("ts", (q) => q.gt("ts", startTs).lte("ts", args.minTsToKeep))
304
+ .order("asc")
305
+ .paginate(args.paginationOpts);
306
+ let maxTs = startTs;
307
+ for (const h of toDelete.page) {
308
+ const prevRev = await ctx.db.query("history").withIndex("id", (q) => q.eq("id", h.id).lt("ts", h.ts)).order("desc").first();
309
+ if (prevRev !== null) {
310
+ await ctx.db.delete(prevRev._id);
311
+ }
312
+ if (h.isDeleted) {
313
+ await ctx.db.delete(h._id);
314
+ }
315
+ maxTs = Math.max(maxTs, h.ts);
316
+ }
317
+ if (vacuumed === null) {
318
+ await ctx.db.insert("vacuumed", {
319
+ minTsToKeep: maxTs,
320
+ });
321
+ } else {
322
+ await ctx.db.patch(vacuumed._id, {
323
+ minTsToKeep: maxTs,
324
+ });
325
+ }
326
+ if (toDelete.isDone) {
327
+ return;
328
+ }
329
+ await ctx.scheduler.runAfter(0, internal.lib.vacuumHistoryRecursive, {
330
+ minTsToKeep: args.minTsToKeep,
331
+ paginationOpts: {
332
+ numItems: args.paginationOpts.numItems,
333
+ cursor: toDelete.continueCursor,
334
+ }
335
+ });
336
+ },
337
+ });
@@ -0,0 +1,17 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ export default defineSchema({
5
+ history: defineTable({
6
+ id: v.string(),
7
+ ts: v.number(),
8
+ doc: v.union(v.any(), v.null()),
9
+ isDeleted: v.boolean(),
10
+ attribution: v.any(),
11
+ })
12
+ .index("ts", ["ts"])
13
+ .index("id", ["id", "ts"]),
14
+ vacuumed: defineTable({
15
+ minTsToKeep: v.number(),
16
+ }),
17
+ });
@@ -0,0 +1,8 @@
1
+ // This is where React components go.
2
+ if (typeof window === "undefined") {
3
+ throw new Error("this is frontend code, but it's running somewhere else!");
4
+ }
5
+
6
+ export function subtract(a: number, b: number): number {
7
+ return a - b;
8
+ }