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
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +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,4 +1,4 @@
|
||||
"""device_tokens (M8 sync hub, step 2)
|
||||
"""device_tokens
|
||||
|
||||
Revision ID: 0016
|
||||
Revises: 0015
|
||||
|
||||
@@ -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,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,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,4 +1,4 @@
|
||||
"""note_link_previews (URL unfurl — M6 1901)
|
||||
"""note_link_previews, cached URL unfurls
|
||||
|
||||
Revision ID: 0020
|
||||
Revises: 0019
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""saved_filters (richer search + saved views — M6 1902)
|
||||
"""saved_filters, saved search views
|
||||
|
||||
Revision ID: 0021
|
||||
Revises: 0020
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""notes.recurrence (recurring reminders — M6 1908)
|
||||
"""notes.recurrence, for recurring reminders
|
||||
|
||||
Revision ID: 0022
|
||||
Revises: 0021
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user