Sales order keys already carry client and location; refund and charge keys do not. That is why two clients configured on the same Square location share one entity: the refund's owner flips every time either client imports, and a single charge ends up referenced by both clients' orders. Scoping the keys the same way makes contention structurally impossible — each client gets its own entity. The hazard is the cutover. These ids are :db.unique/identity and the import relies on upsert-by-identity, so changing the key format alone would match nothing and Datomic would create a SECOND entity for every refund and charge, orphaning the originals under their legacy keys. square.core3/existing-id resolves the entity explicitly, scoped key first and legacy key second, and pins the result as :db/id so the write lands on the existing entity whichever scheme it currently carries. All three construction sites are covered: order tenders, refunds, and the payout path, which mints bare charge stubs from an external id alone. The migration job re-keys whatever the importer has not yet touched. It recovers scope from the referencing sales order or expected deposit for the ~12.8% of charges that carry neither :charge/client nor :charge/location, detects already-scoped entities by comparing against the key they should have rather than pattern-matching ids that may themselves contain dashes, and is therefore idempotent and re-runnable over a partially migrated database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
107 lines
5.2 KiB
Clojure
107 lines
5.2 KiB
Clojure
(ns auto-ap.jobs.rekey-square-external-ids
|
|
"One-shot migration re-keying Square refunds and charges to client-scoped external ids.
|
|
|
|
Refund and charge keys carry no client scoping today, so two clients configured on the same
|
|
Square location share a single entity: the refund's owner flips every time either client
|
|
imports, and one charge ends up referenced by both clients' orders. Sales orders already scope
|
|
their keys by client and location; this brings the other two in line.
|
|
|
|
Run AFTER the importer knows how to resolve both key schemes (`square.core3/existing-id`).
|
|
Running it first would be harmless, but the importer would then re-create legacy-keyed
|
|
entities on its next pass.
|
|
|
|
The migration is idempotent: an entity already carrying its scoped key is skipped, so it can
|
|
be re-run over a partially migrated database."
|
|
(:require
|
|
[auto-ap.datomic :refer [conn]]
|
|
[auto-ap.logging :as alog]
|
|
[datomic.api :as dc]))
|
|
|
|
(def refund-prefix "square/refund/")
|
|
(def charge-prefix "square/charge/")
|
|
|
|
(defn- scope-of
|
|
"`[client-code location]` for an entity, or nil when it cannot be determined.
|
|
|
|
Charges are the awkward case: about an eighth of them carry neither `:charge/client` nor
|
|
`:charge/location`. Those are stubs minted by the payout path, which asserts an external id
|
|
alone and lets unique-identity upsert bring a bare entity into being, plus older tender
|
|
records that predate the client attribute. None are orphaned, so the scope is recovered from
|
|
whatever references them — the sales order first, then the expected deposit."
|
|
[db attr e]
|
|
(let [ent (dc/entity db e)
|
|
pair (fn [code loc] (when (and code loc) [code loc]))]
|
|
(or (case attr
|
|
:sales-refund/external-id (pair (:client/code (:sales-refund/client ent))
|
|
(:sales-refund/location ent))
|
|
:charge/external-id (pair (:client/code (:charge/client ent))
|
|
(:charge/location ent)))
|
|
(when-let [o (:e (first (dc/datoms db :vaet e :sales-order/charges)))]
|
|
(let [oe (dc/entity db o)]
|
|
(pair (:client/code (:sales-order/client oe)) (:sales-order/location oe))))
|
|
(when-let [d (:e (first (dc/datoms db :vaet e :expected-deposit/charges)))]
|
|
(let [de (dc/entity db d)]
|
|
(pair (:client/code (:expected-deposit/client de)) (:expected-deposit/location de)))))))
|
|
|
|
(defn planned-key
|
|
"`[eid new-key]` for an entity that still needs re-keying, or nil when it is already scoped or
|
|
cannot be scoped at all.
|
|
|
|
Detection compares against the key this entity *should* have rather than pattern-matching the
|
|
id, because Square ids may themselves contain dashes and no pattern separates the two schemes
|
|
reliably. That also makes the migration idempotent."
|
|
[db attr prefix datom]
|
|
(let [old (:v datom)]
|
|
(when-let [[code loc] (scope-of db attr (:e datom))]
|
|
(let [scoped-prefix (str prefix code "-" loc "-")]
|
|
(when-not (.startsWith ^String old scoped-prefix)
|
|
[(:e datom) (str scoped-prefix (subs old (count prefix)))])))))
|
|
|
|
(defn plan
|
|
"Everything the migration would change, plus what it cannot touch. Read-only — run this and
|
|
check `:collisions` is empty before transacting anything."
|
|
[db attr prefix]
|
|
(let [acc (reduce (fn [acc d]
|
|
(let [acc (update acc :total inc)]
|
|
(if-let [[e new-key] (planned-key db attr prefix d)]
|
|
(-> acc
|
|
(update :to-migrate inc)
|
|
(update :new-keys conj! [e new-key]))
|
|
(if (scope-of db attr (:e d))
|
|
(update acc :already-scoped inc)
|
|
(update acc :unscopable inc)))))
|
|
{:total 0 :to-migrate 0 :already-scoped 0 :unscopable 0 :new-keys (transient [])}
|
|
(dc/datoms db :aevt attr))]
|
|
(update acc :new-keys persistent!)))
|
|
|
|
(defn collisions
|
|
"Any two entities that would land on the same new key. Must be empty: a collision would merge
|
|
two entities into one and lose whichever lost."
|
|
[new-keys]
|
|
(->> new-keys
|
|
(group-by second)
|
|
(keep (fn [[k es]] (when (> (count es) 1) [k (mapv first es)])))
|
|
vec))
|
|
|
|
(defn migrate!
|
|
"Asserts the new external id on each planned entity. The attribute is cardinality one, so the
|
|
legacy value is retracted by the same assertion and the entity keeps its identity — nothing is
|
|
created and nothing is deleted.
|
|
|
|
Returns the number of entities re-keyed."
|
|
[attr new-keys batch-size]
|
|
(let [total (count new-keys)]
|
|
(alog/info ::migrating :attr attr :count total)
|
|
(doseq [[i batch] (map-indexed vector (partition-all batch-size new-keys))]
|
|
@(dc/transact conn (for [[e new-key] batch] {:db/id e attr new-key}))
|
|
(when (zero? (mod i 20))
|
|
(alog/info ::migrated :attr attr :done (* i batch-size) :of total)))
|
|
total))
|
|
|
|
(defn counts
|
|
"Entity totals, for the before/after assertion that is this migration's real safety net: if
|
|
either number moves, the re-key created duplicates instead of updating in place."
|
|
[db]
|
|
{:refunds (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :sales-refund/external-id))
|
|
:charges (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :charge/external-id))})
|