feat: own the sync watermark in an itk_data_api_modified column - #20
Conversation
Leantime core does not maintain its `modified` column on every write path: timesheets saved through ON DUPLICATE KEY UPDATE leave it untouched, and tickets and milestones were filtered on `ticket.date` — the creation date — so edits to existing entities never reached consumers at all. The plugin now owns `itk_data_api_modified` on zp_projects, zp_tickets, zp_timesheets and zp_user, maintained by database triggers so no write path can bypass it. All modifiedAfter filtering and every `modified` value in a response come from that column; core's own column is left alone. Installing stamps existing rows, so the first sync after install returns everything once. The DDL moves out of the service into a SchemaRepository that executes one statement at a time, so a failure past the first is reported instead of swallowed, and is idempotent — reinstalling no longer throws on the delete triggers it left behind. Timestamps are written with UTC_TIMESTAMP() rather than NOW(), which would record the session timezone's clock; the delete triggers now stamp dateDeleted the same way. Users gain a `modified` field, and WorkerData accepts a null name — CONCAT of the first and last name is NULL for a user without a surname, which used to fail the whole /users response.
|
Reviewed locally. Reproduced the stated verification in the compose container: 30 tests, 259 assertions, and The design holds up and the PR body is unusually candid about its own risks — most of what I would normally raise on a schema change is already in the Risks section. The items below are what remains after discussing the alternatives (see the note at the bottom for what those discussions closed). 1.
|
turegjorup
left a comment
There was a problem hiding this comment.
Approved. See comment for minor issues flagged in review
…lumn # Conflicts: # Model/WorkerData.php # Repositories/ApiDataRepository.php # tests/Model/WorkerDataTest.php # tests/Service/APIDataTest.php
Extracted the per-table install sequence into installStatements() so the order it depends on is pinned by a test rather than by a comment, and moved the index after the backfill — it was being built over an all-NULL column and then rewritten entry by entry. Gave the deletion tables a migration path. CREATE TABLE IF NOT EXISTS never reaches a database that already has the table, so changes now go in deletedTableAlterStatements(), which both populations run. First of them drops the dateDeleted default: the triggers make it unreachable, but the tables outlive an uninstall and the triggers do not. Wrote down the precondition the design rests on — install and update happen with the site down — and corrected the backfill docblock, which claimed to heal rows written while the triggers were absent when it only recovers inserts.
Ticket
https://leantime.itkdev.dk/#/tickets/showTicket/8000
Description
Base branch is
feature/8000-sync-errors(#18), notdevelop.This supersedes #17 — close it unmerged. Its
ticket.date→ticket.modifiedfix and its implicit-nullable parameter fixes are both included here.
Why
The
modifiedAftercontract says: give me everything that changed since thiswatermark. That rested on Leantime core's
modifiedcolumn, which core does notmaintain on every write path.
ON DUPLICATE KEY UPDATE(addTime,upsertTimesheetEntry,punchOut) leavemodifieduntouched, so time editedfrom the weekly grid never reached consumers.
ticket.date— the creation date —so editing an existing ticket never showed up in a sync at all.
NULLor"0000-00-00 00:00:00", which amodified >= ?predicate silently drops.
Economics invoices off this data, so a missed edit is a wrong invoice.
What changed
The plugin now owns
itk_data_api_modifiedonzp_projects,zp_tickets,zp_timesheetsandzp_user, maintained by database triggers. A trigger cannotbe bypassed — not by core, not by this plugin, not by manual SQL. Core's own
modifiedcolumn is left completely alone.The JSON field is still called
modified, sourced from the new column, soconsumers need no change.
usersgains amodifiedfield, so all fourentity types now expose a watermark.
Alongside that, the DDL moved out of
Services/APIData.phpinto a newRepositories/SchemaRepository.php:multi-statement string and executed it once, so PDO reported only the first
statement's error and every later failure was swallowed.
install()is idempotent. Leantime callsinstall()on every install andoffers no separate upgrade hook, so it is both installer and migrator. Column
and index existence are checked through
information_schema; every trigger isdropped with
IF EXISTSbefore being created. This also fixes reinstalling,which used to throw on the delete triggers the previous install left behind
(swallowed by
installPluginintoreport($e)+return false).methods, and the tests pin the properties that would otherwise only fail in
production.
Two smaller fixes carried along:
WorkerDatawas missed by #18's nullabilitysweep —
CONCAT(firstname, ' ', lastname)returnsNULLfor a user with nosurname, which failed the whole
/usersresponse — andAPIData::getWorkers()was passing a fifth argument to a four-parameter repository method.
Why triggers rather than
DATETIME ... ON UPDATE CURRENT_TIMESTAMPAn auto-updating column would need no triggers at all, but
ON UPDATE CURRENT_TIMESTAMPwrites the session timezone's clock — the exact ambiguitythat makes the current handling unreliable. A trigger writes
UTC_TIMESTAMP()explicitly, so every value means one thing regardless of DB, PHP or Leantime
timezone config. The read side already parses as UTC. For the same reason, the
delete triggers now stamp
dateDeletedexplicitly instead of relying on thecolumn's
DEFAULT NOW().MySQL 8.4
Production is MySQL 8.4, so no
CREATE OR REPLACE TRIGGERand noADD COLUMN IF NOT EXISTS— both MariaDB-only, and what #14 was rejected for.There is a test asserting no statement uses either.
Risks
stamps every existing row with the install time, by design: the alternative is
guessing at a watermark for rows that never had a reliable one.
operation — core writes use explicit column lists, so a nullable extra column
is ignored — but a future core migration that rebuilds a
zp_*table coulddrop it. Recovery is a plugin reinstall, which re-adds and re-backfills.
BEFORE UPDATEtrigger fires on any row write,including ones that change nothing the API exposes.
zp_useris the notablecase:
Auth::updateUserSession()writeslastlogin,sessionandsessiontimeon login, andinvalidateSession()writes on logout, so a userre-syncs on every login. There is also a
KeepAliveendpoint calling the sameupdate — worth checking on production whether the frontend polls it, since
that would make active users re-sync continuously. Over-syncing beats missing
updates, which is the trade-off already accepted in Keep timesheets modified current via insert/update triggers #14, but it is worth
knowing the volume.
dateDeletedvalues keep their local-time stamps. Only newdeletions are UTC. On a
Europe/Copenhagenserver the historical rows are1–2 hours ahead, so a consumer polling
/deletedmay re-see one historicaldeletion once around the switchover. Deletions are idempotent, so this is
cosmetic.
UPDATEinside theplugin-install request. Worth timing against a production-sized dump before
installing; batch by id range if it is slow.
Verification
task lintandtask testpass — 30 tests, 259 assertions.Not yet done, and needing a real Leantime instance:
SHOW TRIGGERS(expect 11) andSELECT COUNT(*) FROM zp_timesheets WHERE itk_data_api_modified IS NULL(expect 0).
install()on an already-installed database: must not throw, must notduplicate the index, must not re-stamp rows that already have a value.
Uninstall and reinstall: triggers return, column and data survive.
itk_data_api_modifiedadvanceswhile core's
modifiedstays put, and that the entry comes back from/apidata/api/timesheets./apidata/api/tickets— the fix: fixed modifiedAfter for tickets and milestones #17 case.confirm the returned
modifiedmatches wall-clock UTC.Two placeholders to settle before opening it: the CHANGELOG links to PR-20 (correct it if GitHub assigns another number), and the KeepAlive polling question is stated as something to check
rather than a claim — I found the endpoint at app/Domain/Auth/Controllers/KeepAlive.php:38 but no frontend caller in the checkout.