Server comments describe the code to a stranger (#6221)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 13s
Android / Build the server image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 1m30s
CI & Build / Build & push image (push) Successful in 32s

Rule 32 "Comments describe the product to a stranger" sweep over
src/, alembic/ and tests/. Comments and docstrings now say what the code
does and the reason as a present-tense constraint, briefly.

Removed:
- issue, task, note, rule, milestone and family-idea references
- "used to" / before-and-after narration
- quotes of decisions and incident stories from the deploying instance
- paragraph-length arguments, where a line or two carries the fact

Kept every hazard as a present-tense fact, e.g. timing oracles, the
proxy hop direction, and migrations that must stay frozen.

Also:
- Three test names lose "still"/"used to"/"pre_m304" phrasing.
- pyproject's description drops "FabledSword family".

The history these comments carried stays in Scribe and in git log.
A comment-stripping diff (Scribe snippet #6226 "codeonly.py — prove a
diff changed only comments") shows no code changed apart from the
renamed tests and the description string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:07:47 -04:00
co-authored by Claude Opus 5.5
parent 5556bb2136
commit a51a563fe7
108 changed files with 779 additions and 1610 deletions
+2 -2
View File
@@ -4,8 +4,8 @@ Revision ID: 0001
Revises:
Create Date: 2026-07-19
The M0 identity + sharing-ACL spine. No notes yet (those arrive in M1); the
shares table is polymorphic so notes reuse it without a schema retrofit.
Identity and the sharing ACL. The shares table is polymorphic, so any shareable
entity uses it.
"""
from alembic import op
import sqlalchemy as sa
+1 -1
View File
@@ -4,7 +4,7 @@ Revision ID: 0002
Revises: 0001
Create Date: 2026-07-19
The M1 capture core: the notes table the masonry board renders.
The notes table the board renders.
"""
from alembic import op
import sqlalchemy as sa
+1 -1
View File
@@ -5,7 +5,7 @@ Revises: 0002
Create Date: 2026-07-19
Adds the admin role bit and the DB-backed settings table. No rows are seeded —
defaults live in the code registry (thoughtsync.settings), so an empty table
defaults live in the code registry (inkwell.settings), so an empty table
means every setting is at its default.
"""
from alembic import op
+2 -4
View File
@@ -16,10 +16,8 @@ depends_on = None
def upgrade() -> None:
# The note's display NAME: explicit title if set, else the first body line.
op.add_column("notes", sa.Column("display_title", sa.Text(), nullable=False, server_default=""))
# Best-effort backfill (kept intentionally simple + unambiguous — CI has no
# Postgres lane, so this only runs on a real deploy). The app recomputes the
# precise value via derive_display_title on the next save (which additionally
# skips leading blank lines). Capped at 200 chars.
# Best-effort backfill, capped at 200 chars. The app recomputes the exact value
# (derive_display_title, which also skips leading blank lines) on the next save.
op.execute(
r"""
UPDATE notes SET display_title = LEFT(btrim(
+1 -4
View File
@@ -1,4 +1,4 @@
"""sync revision + tombstone infrastructure (M8 sync hub, step 1)
"""sync revision + tombstone infrastructure
Revision ID: 0015
Revises: 0014
@@ -11,9 +11,6 @@ forget to bump it. Child-row changes (items / attachments / label membership) bu
their parent note's revision, since a note syncs as a whole. `purged_at` is the
hard-delete tombstone (distinct from `deleted_at` = recoverable trash) so an
offline client learns a row is gone instead of resurrecting it.
No API behavior changes here — this is pure infrastructure. Triggers/behavior are
operator-verified on deploy (no Postgres CI lane).
"""
from alembic import op
import sqlalchemy as sa
+1 -1
View File
@@ -1,4 +1,4 @@
"""device_tokens (M8 sync hub, step 2)
"""device_tokens
Revision ID: 0016
Revises: 0015
+1 -1
View File
@@ -1,4 +1,4 @@
"""labels.updated_at (M8 sync hub, step 4 — LWW field for the label catalog)
"""labels.updated_at, the last-write-wins field for the label catalog
Revision ID: 0017
Revises: 0016
+1 -1
View File
@@ -1,4 +1,4 @@
"""note_attachments.sha256 (M8 sync hub, step 5 — attachment blob sync)
"""note_attachments.sha256, for attachment blob sync
Revision ID: 0018
Revises: 0017
+1 -1
View File
@@ -1,4 +1,4 @@
"""note_attachments.filename (any-file attachments — M6 1900)
"""note_attachments.filename, for attachments of any file type
Revision ID: 0019
Revises: 0018
+1 -1
View File
@@ -1,4 +1,4 @@
"""note_link_previews (URL unfurl — M6 1901)
"""note_link_previews, cached URL unfurls
Revision ID: 0020
Revises: 0019
+1 -1
View File
@@ -1,4 +1,4 @@
"""saved_filters (richer search + saved views — M6 1902)
"""saved_filters, saved search views
Revision ID: 0021
Revises: 0020
+1 -1
View File
@@ -1,4 +1,4 @@
"""notes.recurrence (recurring reminders — M6 1908)
"""notes.recurrence, for recurring reminders
Revision ID: 0022
Revises: 0021
+3 -6
View File
@@ -1,14 +1,11 @@
"""note_links.target_id — resolve [[links]] to a note, not to a string (M13 step 1)
"""note_links.target_id — resolve [[links]] to a note, not to a string
Revision ID: 0023
Revises: 0022
Create Date: 2026-08-22
A wiki-link stored only as normalized TEXT means a note's name IS the edge: rename
the note and every inbound link stops matching. The old answer was to rewrite the
`[[Old Name]]` text inside every note that linked to it — workable while an explicit
title existed to hold still, untenable once a note's name is just its first body
line (M13).
A wiki-link stored only as normalized text makes a note's name the edge, so renaming
the note breaks every inbound link. Resolving to an id survives a rename.
`target_norm` stays: it is what an UNRESOLVED link carries, since linking to a note
that doesn't exist yet is a supported way to create one.
+5 -13
View File
@@ -1,22 +1,14 @@
"""drop note_links — [[wiki-links]] are removed (note 2897)
"""drop note_links — [[wiki-links]] are removed
Revision ID: 0024
Revises: 0023
Create Date: 2026-08-22
ThoughtSync is an intermediary surface for capture and recall; a linking system is
organization, which is not what it is for. Backlinks, the graph and the name index
went with it.
0023 stays in the chain: removing an applied revision would strand a database's
alembic_version pointer.
0023 (which added `note_links.target_id`) is deliberately left in the chain rather
than deleted. It shipped in an image and may already be applied, and removing an
applied revision would strand a database's alembic_version pointer. So the column is
dropped here along with the table it lived on, and the history stays honest about the
fact that it existed for a day.
No down-migration data concern: note_links was always DERIVED from note bodies. The
`[[text]]` is still sitting in every body it was written in; nothing a person typed is
lost by this.
note_links was derived from note bodies, so nothing typed is lost; the `[[text]]` is
still in every body.
"""
from alembic import op
import sqlalchemy as sa
+5 -14
View File
@@ -1,19 +1,11 @@
"""drop notes.kind — a checklist is something a note HAS (M13 step 2)
"""drop notes.kind — a checklist is something a note has
Revision ID: 0025
Revises: 0024
Create Date: 2026-08-22
`kind` was never a type: a plain TEXT column with no enum and no CHECK, compared
against a hardcoded ("text", "list") tuple in six places. `note_items` was always an
ordinary child table keyed by note_id, serialization always emitted `items` whatever
the kind, and the Android editor already toggled between the two losslessly. The
storage has modelled "a body plus optional checkable items" the whole time; only the
gates forbade it.
Nothing is lost. Items were already rows in their own table, and a note that was
`kind = 'list'` keeps every one of them — it just stops being a different sort of
thing from the note next to it.
A note is a body plus optional checkable items. Items are rows in their own table,
so a `kind = 'list'` note keeps every one.
"""
from alembic import op
import sqlalchemy as sa
@@ -29,7 +21,6 @@ def upgrade() -> None:
def downgrade() -> None:
# server_default so existing rows get a value; every note comes back as 'text',
# which is right — a restored note with items would previously have hidden its
# body, and there is no record of which ones were once lists.
# server_default so existing rows get a value. Every note comes back as 'text':
# nothing records which were lists.
op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text"))
+6 -25
View File
@@ -4,33 +4,14 @@ Revision ID: 0026
Revises: 0025
Create Date: 2026-08-22
M13 step 3. A note is a body plus optional checkable items; its NAME is the first
non-empty line of that body, falling back to its first checklist item. There is no
separate field to type into, and `display_title` (already persisted, already what
search results and export filenames read) carries the name.
A note's name is its first non-empty body line, carried by `display_title`.
## The search vector has to be rebuilt, not just left alone
`notes.search_vector` is a stored generated column over `title` (0005, weight A), and
Postgres won't drop a column a generated one depends on. So it is recreated over
`display_title`, keeping the name ranked above the body. That recomputes every row
and rebuilds the GIN index, which takes a while on a large table.
`notes.search_vector` is a STORED GENERATED column whose expression names `title`
(migration 0005, weight A) — Postgres will refuse to drop a column another generated
column depends on, and even if it didn't, the weighting would be wrong. So it is
dropped and recreated over `display_title` instead, which keeps the original
intent: the note's NAME ranks above the rest of its body.
Rebuilding a stored generated column re-computes every row, and the GIN index is
rebuilt with it. On a personal instance that is milliseconds; it is worth knowing
before running this against something large.
## What happens to existing titles
Nothing preserves them, deliberately: `display_title` was already derived from the
title when one was set, so every note keeps the NAME it had. What is lost is the
distinction between "this note has an explicit title" and "this note's first line is
its name" — which is the distinction being removed.
Imports are the exception and are handled in code, not here: a Keep note's title, or
one in an export taken before this, is folded in as the note's first body line rather
than dropped (see `_create_imported_note`).
`display_title` was already derived from the title, so every note keeps its name.
"""
from alembic import op
import sqlalchemy as sa
@@ -4,64 +4,31 @@ Revision ID: 0027
Revises: 0026
Create Date: 2026-08-24
M304. A checklist item becomes a `- [ ] milk` line of `notes.body`, and `note_items`
goes. The reason is positional, not cosmetic: a row had a position in a table and no
position in the text, so a separate list could only ever render AFTER the prose. With
the items in the body, a list can sit between two paragraphs — which is the thing that
could not be built before and no amount of restyling would have delivered.
A checklist item becomes a `- [ ] milk` line of `notes.body`, so a list can sit
between two paragraphs, and `note_items` goes.
## This migration rewrites note bodies
Every note that has items gets its body appended to. The rules below are strict
because rewriting somebody's text deserves it — not, as an earlier draft of this
docstring claimed, because this instance holds imported Google Keep notes. It does
not; note 2916's headline is that nothing here is anyone's work but the operator's
test data. What 2916 actually says about imports is conditional — text arriving from
another app WOULD be real, and any import path has to treat it that way — and the
importer this migration shares a format with is one nobody here has run.
* Rows are read before the table is dropped, in this one transaction.
* The existing body is only appended to.
* The layout (a blank line between prose and the list, nothing between items) is
what the export writes and what `derive::append_item` produces, so the clients
migrate their own stores independently and still agree with the server.
Careful was still the right call. It cost little, and the same care is what the rule
demands the day someone does import something:
The fold is inlined rather than imported from `notes/checklist.py`: a migration must
keep producing what it produced the day it ran.
* Rows are read BEFORE the table is dropped, in this one transaction.
* The existing body is never rewritten, only appended to.
* The layout — a blank line between prose and the list, nothing between consecutive
items — is byte-for-byte what `_note_markdown` has always exported and what
`derive::append_item` produces on every client. All three landing on the same text
is what lets the clients migrate their own SQLite stores independently and still
agree with the server, with no sync required to reconcile them.
## `updated_at` is left alone
## The fold is inlined on purpose
Raw SQL, so `onupdate` never fires, and a client holding an unpushed edit keeps the
newer `updated_at` and wins last-write-wins. The `sync_revision` trigger does fire,
so every migrated note is pulled once and clients converge.
`notes/checklist.py` has this same function and this migration deliberately does not
import it. A migration has to keep producing what it produced the day it ran; if the
app's spacing rule ever changes, this file must not change with it.
## The downgrade is not a true inverse
## `updated_at` is left alone, and that is load-bearing
Raw SQL, so SQLAlchemy's `onupdate` never fires. Two reasons, and the second matters
more than the first. Every client folds the same rows the same way, so the new body is
news to nobody. And a client holding an UNPUSHED body edit still has the newer
`updated_at`, so when it pulls the migrated note last-write-wins keeps its edit instead
of the migration silently winning.
The `notes` row's own `sync_revision` trigger (migration 0015) does fire, so every
migrated note becomes pullable once. That is wanted: it is what makes a client whose
local fold somehow differed converge on the server's text.
## The downgrade is not a true inverse, and says so
It recreates an empty `note_items` and leaves the bodies alone. Nothing is lost —
every item is still there as text, which is where this migration put it — but the old
code would show those notes as prose with no checklist. A faithful inverse is not
possible: once the items are lines, nothing distinguishes a line this migration wrote
from one somebody typed, and a downgrade that guessed would eat hand-written task
lists. The real rollback is a database restore.
Recreating the table is not decoration, though. Migration 0015's downgrade runs
`DROP TRIGGER IF EXISTS trg_note_items_bump_note ON note_items`, and `IF EXISTS`
covers the trigger, not the table — against a missing table that statement errors. So
this is what keeps the migration chain runnable all the way back down.
It recreates an empty `note_items` and leaves the bodies alone: nothing tells a line
this wrote from one somebody typed. The real rollback is a database restore. The
table must exist because 0015's downgrade drops a trigger on it.
"""
import re
@@ -100,9 +67,8 @@ def upgrade():
for note_id, items in grouped.items():
body = bind.execute(sa.text("SELECT body FROM notes WHERE id = :id"), {"id": note_id}).scalar()
# An item whose note is already gone has nothing to fold into. The foreign key
# should make this impossible; skipping costs nothing and failing here would
# leave the database half-migrated.
# An item whose note is gone has nothing to fold into. The foreign key should
# prevent it; skipping avoids a half-migrated database.
if body is None:
continue
for text, checked in items:
@@ -113,9 +79,8 @@ def upgrade():
def downgrade():
# Column-for-column as migration 0006 created it, index name included: 0015's
# downgrade names both the table and its trigger, so a near-enough copy is not
# good enough.
# Exactly as 0006 created it, index name included: 0015's downgrade names both
# the table and its trigger.
op.create_table(
"note_items",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
+19 -71
View File
@@ -4,73 +4,27 @@ Revision ID: 0028
Revises: 0027
Create Date: 2026-08-26
M311. A `#tag` was being shown twice — once as the text you typed and once as a chip —
and with the chip moved to the top of the card the text is redundant. This removes it,
but only from notes where the tag was standing on its own.
A tag standing on its own line shows as a chip, so its text is removed, by the same
rule as `notes/tags.py:split_body_tags`:
## This migration rewrites note bodies
* A line containing nothing but tags and whitespace is removed.
* Every other line is left exactly as written, so `remember to call #mom` stays.
* A line inside a ``` fence is never touched.
* A note that is nothing but tags keeps its text, rather than becoming a blank card.
The rule is deliberately narrow, and the same one `notes/tags.py:split_body_tags`
applies from here on:
The label rows flip to `via_tag = false` in the same transaction: the text backing
them is gone, and `_lift_and_reconcile_tags` would otherwise detach them on the next
save.
* A line containing nothing but tags and whitespace is REMOVED.
* Every other line is left exactly as written.
The transform and `_display_title` are inlined: a migration must keep producing what
it produced the day it ran. The title is recomputed only where the body moved.
So `#todo` on its own line goes, and `remember to call #mom tomorrow` does not. The
looser reading — also stripping a trailing tag off a prose line — was rejected because
the text does not say which kind it is: `buy milk #grocery` is filing, `remember to
call #mom` is the sentence's object, and lifting the second leaves "remember to call".
Rewriting somebody's words to save a duplicate chip is a bad trade, and a migration is
the worst possible place to make it.
Raw SQL, so `onupdate` never fires and a client's unpushed edit still wins
last-write-wins. The `sync_revision` trigger fires, so clients pull the server's text.
Two guards, both of which cost a note nothing:
* A line inside a ``` fence is never touched. A `#tag` there is a shell comment in a
snippet somebody pasted, and deleting it would eat a line of their example.
* A note that is NOTHING but tags keeps its text. Lifting would leave a blank card,
which is worse than the duplication this fixes.
## The label rows have to graduate in the same transaction
A `via_tag` row means "this label is backed by text still in the body". Once the text
is gone that is false, and leaving it true is not cosmetic: `_lift_and_reconcile_tags`
detaches any `via_tag` row it cannot find a `#tag` for, so the note would lose the tag
on its very next save. The flip to `via_tag = false` is what makes the label the record
instead — and what makes the chip's × appear in both editors, which is now the only way
to remove a tag whose text no longer exists.
## The transform is inlined, like 0027's
`split_body_tags` is deliberately NOT imported. A migration has to keep producing what
it produced the day it ran; if the app's rule is ever loosened, this file must not
loosen with it and start eating prose it previously left alone.
`_display_title` is inlined for the same reason, and is only recomputed for a note whose
body actually moved — a note named after a `#todo` line needs a new name, and reading it
from the app would couple this migration to a rule that has already changed once (M13).
## `updated_at` is left alone, and that is load-bearing
Raw SQL, so SQLAlchemy's `onupdate` never fires. A client holding an UNPUSHED body edit
keeps the newer `updated_at`, so when it pulls the migrated note last-write-wins keeps
its edit instead of the migration silently winning.
The `sync_revision` trigger (migration 0015) does fire, so every rewritten note becomes
pullable once and clients converge on the server's text. That is wanted here: unlike
0027, the clients do NOT yet apply this rule locally, so the server's copy is the only
correct one until they do.
## The downgrade is not a true inverse, and says so
It cannot be. Nothing distinguishes a `#todo` line this migration deleted from one that
was never there, and putting one back would be guessing at where in the note it went.
Nothing is lost, though, which is why that is acceptable: the tag still exists as a
label on the note, and the chip still shows it. What a downgrade cannot restore is the
DUPLICATE — which is the thing this migration set out to remove. Rolling the rows back
to `via_tag = true` would be actively harmful: the text that flag claims to be backed by
is gone, so the next save would detach the label and lose the tag for real. So the
downgrade leaves both alone. The real rollback is a database restore.
The downgrade does nothing. Restoring the lines would be guessing, and flipping the
rows back to `via_tag = true` would detach every label on the next save. The real
rollback is a database restore.
"""
import re
@@ -152,17 +106,11 @@ def upgrade():
sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"),
{"body": lifted, "title": _display_title(lifted), "id": note_id},
)
# Even when the body did not move, a tag can be standalone only in the sense
# that its line was already removed by an earlier pass — so the flip is driven
# by the tag list, not by whether the text changed.
# The flip is driven by the tag list, not by whether the text changed: an
# earlier pass may already have removed the line.
if standalone:
bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]})
def downgrade():
"""Deliberately empty — see the module docstring.
Restoring the deleted lines would be guessing, and flipping the rows back to
`via_tag = true` would be worse than doing nothing: the text that flag claims backs
them is gone, so the next save would detach the label and lose the tag for real.
"""
"""Deliberately empty; see the module docstring."""
+9 -42
View File
@@ -4,50 +4,18 @@ Revision ID: 0029
Revises: 0028
Create Date: 2026-08-28
M315 step 3. A note's colour was set by a picker and read by three card renderers.
Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and
the only coloured thing on a board is a tag. This drops the column that nothing has
been reading since, and the picker goes with it.
Cards are one neutral surface per theme; the only colour on a board is a tag's.
`labels.color` is untouched. A colour a note was given is dropped with the column.
`labels.color` is untouched. That is the colour that survived, and the one the whole
milestone was about keeping.
`saved_filters.params` may carry a `"color"` facet, so stored views are swept too. In
Python rather than `params::jsonb - 'color'`, because Postgres has no try-cast and one
malformed blob would abort the migration; a row that doesn't parse is left as it is.
## What is lost, and why that is the change rather than a cost of it
Any colour a note was explicitly given. There is nowhere to preserve it TO — the field
it would be preserved in is the one being dropped — and nothing renders it, so a
preserved value would be a column kept warm for a feature that was deliberately
removed. A note that had a colour now takes its identity from its tags, which is what
the operator asked for: "strip color from the cards ... and keep the color for tags
just on the tag."
The palette itself is not lost. `NOTE_COLORS` moved from `models/note.py` to
`colors.py` in the same change — labels still name a colour, and leaving the vocabulary
defined on the model that lost one would be an invitation to put the column back.
## The saved-filter sweep is not optional
`saved_filters.params` is opaque JSON mirroring the `GET /api/notes` facet query, and
a stored view could carry `"color": "teal"`. With the facet gone that key would sit
there forever, and `clean_params` only guards what is written FROM here on. A view that
silently filters on a field the app no longer has is worse than one that visibly lost a
criterion, so the stored rows are swept too.
Done in Python rather than as `params::jsonb - 'color'`, deliberately. Postgres has no
try-cast: one malformed blob would abort the whole migration, and these rows are
somebody's saved views. `json.loads` in a try/except lets a corrupt row keep whatever it
holds and lets every other row be fixed.
## Search is not affected
`notes.search_vector` is a stored generated column over `display_title` and `body`
(rebuilt in 0026). It never named `color`, so unlike the title drop there is nothing
here to tear down and recreate.
The search vector never named `color`, so it needs no rebuild.
## Downgrade
Restores the column, empty, at its old default. The values are not recoverable — see
above. It is the schema that comes back, not the data.
Restores the column, empty, at its old default. The values are not recoverable.
"""
import json
@@ -71,9 +39,8 @@ def upgrade() -> None:
try:
parsed = json.loads(params)
except (ValueError, TypeError):
# A blob that does not parse cannot be edited safely. Leaving it is
# correct: it was already unreadable by the app, and this migration is not
# the place to decide what it should have said.
# A blob that doesn't parse can't be edited safely; the app couldn't read
# it either.
continue
if not isinstance(parsed, dict) or "color" not in parsed:
continue
+3 -9
View File
@@ -4,15 +4,9 @@ Revision ID: 0030
Revises: 0029
Create Date: 2026-10-06
The product is renamed ThoughtSync → Inkwell (Scribe note 5071), and the `site_name`
registry default moves with it. On its own that only reaches servers whose settings
table has no `site_name` row — and most servers have one, because the Settings page
saves EVERY key on Save, not just the one that changed. An admin who opened Settings
to flip registration off has persisted `"ThoughtSync"` without ever choosing it.
So the row is rewritten, but only when it holds exactly the old default. A name the
admin actually typed is theirs and is left alone; the old default is the one value we
can be sure nobody chose.
The app was renamed from ThoughtSync to Inkwell. The Settings page saves every key,
so most servers have a stored `site_name` of the old default without anyone choosing
it. The row is rewritten only when it holds exactly that; a typed name is left alone.
The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so
`'"ThoughtSync"'` is the whole of what it looks for.
@@ -4,16 +4,9 @@ Revision ID: 0031
Revises: 0030
Create Date: 2026-10-07
0015 made every child table bump its parent note's `sync_revision`, so a note syncs
as a whole. `note_link_previews` arrived later (0020) and was never added, and two
things have been missing on every linked device since:
- A preview fetched in the background after a save (`unfurl_queue.py`) never reached
a device that had already pulled the note. The note's revision was assigned when
the TEXT was saved, before the preview existed, and nothing moved it afterwards.
- A preview dismissed on the web stayed on every other device, for the same reason.
The trigger is the same function 0015 installs on the other child tables.
A note syncs as a whole, so every child table bumps its note's `sync_revision`. With
this, a preview fetched after a save, or dismissed on the web, reaches every device.
The trigger is the function 0015 installs on the other child tables.
## Downgrade
+2 -3
View File
@@ -4,9 +4,8 @@ Revision ID: 0032
Revises: 0031
Create Date: 2026-10-07
Until now the only way to add a second person was to re-open registration to the
whole internet while they signed up (#2939 §1). An invite lets one person register
while registration stays closed. Only the token's SHA-256 hash is stored.
An invite lets one person register while registration stays closed. Only the token's
SHA-256 hash is stored.
## Downgrade
+5 -6
View File
@@ -4,20 +4,19 @@ Revision ID: 0033
Revises: 0032
Create Date: 2026-10-07
A forgotten password used to need a hand on the database (#2939 §2), and the app has
no mail path to send a reset by. An admin makes a reset link for the account and
hands it over (#5173). Only the token's SHA-256 hash is stored.
An admin makes a reset link for the account and hands it over. Only the token's
SHA-256 hash is stored.
`users.session_epoch` is what lets a reset sign the account out everywhere. Sessions
are signed cookies held by the browser, so the server can't delete them; each one
carries the epoch it was signed in under, and a reset moves the account's epoch on.
It starts at 0, the value a cookie from before this migration is read as, so nobody
is signed out by the upgrade itself.
It starts at 0, the value a cookie without one is read as, so the upgrade signs
nobody out.
## Downgrade
Drops the table and the column. Outstanding reset links stop working; sessions keep
working, since nothing checks an epoch any more.
working, since nothing checks an epoch.
"""
import sqlalchemy as sa
from alembic import op
+2 -3
View File
@@ -4,11 +4,10 @@ Revision ID: 0034
Revises: 0033
Create Date: 2026-10-07
Nothing wrote a share until #5174, so the table never needed these. Now the Share
dialog does:
For the Share dialog:
- A unique index on (resource_type, resource_id, shared_with_user_id) where a user is
the target, so sharing a note with someone twice updates the one grant rather than
stacking two (the same for a group, ahead of step 15).
stacking two (the same for a group).
- A check that `permission` is `view` or `edit`.
- An index on `shared_with_user_id` for "Shared with me".
+1 -1
View File
@@ -5,7 +5,7 @@ Revises: 0034
Create Date: 2026-10-07
The change feed carries every note a person can see, including notes shared with
them (#5175). When a share ends, the note stops being visible, so it simply stops
them. When a share ends, the note stops being visible, so it simply stops
appearing in the feed. A device that already holds a copy would keep it forever. A
revocation is that missing signal: one row per (note, person) who lost the note,
stamped from the same `sync_revision_seq` the notes and labels draw from, so it sits
+4 -6
View File
@@ -4,12 +4,10 @@ Revision ID: 0036
Revises: 0035
Create Date: 2026-10-07
Pin, archive and board order are personal organization, and until now they were
columns on `notes`, so on a shared note they could only ever mean the owner's
(#5176). This table holds them for everyone else: one row per (note, person it is
shared with) who has pinned, archived or moved it. The owner keeps the note's own
columns; a recipient with no row sees the note unpinned, unarchived, in the owner's
order.
Pin, archive and board order are personal. The owner's are columns on `notes`; this
table holds everyone else's, one row per (note, person it is shared with) who has
pinned, archived or moved it. A recipient with no row sees the note unpinned,
unarchived, in the owner's order.
`sync_revision` is stamped by the same `ts_set_sync_revision()` trigger the notes
and labels use (0015), so a recipient's change reaches their own devices on the one
@@ -4,12 +4,8 @@ Revision ID: 0037
Revises: 0036
Create Date: 2026-10-07
Both came with the foundation (0001) for features that were never built (#5178).
Nothing ever set `email_verified`, so it was false for every account on the server
while the offline desktop reported it as true; nothing read or wrote `avatar_path`.
A field whose value means nothing is worse than no field: a later feature would
trust it. If address verification is ever built, it adds its own column, with a
migration that says what the existing accounts are.
Nothing set `email_verified` or read `avatar_path`, and a field whose value means
nothing would be trusted by a later feature.
## Downgrade
+1 -3
View File
@@ -4,9 +4,7 @@ Revision ID: 0038
Revises: 0037
Create Date: 2026-10-08
Saved views were removed in #5180 (note 2897: recall first, organizing second). They
lived only on the web; the desktop kept its own set that never synced, and Android
had none. Tags in the drawer already give one-click recall.
Saved views are removed; tags in the drawer give one-click recall.
## Downgrade
+2 -3
View File
@@ -4,9 +4,8 @@ Revision ID: 0039
Revises: 0038
Create Date: 2026-10-08
Credential events were only written to the app log, which is not queryable and is
gone at the container's log rotation (#2939 §5). This keeps them in the database for
`audit_retention_days`, for admins to read in Settings → Activity.
Keeps credential events in the database for `audit_retention_days`, for admins to
read in Settings → Activity; the app log isn't queryable and rotates away.
## Downgrade
+3 -5
View File
@@ -4,11 +4,9 @@ Revision ID: 0040
Revises: 0039
Create Date: 2026-10-08
Deleting a tag now leaves a tombstone row (`purged_at` set) so the change feed can
tell linked devices about it (#5382). Before, the web deleted the row outright and
devices never heard. A tombstone must not hold its name: creating `#grocery` again
after deleting it has to make a live tag, on the web and from a device alike. So
`(owner_id, name)` stays unique only where `purged_at IS NULL`.
Deleting a tag leaves a tombstone row (`purged_at` set) so the change feed tells
linked devices. A tombstone must not hold its name, so `(owner_id, name)` is unique
only where `purged_at IS NULL`.
## Downgrade