@knpkv/herdr-work 0.8.1 → 0.9.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/store.js CHANGED
@@ -2812,7 +2812,15 @@ export class WorkStore {
2812
2812
  * succeeds), so a writer that planned from the head cannot bury a newer
2813
2813
  * owner checkpoint.
2814
2814
  */
2815
- appendAt = Effect.fn("WorkStore.append")(function* (event, guard) {
2815
+ /**
2816
+ * Appends one checkpoint. With `mode` `check`, every check runs in the same
2817
+ * transaction and it is rolled back instead of written: the checkpoint is
2818
+ * returned when it would have been appended.
2819
+ */
2820
+ appendAt = Effect.fn("WorkStore.append")(function* (event, guard, mode = "write",
2821
+ // In a check, the checkpoints earlier steps of the same plan would have
2822
+ // written: limits are checked as if they were already stored.
2823
+ planned = []) {
2816
2824
  const decoded = yield* Schema.decodeUnknownEffect(WorkGoalCheckpoint)(event).pipe(Effect.mapError(storeError("append.decode")));
2817
2825
  // The reconciler's event and activity ids are its provenance; no other
2818
2826
  // writer may use them.
@@ -2827,11 +2835,12 @@ export class WorkStore {
2827
2835
  const decision = yield* Effect.try({
2828
2836
  try: () => {
2829
2837
  let transaction = false;
2838
+ const abort = () => this.#database.exec("ROLLBACK");
2830
2839
  try {
2831
2840
  this.#database.exec("BEGIN IMMEDIATE");
2832
2841
  transaction = true;
2833
2842
  const reject = (error) => {
2834
- this.#database.exec("ROLLBACK");
2843
+ abort();
2835
2844
  transaction = false;
2836
2845
  return { _tag: "rejected", error };
2837
2846
  };
@@ -2840,7 +2849,7 @@ export class WorkStore {
2840
2849
  WHERE event_id = ? OR (goal_id = ? AND occurred_at = ?)`)
2841
2850
  .all(decoded.eventId, decoded.goal.id, decoded.occurredAt)).map(({ record }) => Schema.decodeUnknownSync(WorkGoalCheckpoint)(JSON.parse(record)));
2842
2851
  if (collisions.length > 0 && collisions.every((existing) => Equal.equals(existing, decoded))) {
2843
- this.#database.exec("ROLLBACK");
2852
+ abort();
2844
2853
  transaction = false;
2845
2854
  return { _tag: "replayed", event: decoded };
2846
2855
  }
@@ -2868,7 +2877,7 @@ export class WorkStore {
2868
2877
  }));
2869
2878
  }
2870
2879
  const count = Schema.decodeUnknownSync(CountRow)(this.#database.prepare("SELECT COUNT(*) AS count FROM work_goal_events").get()).count;
2871
- if (count + 1 > workHistoryMaxEvents - guard.reserve) {
2880
+ if (count + planned.length + 1 > workHistoryMaxEvents - guard.reserve) {
2872
2881
  return reject(new WorkProjectionError({
2873
2882
  cause: count,
2874
2883
  detail: `the reconciler leaves ${guard.reserve} of ${workHistoryMaxEvents} checkpoints free`,
@@ -2884,14 +2893,15 @@ export class WorkStore {
2884
2893
  }));
2885
2894
  }
2886
2895
  const eventCount = Schema.decodeUnknownSync(CountRow)(this.#database.prepare("SELECT COUNT(*) AS count FROM work_goal_events").get()).count;
2887
- if (eventCount >= workHistoryMaxEvents) {
2896
+ if (eventCount + planned.length >= workHistoryMaxEvents) {
2888
2897
  return reject(new WorkProjectionError({
2889
2898
  cause: decoded,
2890
2899
  detail: `work history cannot exceed ${workHistoryMaxEvents} checkpoints`,
2891
2900
  reason: "capacity_exceeded"
2892
2901
  }));
2893
2902
  }
2894
- const history = Schema.decodeUnknownSync(StoredEventRows)(this.#database.prepare("SELECT record FROM work_goal_events").all()).map(({ record }) => Schema.decodeUnknownSync(WorkGoalCheckpoint)(JSON.parse(record)));
2903
+ const history = Schema.decodeUnknownSync(StoredEventRows)(this.#database.prepare("SELECT record FROM work_goal_events").all()).map(({ record }) => Schema.decodeUnknownSync(WorkGoalCheckpoint)(JSON.parse(record)))
2904
+ .concat(planned);
2895
2905
  const familyError = validateGoalFamilyHistory([...history, decoded]);
2896
2906
  if (familyError !== undefined)
2897
2907
  return reject(familyError);
@@ -2930,6 +2940,11 @@ export class WorkStore {
2930
2940
  }));
2931
2941
  }
2932
2942
  }
2943
+ if (mode === "check") {
2944
+ abort();
2945
+ transaction = false;
2946
+ return { _tag: "checked" };
2947
+ }
2933
2948
  const result = this.#database.prepare("INSERT INTO work_goal_events (event_id, goal_id, occurred_at, record) VALUES (?, ?, ?, ?)").run(decoded.eventId, decoded.goal.id, decoded.occurredAt, JSON.stringify(decoded));
2934
2949
  // The reconciler's own record of what it wrote: its authorship and its
2935
2950
  // once-per-goal rule come from this table, never from an id's spelling.
@@ -2942,7 +2957,7 @@ export class WorkStore {
2942
2957
  }
2943
2958
  catch (error) {
2944
2959
  if (transaction)
2945
- this.#database.exec("ROLLBACK");
2960
+ abort();
2946
2961
  throw error;
2947
2962
  }
2948
2963
  },
@@ -2952,6 +2967,8 @@ export class WorkStore {
2952
2967
  return yield* decision.error;
2953
2968
  if (decision._tag === "replayed")
2954
2969
  return decision.event;
2970
+ if (decision._tag === "checked")
2971
+ return decoded;
2955
2972
  if (decision.changes !== 1 && decision.changes !== 1n) {
2956
2973
  return yield* storeError("append.insert.count")(decision.changes);
2957
2974
  }
@@ -4041,7 +4058,11 @@ export class WorkStore {
4041
4058
  reconcilerEvents
4042
4059
  };
4043
4060
  });
4044
- reconcile = Effect.fn("WorkStore.reconcile")(function* (request) {
4061
+ /**
4062
+ * The steps `reconcile` takes, shared by `reconcile` (`write`) and
4063
+ * `planReconcile` (`check`, which runs every check and writes nothing).
4064
+ */
4065
+ reconcileSteps = Effect.fn("WorkStore.reconcileSteps")(function* (request, mode) {
4045
4066
  const options = yield* Schema.decodeUnknownEffect(WorkReconcileOptions)(request).pipe(Effect.mapError(storeError("reconcile.options")));
4046
4067
  const source = yield* this.snapshotInput();
4047
4068
  if (workHistoryMaxEvents - source.events.length < workReconcilerHeadroom) {
@@ -4063,7 +4084,13 @@ export class WorkStore {
4063
4084
  const stamped = new Map(source.reconcilerEvents.map(({ eventId, goalId }) => [goalId, eventId]));
4064
4085
  const cryptoService = this.#cryptoService;
4065
4086
  const now = yield* Clock.currentTimeMillis;
4066
- const appendAt = (checkpoint, guard) => this.appendAt(checkpoint, guard);
4087
+ // A plan holds no transaction between steps: each check rolls back at once,
4088
+ // and later steps are checked against the checkpoints earlier ones planned.
4089
+ const planned = [];
4090
+ const appendAt = (checkpoint, guard) => this.appendAt(checkpoint, guard, mode, planned).pipe(Effect.tap(() => Effect.sync(() => {
4091
+ if (mode === "check")
4092
+ planned.push(checkpoint);
4093
+ })));
4067
4094
  return yield* Effect.forEach(terminalCandidates(source.events, source.facts, source.failures, new Set(options.confirmed.map(({ observationId, subject }) => `${subject}\u0000${observationId}`))), Effect.fnUntraced(function* (candidate) {
4068
4095
  const goalId = candidate.head.goal.id;
4069
4096
  const previous = stamped.get(goalId);
@@ -4076,7 +4103,7 @@ export class WorkStore {
4076
4103
  // holds the reconciler's id, a timestamp past the bound) is left for
4077
4104
  // its owner: reported as a conflict, never failing the other goals.
4078
4105
  if (Result.isFailure(checkpoint))
4079
- return { _tag: "conflict", goalId };
4106
+ return { _tag: "conflict", goalId, reason: "checkpoint" };
4080
4107
  return yield* appendAt(checkpoint.success, {
4081
4108
  fact: { observationId: candidate.fact.observationId, subject: candidate.fact.subject },
4082
4109
  head: candidate.head,
@@ -4085,10 +4112,40 @@ export class WorkStore {
4085
4112
  _tag: "applied",
4086
4113
  eventId,
4087
4114
  goalId,
4088
- state: candidate.pullRequest.state === "merged" ? "completed" : "abandoned"
4089
- }), Effect.catchTag("WorkGoalRevisionConflictError", () => Effect.succeed({ _tag: "conflict", goalId })));
4115
+ state: candidate.pullRequest.state === "merged" ? "completed" : "abandoned",
4116
+ candidate
4117
+ }), Effect.catchTag("WorkGoalRevisionConflictError", () => Effect.succeed({ _tag: "conflict", goalId, reason: "revision" })));
4090
4118
  }));
4091
4119
  });
4120
+ reconcile = Effect.fn("WorkStore.reconcile")(function* (request) {
4121
+ const steps = yield* this.reconcileSteps(request, "write");
4122
+ return steps.map((step) => step._tag === "applied"
4123
+ ? { _tag: "applied", eventId: step.eventId, goalId: step.goalId, state: step.state }
4124
+ : step._tag === "recorded"
4125
+ ? step
4126
+ : { _tag: "conflict", goalId: step.goalId });
4127
+ });
4128
+ /**
4129
+ * What `reconcile` would do now with the same confirmations, writing
4130
+ * nothing: every check it makes before writing runs, each in a transaction
4131
+ * that is rolled back at once, with each step checked against the
4132
+ * checkpoints earlier steps planned.
4133
+ */
4134
+ planReconcile = Effect.fn("WorkStore.planReconcile")(function* (request) {
4135
+ const steps = yield* this.reconcileSteps(request, "check");
4136
+ return steps.map((step) => step._tag === "applied"
4137
+ ? {
4138
+ _tag: "would_apply",
4139
+ eventId: step.eventId,
4140
+ goalId: step.goalId,
4141
+ state: step.state,
4142
+ subject: step.candidate.fact.subject,
4143
+ observationId: step.candidate.fact.observationId,
4144
+ goalEventId: step.candidate.head.eventId,
4145
+ goalUpdatedAt: step.candidate.head.goal.updatedAt
4146
+ }
4147
+ : step);
4148
+ });
4092
4149
  observe = Effect.fn("WorkStore.observe")(function* (envelopes) {
4093
4150
  const decoded = yield* Schema.decodeUnknownEffect(Schema.Array(WorkObservationEnvelope).check(Schema.isMaxLength(workObservedFactMaxRecords)))(envelopes).pipe(Effect.mapError(storeError("observe.decode")));
4094
4151
  const cryptoService = this.#cryptoService;