@vllnt/convex-idempotency 0.1.0-canary.6abc175

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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/dist/client/index.d.ts +135 -0
  4. package/dist/client/index.d.ts.map +1 -0
  5. package/dist/client/index.js +117 -0
  6. package/dist/client/index.js.map +1 -0
  7. package/dist/client/types.d.ts +78 -0
  8. package/dist/client/types.d.ts.map +1 -0
  9. package/dist/client/types.js +3 -0
  10. package/dist/client/types.js.map +1 -0
  11. package/dist/component/_generated/api.d.ts +40 -0
  12. package/dist/component/_generated/api.d.ts.map +1 -0
  13. package/dist/component/_generated/api.js +31 -0
  14. package/dist/component/_generated/api.js.map +1 -0
  15. package/dist/component/_generated/component.d.ts +65 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -0
  17. package/dist/component/_generated/component.js +11 -0
  18. package/dist/component/_generated/component.js.map +1 -0
  19. package/dist/component/_generated/dataModel.d.ts +46 -0
  20. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  21. package/dist/component/_generated/dataModel.js +11 -0
  22. package/dist/component/_generated/dataModel.js.map +1 -0
  23. package/dist/component/_generated/server.d.ts +121 -0
  24. package/dist/component/_generated/server.d.ts.map +1 -0
  25. package/dist/component/_generated/server.js +78 -0
  26. package/dist/component/_generated/server.js.map +1 -0
  27. package/dist/component/convex.config.d.ts +3 -0
  28. package/dist/component/convex.config.d.ts.map +1 -0
  29. package/dist/component/convex.config.js +7 -0
  30. package/dist/component/convex.config.js.map +1 -0
  31. package/dist/component/crons.d.ts +16 -0
  32. package/dist/component/crons.d.ts.map +1 -0
  33. package/dist/component/crons.js +19 -0
  34. package/dist/component/crons.js.map +1 -0
  35. package/dist/component/mutations.d.ts +91 -0
  36. package/dist/component/mutations.d.ts.map +1 -0
  37. package/dist/component/mutations.js +177 -0
  38. package/dist/component/mutations.js.map +1 -0
  39. package/dist/component/queries.d.ts +9 -0
  40. package/dist/component/queries.d.ts.map +1 -0
  41. package/dist/component/queries.js +22 -0
  42. package/dist/component/queries.js.map +1 -0
  43. package/dist/component/schema.d.ts +26 -0
  44. package/dist/component/schema.d.ts.map +1 -0
  45. package/dist/component/schema.js +21 -0
  46. package/dist/component/schema.js.map +1 -0
  47. package/dist/component/validators.d.ts +80 -0
  48. package/dist/component/validators.d.ts.map +1 -0
  49. package/dist/component/validators.js +42 -0
  50. package/dist/component/validators.js.map +1 -0
  51. package/dist/shared.d.ts +17 -0
  52. package/dist/shared.d.ts.map +1 -0
  53. package/dist/shared.js +17 -0
  54. package/dist/shared.js.map +1 -0
  55. package/package.json +96 -0
  56. package/src/client/index.ts +251 -0
  57. package/src/client/types.ts +87 -0
  58. package/src/component/_generated/api.ts +56 -0
  59. package/src/component/_generated/component.ts +67 -0
  60. package/src/component/_generated/dataModel.ts +60 -0
  61. package/src/component/_generated/server.ts +156 -0
  62. package/src/component/convex.config.ts +9 -0
  63. package/src/component/crons.ts +23 -0
  64. package/src/component/mutations.ts +195 -0
  65. package/src/component/queries.ts +24 -0
  66. package/src/component/schema.ts +21 -0
  67. package/src/component/validators.ts +56 -0
  68. package/src/shared.ts +21 -0
  69. package/src/test.ts +12 -0
@@ -0,0 +1,22 @@
1
+ import { v } from "convex/values";
2
+ import { query } from "./_generated/server";
3
+ import { keyState } from "./validators";
4
+ export const get = query({
5
+ args: { key: v.string(), scope: v.string() },
6
+ returns: v.union(v.null(), keyState),
7
+ handler: async (ctx, args) => {
8
+ const row = await ctx.db
9
+ .query("keys")
10
+ .withIndex("by_scope_key", (q) => q.eq("scope", args.scope).eq("key", args.key))
11
+ .unique();
12
+ if (row === null) {
13
+ return null;
14
+ }
15
+ return {
16
+ status: row.status,
17
+ result: row.result,
18
+ expiresAt: row.expiresAt,
19
+ };
20
+ },
21
+ });
22
+ //# sourceMappingURL=queries.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queries.js","sourceRoot":"","sources":["../../src/component/queries.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,eAAe,CAAC;AAClC,OAAO,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAC5C,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAExC,MAAM,CAAC,MAAM,GAAG,GAAG,KAAK,CAAC;IACvB,IAAI,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE;IAC5C,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,QAAQ,CAAC;IACpC,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QAC3B,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,EAAE;aACrB,KAAK,CAAC,MAAM,CAAC;aACb,SAAS,CAAC,cAAc,EAAE,CAAC,CAAC,EAAE,EAAE,CAC/B,CAAC,CAAC,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAC9C;aACA,MAAM,EAAE,CAAC;QACZ,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;YACjB,OAAO,IAAI,CAAC;QACd,CAAC;QACD,OAAO;YACL,MAAM,EAAE,GAAG,CAAC,MAAM;YAClB,MAAM,EAAE,GAAG,CAAC,MAAM;YAClB,SAAS,EAAE,GAAG,CAAC,SAAS;SACzB,CAAC;IACJ,CAAC;CACF,CAAC,CAAC"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Sandboxed tables — the idempotency ledger's own concern. A `key` is unique
3
+ * within a `scope`; `result` carries the opaque host-owned outcome (never
4
+ * inspected). `expiresAt` is an absolute ms timestamp that bounds the grace
5
+ * window after which a key may be re-minted.
6
+ */
7
+ declare const _default: import("convex/server").SchemaDefinition<{
8
+ keys: import("convex/server").TableDefinition<import("convex/values").VObject<{
9
+ result?: any;
10
+ key: string;
11
+ scope: string;
12
+ status: "inflight" | "done";
13
+ expiresAt: number;
14
+ }, {
15
+ key: import("convex/values").VString<string, "required">;
16
+ scope: import("convex/values").VString<string, "required">;
17
+ status: import("convex/values").VUnion<"inflight" | "done", [import("convex/values").VLiteral<"inflight", "required">, import("convex/values").VLiteral<"done", "required">], "required", never>;
18
+ result: import("convex/values").VAny<any, "optional", string>;
19
+ expiresAt: import("convex/values").VFloat64<number, "required">;
20
+ }, "required", "key" | "scope" | "result" | "status" | "expiresAt" | `result.${string}`>, {
21
+ by_scope_key: ["scope", "key", "_creationTime"];
22
+ by_expires: ["expiresAt", "_creationTime"];
23
+ }, {}, {}>;
24
+ }, true>;
25
+ export default _default;
26
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/component/schema.ts"],"names":[],"mappings":"AAIA;;;;;GAKG;;;;;;;;;;;;;;;;;;;AACH,wBAUG"}
@@ -0,0 +1,21 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ import { jsonValue } from "./validators";
4
+ /**
5
+ * Sandboxed tables — the idempotency ledger's own concern. A `key` is unique
6
+ * within a `scope`; `result` carries the opaque host-owned outcome (never
7
+ * inspected). `expiresAt` is an absolute ms timestamp that bounds the grace
8
+ * window after which a key may be re-minted.
9
+ */
10
+ export default defineSchema({
11
+ keys: defineTable({
12
+ key: v.string(),
13
+ scope: v.string(),
14
+ status: v.union(v.literal("inflight"), v.literal("done")),
15
+ result: v.optional(jsonValue),
16
+ expiresAt: v.number(),
17
+ })
18
+ .index("by_scope_key", ["scope", "key"])
19
+ .index("by_expires", ["expiresAt"]),
20
+ });
21
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/component/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,EAAE,CAAC,EAAE,MAAM,eAAe,CAAC;AAClC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzC;;;;;GAKG;AACH,eAAe,YAAY,CAAC;IAC1B,IAAI,EAAE,WAAW,CAAC;QAChB,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE;QACf,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;QACjB,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QACzD,MAAM,EAAE,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;QAC7B,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;KACtB,CAAC;SACC,KAAK,CAAC,cAAc,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;SACvC,KAAK,CAAC,YAAY,EAAE,CAAC,WAAW,CAAC,CAAC;CACtC,CAAC,CAAC"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Opaque host-owned outcome stored against a completed key. The component never
3
+ * inspects it — last-resort arbitrary data, aliased here rather than left bare
4
+ * in function signatures. The host narrows it at the {@link Idempotency} client
5
+ * boundary via an optional `resultValidator`.
6
+ *
7
+ * This is the single documented `v.any()` escape hatch in the component; the
8
+ * lint rule `convex-rules/no-bare-v-any` is satisfied by routing every arbitrary
9
+ * payload through this alias instead of a bare `v.any()`.
10
+ */
11
+ export declare const jsonValue: import("convex/values").VAny<any, "required", string>;
12
+ /**
13
+ * Result of {@link begin}. `fresh` — the key was just minted, the caller should
14
+ * do the work and then `complete`. `inflight` — another attempt holds the key;
15
+ * `expiresAt` is when the lease frees and `retryAfterMs` how long to back off
16
+ * before retrying. `done` — a prior attempt already completed; `result` carries
17
+ * the recorded outcome for a short-circuit replay.
18
+ */
19
+ export declare const beginResult: import("convex/values").VUnion<{
20
+ state: "fresh";
21
+ } | {
22
+ state: "inflight";
23
+ expiresAt: number;
24
+ retryAfterMs: number;
25
+ } | {
26
+ result?: any;
27
+ state: "done";
28
+ }, [import("convex/values").VObject<{
29
+ state: "fresh";
30
+ }, {
31
+ state: import("convex/values").VLiteral<"fresh", "required">;
32
+ }, "required", "state">, import("convex/values").VObject<{
33
+ state: "inflight";
34
+ expiresAt: number;
35
+ retryAfterMs: number;
36
+ }, {
37
+ state: import("convex/values").VLiteral<"inflight", "required">;
38
+ expiresAt: import("convex/values").VFloat64<number, "required">;
39
+ retryAfterMs: import("convex/values").VFloat64<number, "required">;
40
+ }, "required", "state" | "expiresAt" | "retryAfterMs">, import("convex/values").VObject<{
41
+ result?: any;
42
+ state: "done";
43
+ }, {
44
+ state: import("convex/values").VLiteral<"done", "required">;
45
+ result: import("convex/values").VAny<any, "optional", string>;
46
+ }, "required", "result" | "state" | `result.${string}`>], "required", "result" | "state" | "expiresAt" | "retryAfterMs" | `result.${string}`>;
47
+ /**
48
+ * Result of {@link complete}. `recorded: true` — the outcome was written.
49
+ * `recorded: false` — the claim was lost; `reason` distinguishes a never-claimed
50
+ * key (`missing`), a lease that expired before completion (`expired`), and a key
51
+ * already marked done by a prior attempt (`already_done`). A host that sees
52
+ * `recorded: false` knows its work finished but the ledger row was gone.
53
+ */
54
+ export declare const completeResult: import("convex/values").VUnion<{
55
+ recorded: true;
56
+ } | {
57
+ recorded: false;
58
+ reason: "missing" | "expired" | "already_done";
59
+ }, [import("convex/values").VObject<{
60
+ recorded: true;
61
+ }, {
62
+ recorded: import("convex/values").VLiteral<true, "required">;
63
+ }, "required", "recorded">, import("convex/values").VObject<{
64
+ recorded: false;
65
+ reason: "missing" | "expired" | "already_done";
66
+ }, {
67
+ recorded: import("convex/values").VLiteral<false, "required">;
68
+ reason: import("convex/values").VUnion<"missing" | "expired" | "already_done", [import("convex/values").VLiteral<"missing", "required">, import("convex/values").VLiteral<"expired", "required">, import("convex/values").VLiteral<"already_done", "required">], "required", never>;
69
+ }, "required", "recorded" | "reason">], "required", "recorded" | "reason">;
70
+ /** Stored projection of a key returned by {@link get}. */
71
+ export declare const keyState: import("convex/values").VObject<{
72
+ result?: any;
73
+ status: "inflight" | "done";
74
+ expiresAt: number;
75
+ }, {
76
+ status: import("convex/values").VUnion<"inflight" | "done", [import("convex/values").VLiteral<"inflight", "required">, import("convex/values").VLiteral<"done", "required">], "required", never>;
77
+ result: import("convex/values").VAny<any, "optional", string>;
78
+ expiresAt: import("convex/values").VFloat64<number, "required">;
79
+ }, "required", "result" | "status" | "expiresAt" | `result.${string}`>;
80
+ //# sourceMappingURL=validators.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../../src/component/validators.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AACH,eAAO,MAAM,SAAS,uDAAU,CAAC;AAEjC;;;;;;GAMG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;6IAQvB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;0EAU1B,CAAC;AAEF,0DAA0D;AAC1D,eAAO,MAAM,QAAQ;;;;;;;;sEAInB,CAAC"}
@@ -0,0 +1,42 @@
1
+ import { v } from "convex/values";
2
+ /**
3
+ * Opaque host-owned outcome stored against a completed key. The component never
4
+ * inspects it — last-resort arbitrary data, aliased here rather than left bare
5
+ * in function signatures. The host narrows it at the {@link Idempotency} client
6
+ * boundary via an optional `resultValidator`.
7
+ *
8
+ * This is the single documented `v.any()` escape hatch in the component; the
9
+ * lint rule `convex-rules/no-bare-v-any` is satisfied by routing every arbitrary
10
+ * payload through this alias instead of a bare `v.any()`.
11
+ */
12
+ export const jsonValue = v.any();
13
+ /**
14
+ * Result of {@link begin}. `fresh` — the key was just minted, the caller should
15
+ * do the work and then `complete`. `inflight` — another attempt holds the key;
16
+ * `expiresAt` is when the lease frees and `retryAfterMs` how long to back off
17
+ * before retrying. `done` — a prior attempt already completed; `result` carries
18
+ * the recorded outcome for a short-circuit replay.
19
+ */
20
+ export const beginResult = v.union(v.object({ state: v.literal("fresh") }), v.object({
21
+ state: v.literal("inflight"),
22
+ expiresAt: v.number(),
23
+ retryAfterMs: v.number(),
24
+ }), v.object({ state: v.literal("done"), result: v.optional(jsonValue) }));
25
+ /**
26
+ * Result of {@link complete}. `recorded: true` — the outcome was written.
27
+ * `recorded: false` — the claim was lost; `reason` distinguishes a never-claimed
28
+ * key (`missing`), a lease that expired before completion (`expired`), and a key
29
+ * already marked done by a prior attempt (`already_done`). A host that sees
30
+ * `recorded: false` knows its work finished but the ledger row was gone.
31
+ */
32
+ export const completeResult = v.union(v.object({ recorded: v.literal(true) }), v.object({
33
+ recorded: v.literal(false),
34
+ reason: v.union(v.literal("missing"), v.literal("expired"), v.literal("already_done")),
35
+ }));
36
+ /** Stored projection of a key returned by {@link get}. */
37
+ export const keyState = v.object({
38
+ status: v.union(v.literal("inflight"), v.literal("done")),
39
+ result: v.optional(jsonValue),
40
+ expiresAt: v.number(),
41
+ });
42
+ //# sourceMappingURL=validators.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validators.js","sourceRoot":"","sources":["../../src/component/validators.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,eAAe,CAAC;AAElC;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;AAEjC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAChC,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,EACvC,CAAC,CAAC,MAAM,CAAC;IACP,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC;IAC5B,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;IACrB,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE;CACzB,CAAC,EACF,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,CACtE,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CACnC,CAAC,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,EACvC,CAAC,CAAC,MAAM,CAAC;IACP,QAAQ,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC;IAC1B,MAAM,EAAE,CAAC,CAAC,KAAK,CACb,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,EACpB,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,EACpB,CAAC,CAAC,OAAO,CAAC,cAAc,CAAC,CAC1B;CACF,CAAC,CACH,CAAC;AAEF,0DAA0D;AAC1D,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,CAAC,MAAM,CAAC;IAC/B,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACzD,MAAM,EAAE,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;IAC7B,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;CACtB,CAAC,CAAC"}
@@ -0,0 +1,17 @@
1
+ /** Shared constants used by both `client/` and `component/`. */
2
+ export declare const COMPONENT_NAME = "idempotency";
3
+ /** Default namespace when the host does not scope a key. */
4
+ export declare const DEFAULT_SCOPE = "global";
5
+ /**
6
+ * Default inflight lease: 60 seconds, in milliseconds. Short by design so a
7
+ * crashed worker's claim self-heals quickly and the key can be re-minted.
8
+ */
9
+ export declare const DEFAULT_INFLIGHT_TTL_MS = 60000;
10
+ /**
11
+ * Default done grace window: 24 hours, in milliseconds. How long a recorded
12
+ * outcome replays before the key may be re-minted.
13
+ */
14
+ export declare const DEFAULT_DONE_TTL_MS = 86400000;
15
+ /** Default page size for a `purge` pass before the sweep self-reschedules. */
16
+ export declare const DEFAULT_PURGE_BATCH = 200;
17
+ //# sourceMappingURL=shared.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAEhE,eAAO,MAAM,cAAc,gBAAgB,CAAC;AAE5C,4DAA4D;AAC5D,eAAO,MAAM,aAAa,WAAW,CAAC;AAEtC;;;GAGG;AACH,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,mBAAmB,WAAa,CAAC;AAE9C,8EAA8E;AAC9E,eAAO,MAAM,mBAAmB,MAAM,CAAC"}
package/dist/shared.js ADDED
@@ -0,0 +1,17 @@
1
+ /** Shared constants used by both `client/` and `component/`. */
2
+ export const COMPONENT_NAME = "idempotency";
3
+ /** Default namespace when the host does not scope a key. */
4
+ export const DEFAULT_SCOPE = "global";
5
+ /**
6
+ * Default inflight lease: 60 seconds, in milliseconds. Short by design so a
7
+ * crashed worker's claim self-heals quickly and the key can be re-minted.
8
+ */
9
+ export const DEFAULT_INFLIGHT_TTL_MS = 60_000;
10
+ /**
11
+ * Default done grace window: 24 hours, in milliseconds. How long a recorded
12
+ * outcome replays before the key may be re-minted.
13
+ */
14
+ export const DEFAULT_DONE_TTL_MS = 86_400_000;
15
+ /** Default page size for a `purge` pass before the sweep self-reschedules. */
16
+ export const DEFAULT_PURGE_BATCH = 200;
17
+ //# sourceMappingURL=shared.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared.js","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAEhE,MAAM,CAAC,MAAM,cAAc,GAAG,aAAa,CAAC;AAE5C,4DAA4D;AAC5D,MAAM,CAAC,MAAM,aAAa,GAAG,QAAQ,CAAC;AAEtC;;;GAGG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAC;AAE9C;;;GAGG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,UAAU,CAAC;AAE9C,8EAA8E;AAC9E,MAAM,CAAC,MAAM,mBAAmB,GAAG,GAAG,CAAC"}
package/package.json ADDED
@@ -0,0 +1,96 @@
1
+ {
2
+ "name": "@vllnt/convex-idempotency",
3
+ "version": "0.1.0-canary.6abc175",
4
+ "description": "Exactly-once idempotency key ledger for retried operations, as a Convex component",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "packageManager": "pnpm@9.15.4",
8
+ "files": [
9
+ "dist",
10
+ "src"
11
+ ],
12
+ "exports": {
13
+ "./package.json": "./package.json",
14
+ ".": {
15
+ "types": "./dist/client/index.d.ts",
16
+ "default": "./dist/client/index.js"
17
+ },
18
+ "./test": "./src/test.ts",
19
+ "./_generated/component.js": {
20
+ "types": "./dist/component/_generated/component.d.ts"
21
+ },
22
+ "./_generated/component": {
23
+ "types": "./dist/component/_generated/component.d.ts"
24
+ },
25
+ "./convex.config": {
26
+ "types": "./dist/component/convex.config.d.ts",
27
+ "default": "./dist/component/convex.config.js"
28
+ },
29
+ "./convex.config.js": {
30
+ "types": "./dist/component/convex.config.d.ts",
31
+ "default": "./dist/component/convex.config.js"
32
+ }
33
+ },
34
+ "types": "./dist/client/index.d.ts",
35
+ "module": "./dist/client/index.js",
36
+ "scripts": {
37
+ "build": "tsc --project ./tsconfig.build.json",
38
+ "build:codegen": "pnpm convex codegen --component-dir ./src/component && pnpm build",
39
+ "build:clean": "rm -rf dist *.tsbuildinfo && pnpm build:codegen",
40
+ "typecheck": "tsc --noEmit",
41
+ "typecheck:ci": "tsc --noEmit --project tsconfig.ci.json",
42
+ "lint": "eslint .",
43
+ "test": "vitest run --passWithNoTests",
44
+ "test:watch": "vitest --typecheck --clearScreen false",
45
+ "test:coverage": "vitest run --coverage --coverage.reporter=text",
46
+ "generate:llms": "node scripts/generate-llms.mjs",
47
+ "preversion": "pnpm install --frozen-lockfile && pnpm build && pnpm test:coverage && pnpm typecheck && pnpm generate:llms",
48
+ "prepublishOnly": "npm whoami || npm login",
49
+ "alpha": "npm version prerelease --preid alpha && npm publish --tag alpha && git push --follow-tags",
50
+ "release": "npm version patch && npm publish && git push --follow-tags"
51
+ },
52
+ "dependencies": {},
53
+ "peerDependencies": {
54
+ "convex": "^1.41.0"
55
+ },
56
+ "devDependencies": {
57
+ "@edge-runtime/vm": "^5.0.0",
58
+ "@vitest/coverage-v8": "^4.1.8",
59
+ "@vllnt/eslint-config": "^1.0.0",
60
+ "@vllnt/typescript": "^1.0.0",
61
+ "convex": "^1.41.0",
62
+ "convex-test": "^0.0.53",
63
+ "eslint": "^9.0.0",
64
+ "prettier": "^3.4.0",
65
+ "typescript": "^5.7.0",
66
+ "vitest": "^4.1.8"
67
+ },
68
+ "homepage": "https://github.com/vllnt/convex-idempotency#readme",
69
+ "bugs": {
70
+ "url": "https://github.com/vllnt/convex-idempotency/issues"
71
+ },
72
+ "author": {
73
+ "name": "bntvllnt",
74
+ "url": "https://bntvllnt.com"
75
+ },
76
+ "funding": {
77
+ "type": "github",
78
+ "url": "https://github.com/sponsors/bntvllnt"
79
+ },
80
+ "repository": {
81
+ "type": "git",
82
+ "url": "https://github.com/vllnt/convex-idempotency"
83
+ },
84
+ "engines": {
85
+ "node": ">=18"
86
+ },
87
+ "publishConfig": {
88
+ "access": "public"
89
+ },
90
+ "sideEffects": false,
91
+ "keywords": [
92
+ "convex",
93
+ "convex-component",
94
+ "idempotency"
95
+ ]
96
+ }
@@ -0,0 +1,251 @@
1
+ import type {
2
+ FunctionArgs,
3
+ FunctionReference,
4
+ FunctionReturnType,
5
+ } from "convex/server";
6
+ import type {
7
+ BeginResult,
8
+ CompleteResult,
9
+ IdempotencyOptions,
10
+ KeyState,
11
+ ResultParser,
12
+ } from "./types.js";
13
+ import {
14
+ DEFAULT_DONE_TTL_MS,
15
+ DEFAULT_INFLIGHT_TTL_MS,
16
+ DEFAULT_PURGE_BATCH,
17
+ DEFAULT_SCOPE,
18
+ } from "../shared.js";
19
+
20
+ /**
21
+ * The component's raw `begin` return, before the client narrows `result`. The
22
+ * stored outcome is opaque here (`unknown`); the {@link Idempotency} client runs
23
+ * the host `resultValidator` over it at its typed boundary.
24
+ */
25
+ type RawBegin =
26
+ | { state: "fresh" }
27
+ | { state: "inflight"; expiresAt: number; retryAfterMs: number }
28
+ | { state: "done"; result?: unknown };
29
+
30
+ /**
31
+ * The idempotency component's function references, as exposed on the host via
32
+ * `components.idempotency`. The host's stored outcome type is opaque here
33
+ * (`unknown`); the {@link Idempotency} client narrows it to `TResult` at its
34
+ * own typed boundary.
35
+ */
36
+ export interface IdempotencyComponent {
37
+ mutations: {
38
+ begin: FunctionReference<
39
+ "mutation",
40
+ "internal",
41
+ { key: string; scope: string; inflightTtlMs: number },
42
+ RawBegin
43
+ >;
44
+ complete: FunctionReference<
45
+ "mutation",
46
+ "internal",
47
+ {
48
+ key: string;
49
+ scope: string;
50
+ result?: unknown;
51
+ doneTtlMs: number;
52
+ upsertOnMissing: boolean;
53
+ },
54
+ CompleteResult
55
+ >;
56
+ purge: FunctionReference<
57
+ "mutation",
58
+ "internal",
59
+ { before?: number; batch: number },
60
+ number
61
+ >;
62
+ };
63
+ queries: {
64
+ get: FunctionReference<
65
+ "query",
66
+ "internal",
67
+ { key: string; scope: string },
68
+ {
69
+ status: "inflight" | "done";
70
+ result?: unknown;
71
+ expiresAt: number;
72
+ } | null
73
+ >;
74
+ };
75
+ }
76
+
77
+ interface RunQueryCtx {
78
+ runQuery<Q extends FunctionReference<"query", "internal">>(
79
+ reference: Q,
80
+ args: FunctionArgs<Q>,
81
+ ): Promise<FunctionReturnType<Q>>;
82
+ }
83
+
84
+ interface RunMutationCtx {
85
+ runMutation<M extends FunctionReference<"mutation", "internal">>(
86
+ reference: M,
87
+ args: FunctionArgs<M>,
88
+ ): Promise<FunctionReturnType<M>>;
89
+ }
90
+
91
+ /** Per-call overrides for `scope` and the inflight lease. */
92
+ interface BeginOptions {
93
+ scope?: string;
94
+ inflightTtlMs?: number;
95
+ }
96
+
97
+ /** Per-call overrides for `scope`, the done grace window, and lost-claim upsert. */
98
+ interface CompleteOptions {
99
+ scope?: string;
100
+ doneTtlMs?: number;
101
+ upsertOnMissing?: boolean;
102
+ }
103
+
104
+ /**
105
+ * Consumer-facing client for the exactly-once idempotency key ledger. The host
106
+ * owns meaning and auth; it passes an opaque `key` and an optional `scope`, and
107
+ * stores an arbitrary `TResult` outcome that replays return verbatim. Pass a
108
+ * `resultValidator` to narrow the opaque stored value to `TResult` at the
109
+ * boundary — there is no unchecked cast.
110
+ *
111
+ * @typeParam TResult - The host's stored outcome type (defaults to `unknown`).
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * const idem = new Idempotency(components.idempotency, {
116
+ * resultValidator: v.object({ chargeId: v.string() }).parse,
117
+ * });
118
+ * const r = await idem.begin(ctx, requestId);
119
+ * if (r.state === "done") return r.result; // typed replay
120
+ * if (r.state === "inflight") throw new Error(`retry in ${r.retryAfterMs}ms`);
121
+ * const out = await doWork(); // r.state === "fresh"
122
+ * const done = await idem.complete(ctx, requestId, out);
123
+ * if (!done.recorded) log.warn("claim lost", done.reason); // work ran, row gone
124
+ * ```
125
+ */
126
+ export class Idempotency<TResult = unknown> {
127
+ private readonly defaultScope: string;
128
+ private readonly defaultInflightTtlMs: number;
129
+ private readonly defaultDoneTtlMs: number;
130
+ private readonly defaultUpsertOnMissing: boolean;
131
+ private readonly resultValidator: ResultParser<TResult> | undefined;
132
+
133
+ constructor(
134
+ private readonly component: IdempotencyComponent,
135
+ options: IdempotencyOptions<TResult> = {},
136
+ ) {
137
+ this.defaultScope = options.defaultScope ?? DEFAULT_SCOPE;
138
+ this.defaultInflightTtlMs =
139
+ options.defaultInflightTtlMs ?? DEFAULT_INFLIGHT_TTL_MS;
140
+ this.defaultDoneTtlMs = options.defaultDoneTtlMs ?? DEFAULT_DONE_TTL_MS;
141
+ this.defaultUpsertOnMissing = options.upsertOnMissing ?? false;
142
+ this.resultValidator = options.resultValidator;
143
+ }
144
+
145
+ private scopeOf(scope: string | undefined): string {
146
+ return scope ?? this.defaultScope;
147
+ }
148
+
149
+ /**
150
+ * Narrow an opaque stored `result` to `TResult` via the host validator. Absent
151
+ * values pass through untouched; with no validator the value is returned as-is
152
+ * (the host accepted the unchecked-type tradeoff by omitting one).
153
+ */
154
+ private parseResult(value: unknown): TResult | undefined {
155
+ if (value === undefined) {
156
+ return undefined;
157
+ }
158
+ if (this.resultValidator === undefined) {
159
+ return value as TResult;
160
+ }
161
+ return this.resultValidator(value);
162
+ }
163
+
164
+ /**
165
+ * Claim `key`. `fresh` mints an inflight key (do the work, then `complete`);
166
+ * `inflight` means a concurrent attempt holds it — back off `retryAfterMs`;
167
+ * `done` returns the validated recorded `result` for a short-circuit replay.
168
+ */
169
+ async begin(
170
+ ctx: RunMutationCtx,
171
+ key: string,
172
+ opts: BeginOptions = {},
173
+ ): Promise<BeginResult<TResult>> {
174
+ const r: RawBegin = await ctx.runMutation(this.component.mutations.begin, {
175
+ key,
176
+ scope: this.scopeOf(opts.scope),
177
+ inflightTtlMs: opts.inflightTtlMs ?? this.defaultInflightTtlMs,
178
+ });
179
+ if (r.state === "done") {
180
+ return { state: "done", result: this.parseResult(r.result) };
181
+ }
182
+ return r;
183
+ }
184
+
185
+ /**
186
+ * Mark `key` `done`, recording `result` and extending the done grace window.
187
+ * Returns a discriminated outcome: `recorded: true` on success, or
188
+ * `recorded: false` with a `reason` when the claim was lost (missing, expired,
189
+ * or already done) so the host can react to dropped work. `result` is validated
190
+ * against the host validator before it is stored.
191
+ */
192
+ complete(
193
+ ctx: RunMutationCtx,
194
+ key: string,
195
+ result?: TResult,
196
+ opts: CompleteOptions = {},
197
+ ): Promise<CompleteResult> {
198
+ const validated =
199
+ result === undefined ? undefined : this.parseResult(result);
200
+ return ctx.runMutation(this.component.mutations.complete, {
201
+ key,
202
+ scope: this.scopeOf(opts.scope),
203
+ result: validated,
204
+ doneTtlMs: opts.doneTtlMs ?? this.defaultDoneTtlMs,
205
+ upsertOnMissing: opts.upsertOnMissing ?? this.defaultUpsertOnMissing,
206
+ });
207
+ }
208
+
209
+ /** The stored state for `key`, or `null` if no key is held in the scope. */
210
+ async get(
211
+ ctx: RunQueryCtx,
212
+ key: string,
213
+ scope?: string,
214
+ ): Promise<KeyState<TResult> | null> {
215
+ const row = await ctx.runQuery(this.component.queries.get, {
216
+ key,
217
+ scope: this.scopeOf(scope),
218
+ });
219
+ if (row === null) {
220
+ return null;
221
+ }
222
+ return {
223
+ status: row.status,
224
+ result: this.parseResult(row.result),
225
+ expiresAt: row.expiresAt,
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Delete expired keys in bounded batches, oldest first. `before` defaults to
231
+ * the server clock; `batch` caps each pass and the sweep self-reschedules until
232
+ * the tail is clean. Returns the count removed in the first pass.
233
+ */
234
+ purge(
235
+ ctx: RunMutationCtx,
236
+ opts: { before?: number; batch?: number } = {},
237
+ ): Promise<number> {
238
+ return ctx.runMutation(this.component.mutations.purge, {
239
+ before: opts.before,
240
+ batch: opts.batch ?? DEFAULT_PURGE_BATCH,
241
+ });
242
+ }
243
+ }
244
+
245
+ export type {
246
+ BeginResult,
247
+ CompleteResult,
248
+ IdempotencyOptions,
249
+ KeyState,
250
+ ResultParser,
251
+ };