The test declared its own minimal label struct. With
VIKUNJA_TESTS_USE_CONFIG the test engine is the database every other test
package shares, so syncing that partial struct created `labels` without
`created_by_id` and left a row in it. Every package running afterwards
then failed to add the column as NOT NULL.
The upload was spooled to the receiving instance's temp dir and only its
name travelled with the event. That holds while events stay in-process,
but it ties the job to the instance that accepted the upload.
Store it through the files package on the configured backend and record
its id on the migration status, so the event carries only the status id
and any instance can run the import. The job copies it to a local temp
file first: the zip importer needs random access, which an S3 object body
cannot give.
The upload is removed once the import is over. An hourly cron deletes
leftovers from events lost on restart or instances that died mid-import.
A finished migration_status row looked the same whether the import worked
or failed, so the client could only guess. Rows now carry error_kind, a
closed set the client translates (reported, interrupted, credentials,
queue, upload, detail), and error_message, set only for "detail": the
error caused by the user's own data, the same text the failure mail sends.
A failure we report to Sentry stores just the kind, so server internals
never reach the status.
The claim timeout was measured from started_at, so a long import either
kept a dead claim for a day (the old 24h default) or had its slot taken
over while still running. A running migration now refreshes heartbeat_at
at a tenth of the timeout, clamped to 1s..30s, and a claim only counts as
stale once its last beat is older than the timeout. Rows that never beat
fall back to started_at. The default timeout drops to 5m accordingly.
Also bind the stale cutoff in the configured timezone: xorm writes times in
the engine's zone but binds query args as-is, which broke the comparison on
SQLite.
Upgrades from before v2.5.0 ran 20260720120000 before 20260829190000, so the
index recovery tried to create the unique subscriptions index from the current
model while duplicate rows still existed and aborted the upgrade.
Fixes#3882
Each project gets a monotonic high-water counter row, backfilled from the
highest existing task index, and an append-only alias table recording
retired (project, index) addresses. Project creation seeds the counter,
project deletion removes both.
Backfills from projects in memory, replacing one batch of 500 projects
at a time: a re-run after an interrupted run converges without the
whole table ever being empty under a replica which already serves from
it. A parent cycle or a missing parent ends the chain there.
Nullable on purpose: existing rows have no plaintext to hash and get
backfilled lazily on first use, and new tokens no longer write the
PBKDF2 salt/hash/last-eight columns. SQLite cannot drop NOT NULL, so
the table is rebuilt there inside one transaction; the rebuild is
skipped when token_sha256 already exists so a re-run cannot null it.
The unique index is created through the dialect so its name matches
what xorm's own Sync would produce on every database.
Postgres folds unquoted identifiers to lowercase. `DROP INDEX IF EXISTS
IDX_tasks_done_due_date` therefore looked for `idx_tasks_done_due_date` and
silently matched nothing whenever the index carried the mixed-case name xorm
gives it, and the companion CREATE stored the new index under a lowercase name
that a later sync would not recognise, creating a second copy.
Both statements now go through the dialect quoter, with CREATE built by
`CreateIndexSQL` so the name matches what xorm derives from the struct tag.
v2.6.0's migration had the same bug, so on postgres the old index is actually
named `idx_tasks_done_due_date` there. Drop both spellings.
Also fixes the test helper, which called XName on index names DBMetas had
already returned in full and so compared against a double-prefixed
`IDX_tasks_idx_tasks_project_done_due_date`.
The overview's task query filters `project_id IN (...) AND done = false` and
sorts by due_date. `done` is not selective, so with an index on
(done, due_date) the planner walks it in due_date order and heap-probes every
open task in the database just to test project_id.
Leading with project_id lets the scan seek to the user's projects and top-N
sort a small set instead.
The old index is dropped rather than kept: its only other consumer is the daily
overdue-reminder cron, whose selective predicate is the due_date range that
IDX_tasks_due_date already covers.
ProjectID moves above Done in the Task struct because xorm orders composite
index columns by struct field order, which also shifts project_id earlier in
the serialized JSON — hence the churn in the task collection test's expected
response bodies.
The recursive step of the project access CTE joins parent_project_id
against a project id, which implies NOT NULL. Storing root projects as
NULL instead of 0 lets a partial index cover only real children, so the
index scan walks a fraction of the rows.
MySQL has no partial indexes and keeps the existing full index.
The opt-out design assumes at most one row per (entity_type, entity_id, user_id),
but the table never had a constraint saying so - the original migration declared
only plain indexes. Delete inserts as well as deletes now, so concurrent requests
could write duplicates.
Existing duplicates are cleared first, keeping the lowest id. That loses nothing:
muted ships in this same release and defaults to false, so every duplicate today
is a set of identical subscriptions left by the old check-then-insert race.
The unique(entity_user) tag gives fresh installs the same constraint through the
model sync; the index name matches what xorm derives from it so a later sync does
not create a second copy.
The home overview filters done = false over a due_date range and sorts by
due_date, but tasks only had single-column indexes. A composite lets the
planner walk the index in due_date order and stop at the LIMIT instead of
sorting every open task.
Leaves out a third `id DESC` column: DESC-direction indexes are awkward
across MySQL/PostgreSQL/SQLite and ties on an exact due_date are rare
enough for incremental sort to handle.
SQLite supports ALTER TABLE DROP COLUMN since 3.35 and the bundled
driver ships 3.50.x, so dropTableColum no longer needs to skip SQLite
with a warning that leaves dead columns behind.
Views broken by the bug above keep their state until something writes
them again, so repair them on startup: set the manual mode, seed the
default buckets when the view has none and place the project's tasks in
the default bucket.
Views whose project or saved filter is gone are skipped rather than
repaired with a dangling creator, soft-deleted tasks stay out of the
backfill, and the mode flip is the last write per view so an
interrupted run picks the view up again.
createTask becomes a wrapper around createTasks, which creates a whole
batch in one pass: project, creator, views and default buckets are
looked up once, indexes are assigned from a single max-index query
(preset indexes kept when free, collisions get the next free one), and
explicitly provided buckets are resolved once per distinct bucket —
verified to belong to the target project and checked against their
limit with the batch's own members counted. The row insert stays per
task because multi-row inserts don't reliably return autoincrement ids
on all supported databases.
Validation errors always carry the payload index; the single-create
wrapper unwraps them so existing callers keep their raw error types.
A new TasksBatchCreatedEvent fires once per batch (single create is a
batch of one) for listeners which can process all new tasks in one
pass; per-task TaskCreatedEvent semantics stay unchanged for webhooks,
mentions and audit.
Split for reviewability: builds together with the follow-up commit
adding the BulkTaskCreation model and error types.
349e6a590 renamed ListCreatedNotification's name from list.created to
project.created without rewriting the notifications.name column, so instances
upgraded from 0.19 or earlier still hold list.created rows carrying a full
project payload. Nothing can hydrate them, so nothing can scope them either,
and the read paths pass unrecognised names through unfiltered — leaking the
project's title, description, identifier, colour and owner to users since
removed from it.
Retaining rows no type can render only preserves that leak, so they go.
Notification rows outlived access. A subscription survives a project being
unshared, so every notification already written for a revoked user stayed
readable — comment bodies, task titles, project names, deletion notices. The
read paths filtered on notifiable_id alone, with no permission check anywhere.
#3325 stopped the sender writing new ones; this is the other half.
The project a notification is about is persisted on the row when it is written
and the read paths filter on it in SQL, so LIMIT, OFFSET and total are all
computed on the filtered set. Notification types declare their project through
a capability interface in pkg/notifications, the same way they already declare
SubjectID, ThreadID and ToTitle — which is what lets the package below
pkg/models stay ignorant of what a project is.
project_id 0 means account-scoped and always visible, a positive value is
checked against the projects the caller can read, and -1 marks a project-scoped
row whose project could not be determined, so it is visible to nobody. Filtering
reuses the existing accessibleProjectIDsSubquery, so the page query and the
count cannot drift apart. A migration backfills existing rows from their stored
payloads, resolving through soft-deleted tasks so task.deleted rows still land
on their project.
Covers every read path: the v1 and v2 list endpoints, mark-as-read (which
echoes the payload back), the Atom feed, and the websocket push — the last of
which is load-bearing, since a row is still written for a revoked subscriber.
Deliberately no instance-admin bypass: notifications are always the caller's
own, and being an admin says nothing about whether they should still read a
comment out of a project they were removed from.
Recreates model-declared indexes that are missing, matching by column
set so converted databases with differently-named equivalent indexes
are left alone. Fails with an actionable message if duplicate values
block recreating a unique index.
Fixes#3244
xorm's Sync drops every index and unique constraint the synced struct
doesn't declare (#3244). Adds a partialSync helper (no drops) and
converts all 65 existing-table struct syncs to it — the four v2.4.0
migrations that wiped users/tasks indexes on every upgrade, plus every
historical one back to 2019, each a latent index wipe and a pgloader
2BP01 abort point for installs upgrading from versions predating it.
Every task creation looks for rows with the same position in the view to
resolve conflicts. Without an index on position that scans all positions of
the view, so creating n tasks in one project costs O(n²) row reads - the
second half of the slow import in #3297.
A pair returned by the GroupBy was just reported as duplicated, so a row
must exist. Continuing on !has would let the delete loop drop every row
for that pair without re-inserting one, silently losing positions. Abort
the migration instead.
A task could end up with more than one task_positions row for the same
(task_id, project_view_id): rapid/concurrent creation raced the
check-then-insert paths, and the create path could insert a position that
a triggered RecalculateTaskPositions had already persisted for the new
task. The table had no unique constraint, so the duplicates were stored
silently (#2844).
In the table view this made the LEFT JOIN on task_positions emit the task
twice; getTasksForProjects enriched only the map entry, so the duplicate
slice row kept an empty identifier and rendered as "#N" instead of
"PREFIX-N" (#2725).
- Add a unique index on task_positions(task_id, project_view_id) via a
dedup migration (mirrors the task_buckets fix in 20250624092830) plus the
unique(task_view) struct tag so fresh installs get it too.
- Harden the create path: only queue a position insert when one does not
already exist for the task+view, and dedupe within the batch.
- Dedupe the task slice returned by getTasksForProjects by id, returning
the enriched entry, so duplicate position rows can never surface a task
twice or with a missing identifier.
Fixes#2844Fixes#2725
Implement the license validation system with:
- Server communication with retry logic and exponential backoff
- In-memory state management for feature flags and user limits
- Cached validation with 72h expiry stored in database
- Background goroutine with adaptive check intervals (24h/1h)
- Graceful degradation to community mode on failure
- Instance ID generation and persistence
Add the OAuthCode model for storing short-lived authorization codes
with PKCE challenges. Codes are hashed (SHA-256) before storage and
are single-use with a 10-minute expiry. Add the database migration
and OAuth-specific error types.
MySQL does not support CREATE INDEX IF NOT EXISTS syntax. Switch on
database type to use IF NOT EXISTS for Postgres/SQLite and plain
CREATE INDEX with duplicate key error suppression for MySQL.
Fixes#2431
Add user_id column to webhooks table (nullable, for user-level webhooks
vs project-level). Extend webhook model, permissions, and listener to
support user-level webhooks that fire for user-directed events like
task reminders and overdue task notifications.
Add TasksOverdueEvent for dispatching overdue notifications via webhooks.
Update webhook permissions to handle both user-level and project-level
ownership. Add webhook test fixture and register webhooks table in test
fixture loader.