From 3ced1d8f8ca30fa773fab95018d283312c36a418 Mon Sep 17 00:00:00 2001 From: bean1352 Date: Fri, 6 Feb 2026 14:27:27 +0200 Subject: [PATCH 1/3] Add local-only columns section to raw tables page --- client-sdks/advanced/local-only-usage.mdx | 4 + client-sdks/advanced/raw-tables.mdx | 190 ++++++++++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/client-sdks/advanced/local-only-usage.mdx b/client-sdks/advanced/local-only-usage.mdx index f0174ddf..2e09744d 100644 --- a/client-sdks/advanced/local-only-usage.mdx +++ b/client-sdks/advanced/local-only-usage.mdx @@ -83,3 +83,7 @@ DELETE FROM ps_crud It is up to the application to then re-create the queue when the user registers, or upload data directly from the existing tables instead. A small amount of metadata per row is also stored in the `ps_oplog` table. We do not recommend deleting this data, as it can cause or hide consistency issues when later uploading the data. If the overhead in `ps_oplog` is too much, rather use the local-only tables approach. + +### Local-only columns on synced tables + +If you need individual local-only columns on a table that is otherwise synced (rather than an entirely local-only table), this can be achieved with [raw tables](/client-sdks/advanced/raw-tables#local-only-columns). diff --git a/client-sdks/advanced/raw-tables.mdx b/client-sdks/advanced/raw-tables.mdx index 6526e8a8..24df049a 100644 --- a/client-sdks/advanced/raw-tables.mdx +++ b/client-sdks/advanced/raw-tables.mdx @@ -268,6 +268,196 @@ Raw tables support advanced table constraints including foreign keys. When enabl rows to lower-priority data. PowerSync applies data in one transaction per priority, so these foreign keys would not work. 3. As usual when using foreign keys, note that they need to be explicitly enabled with `pragma foreign_keys = on`. +## Local-Only Columns + +Raw tables allow you to add columns that exist only on the client and are never synced to the backend. This is useful for client-specific state like user preferences, local notes, or UI flags that should persist across app restarts but have no equivalent in the backend database. + + + Local-only columns are not supported with PowerSync's default [JSON-based view system](/architecture/client-architecture#schema). Raw tables are required for this functionality. + + +Building on the `todo_lists` example above, you can add local-only columns such as `is_pinned` and `local_notes`: + +```sql +CREATE TABLE IF NOT EXISTS todo_lists ( + id TEXT NOT NULL PRIMARY KEY, + -- Synced columns + created_by TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT, + -- Local-only columns (not synced) + is_pinned INTEGER NOT NULL DEFAULT 0, + local_notes TEXT +) STRICT; +``` + +The standard raw table setup requires three modifications to support local-only columns: + +### Use upsert instead of INSERT OR REPLACE + +The `put` statement must use `INSERT ... ON CONFLICT(id) DO UPDATE SET` instead of `INSERT OR REPLACE`. `INSERT OR REPLACE` deletes and re-inserts the row, which resets local-only columns to their defaults on every sync update. An upsert only updates the specified synced columns, leaving local-only columns intact. + +Only synced columns should be referenced in the `put` params. Local-only columns are omitted entirely: + + + + ```JavaScript + schema.withRawTables({ + todo_lists: { + put: { + sql: `INSERT INTO todo_lists (id, created_by, title, content) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + created_by = excluded.created_by, + title = excluded.title, + content = excluded.content`, + params: ['Id', { Column: 'created_by' }, { Column: 'title' }, { Column: 'content' }] + }, + delete: { + sql: 'DELETE FROM todo_lists WHERE id = ?', + params: ['Id'] + } + } + }); + ``` + + + ```dart + final schema = Schema(const [], rawTables: const [ + RawTable( + name: 'todo_lists', + put: PendingStatement( + sql: '''INSERT INTO todo_lists (id, created_by, title, content) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + created_by = excluded.created_by, + title = excluded.title, + content = excluded.content''', + params: [ + PendingStatementValue.id(), + PendingStatementValue.column('created_by'), + PendingStatementValue.column('title'), + PendingStatementValue.column('content'), + ], + ), + delete: PendingStatement( + sql: 'DELETE FROM todo_lists WHERE id = ?', + params: [ + PendingStatementValue.id(), + ], + ), + ), + ]); + ``` + + + ```Kotlin + val schema = Schema(listOf( + RawTable( + name = "todo_lists", + put = PendingStatement( + """INSERT INTO todo_lists (id, created_by, title, content) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + created_by = excluded.created_by, + title = excluded.title, + content = excluded.content""", + listOf( + PendingStatementParameter.Id, + PendingStatementParameter.Column("created_by"), + PendingStatementParameter.Column("title"), + PendingStatementParameter.Column("content") + ) + ), + delete = PendingStatement( + "DELETE FROM todo_lists WHERE id = ?", listOf(PendingStatementParameter.Id) + ) + ) + )) + ``` + + + ```Swift + let lists = RawTable( + name: "todo_lists", + put: PendingStatement( + sql: """ + INSERT INTO todo_lists (id, created_by, title, content) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + created_by = excluded.created_by, + title = excluded.title, + content = excluded.content + """, + parameters: [.id, .column("created_by"), .column("title"), .column("content")] + ), + delete: PendingStatement( + sql: "DELETE FROM todo_lists WHERE id = ?", + parameters: [.id], + ), + ) + + let schema = Schema(lists) + ``` + + + Unfortunately, raw tables are not available in the .NET SDK yet. + + + +### Exclude local-only columns from triggers + +The `json_object()` in both the INSERT and UPDATE triggers should only reference synced columns. Local-only columns must not appear in the CRUD payload sent to the backend. + +Additionally, the UPDATE trigger needs a `WHEN` clause that checks only synced columns. Without it, changes to local-only columns would fire the trigger and produce unnecessary CRUD entries that get uploaded. The `WHEN` clause must use `IS NOT` instead of `!=` for NULL-safe comparisons. `NULL != NULL` evaluates to `NULL` in SQLite, which would cause the trigger to skip legitimate changes to nullable synced columns. + +```SQL +CREATE TRIGGER todo_lists_insert + AFTER INSERT ON todo_lists + FOR EACH ROW + BEGIN + INSERT INTO powersync_crud (op, id, type, data) VALUES ('PUT', NEW.id, 'todo_lists', json_object( + 'created_by', NEW.created_by, + 'title', NEW.title, + 'content', NEW.content + )); + END; + +-- WHEN clause ensures this only fires for synced column changes. +-- Uses IS NOT instead of != for correct NULL handling. +CREATE TRIGGER todo_lists_update + AFTER UPDATE ON todo_lists + FOR EACH ROW + WHEN + OLD.created_by IS NOT NEW.created_by + OR OLD.title IS NOT NEW.title + OR OLD.content IS NOT NEW.content + BEGIN + INSERT INTO powersync_crud (op, id, type, data) VALUES ('PATCH', NEW.id, 'todo_lists', json_object( + 'created_by', NEW.created_by, + 'title', NEW.title, + 'content', NEW.content + )); + END; + +CREATE TRIGGER todo_lists_delete + AFTER DELETE ON todo_lists + FOR EACH ROW + BEGIN + INSERT INTO powersync_crud (op, id, type) VALUES ('DELETE', OLD.id, 'todo_lists'); + END; +``` + +With this setup, local-only columns can be queried and updated using standard SQL without affecting sync: + +```SQL +-- Updating a local-only column does not produce a CRUD entry +UPDATE todo_lists SET is_pinned = 1 WHERE id = '...'; + +-- Local-only columns can be used in queries and ordering +SELECT * FROM todo_lists ORDER BY is_pinned DESC, title ASC; +``` + ## Migrations In PowerSync's [JSON-based view system](/architecture/client-architecture#schema) the client-side schema is applied to the schemaless data, meaning no migrations are required. Raw tables however are excluded from this, so it is the developers responsibility to manage migrations for these tables. From 3a3756979f184ac3c38f045aa258a6cb5fac0ff2 Mon Sep 17 00:00:00 2001 From: bean1352 Date: Fri, 6 Feb 2026 14:47:50 +0200 Subject: [PATCH 2/3] Convert raw tables docs to use CodeGroup --- client-sdks/advanced/raw-tables.mdx | 380 ++++++++++++++-------------- 1 file changed, 184 insertions(+), 196 deletions(-) diff --git a/client-sdks/advanced/raw-tables.mdx b/client-sdks/advanced/raw-tables.mdx index 24df049a..28e964f3 100644 --- a/client-sdks/advanced/raw-tables.mdx +++ b/client-sdks/advanced/raw-tables.mdx @@ -87,112 +87,104 @@ The PowerSync client as part of our SDKs will automatically run these statements To reference the ID or extract values, prepared statements with parameters are used. `delete` statements can reference the id of the affected row, while `put` statements can also reference individual column values. Declaring these statements and parameters happens as part of the schema passed to PowerSync databases: - - - Raw tables are not included in the regular `Schema()` object. Instead, add them afterwards using `withRawTables`. For each raw table, specify the `put` and `delete` statement. The values of parameters are described as a JSON array either containing: - - - the string `Id` to reference the id of the affected row. - - the object `{ Column: name }` to reference the value of the column `name`. - - ```JavaScript - const mySchema = new Schema({ - // Define your PowerSync-managed schema here - // ... - }); - mySchema.withRawTables({ + + ```JavaScript JavaScript + // Raw tables are not included in the regular Schema() object. + // Instead, add them afterwards using withRawTables(). + // The values of parameters are described as a JSON array either containing: + // - the string 'Id' to reference the id of the affected row. + // - the object { Column: name } to reference the value of the column 'name'. + const mySchema = new Schema({ + // Define your PowerSync-managed schema here + // ... + }); + mySchema.withRawTables({ + // The name here doesn't have to match the name of the table in SQL. Instead, it's used to match + // the table name from the backend source database as sent by the PowerSync Service. + todo_lists: { + put: { + sql: 'INSERT OR REPLACE INTO todo_lists (id, created_by, title, content) VALUES (?, ?, ?, ?)', + params: ['Id', { Column: 'created_by' }, { Column: 'title' }, { Column: 'content' }] + }, + delete: { + sql: 'DELETE FROM lists WHERE id = ?', + params: ['Id'] + } + } + }); + // We will simplify this API after understanding the use-cases for raw tables better. + ``` + + ```dart Dart + // Raw tables are not part of the regular tables list and can be defined with the optional rawTables parameter. + final schema = Schema(const [], rawTables: const [ + RawTable( // The name here doesn't have to match the name of the table in SQL. Instead, it's used to match // the table name from the backend source database as sent by the PowerSync Service. - todo_lists: { - put: { - sql: 'INSERT OR REPLACE INTO todo_lists (id, created_by, title, content) VALUES (?, ?, ?, ?)', - params: ['Id', { Column: 'created_by' }, { Column: 'title' }, { Column: 'content' }] - }, - delete: { - sql: 'DELETE FROM lists WHERE id = ?', - params: ['Id'] - } - } - }); - ``` - - We will simplify this API after understanding the use-cases for raw tables better. - - - Raw tables are not part of the regular tables list and can be defined with the optional `rawTables` parameter. - - ```dart - final schema = Schema(const [], rawTables: const [ - RawTable( - // The name here doesn't have to match the name of the table in SQL. Instead, it's used to match - // the table name from the backend source database as sent by the PowerSync Service. - name: 'todo_lists', - put: PendingStatement( - sql: 'INSERT OR REPLACE INTO todo_lists (id, created_by, title, content) VALUES (?, ?, ?, ?)', - params: [ - PendingStatementValue.id(), - PendingStatementValue.column('created_by'), - PendingStatementValue.column('title'), - PendingStatementValue.column('content'), - ], - ), - delete: PendingStatement( - sql: 'DELETE FROM todo_lists WHERE id = ?', - params: [ - PendingStatementValue.id(), - ], - ), + name: 'todo_lists', + put: PendingStatement( + sql: 'INSERT OR REPLACE INTO todo_lists (id, created_by, title, content) VALUES (?, ?, ?, ?)', + params: [ + PendingStatementValue.id(), + PendingStatementValue.column('created_by'), + PendingStatementValue.column('title'), + PendingStatementValue.column('content'), + ], ), - ]); - ``` - - - To define a raw table, include it in the list of tables passed to the `Schema`: + delete: PendingStatement( + sql: 'DELETE FROM todo_lists WHERE id = ?', + params: [ + PendingStatementValue.id(), + ], + ), + ), + ]); + ``` - ```Kotlin + ```Kotlin Kotlin + // To define a raw table, include it in the list of tables passed to the Schema val schema = Schema(listOf( - RawTable( - // The name here doesn't have to match the name of the table in SQL. Instead, it's used to match - // the table name from the backend database as sent by the PowerSync service. - name = "todo_lists", - put = PendingStatement( - "INSERT OR REPLACE INTO todo_lists (id, created_by, title, content) VALUES (?, ?, ?, ?)", - listOf( - PendingStatementParameter.Id, - PendingStatementParameter.Column("created_by"), - PendingStatementParameter.Column("title"), - PendingStatementParameter.Column("content") - ) - ), - delete = PendingStatement( - "DELETE FROM todo_lists WHERE id = ?", listOf(PendingStatementParameter.Id) - ) - ) - )) - ``` - - - To define a raw table, include it in the list of tables passed to the `Schema`: - - ```Swift - let lists = RawTable( - name: "todo_lists", - put: PendingStatement( - sql: "INSERT OR REPLACE INTO todo_lists (id, created_by, title, content) VALUES (?, ?, ?, ?)", - parameters: [.id, .column("created_by"), .column("title"), .column("content")] - ), - delete: PendingStatement( - sql: "DELETE FROM todo_lists WHERE id = ?", - parameters: [.id], - ), + RawTable( + // The name here doesn't have to match the name of the table in SQL. Instead, it's used to match + // the table name from the backend database as sent by the PowerSync service. + name = "todo_lists", + put = PendingStatement( + "INSERT OR REPLACE INTO todo_lists (id, created_by, title, content) VALUES (?, ?, ?, ?)", + listOf( + PendingStatementParameter.Id, + PendingStatementParameter.Column("created_by"), + PendingStatementParameter.Column("title"), + PendingStatementParameter.Column("content") + ) + ), + delete = PendingStatement( + "DELETE FROM todo_lists WHERE id = ?", listOf(PendingStatementParameter.Id) + ) ) - - let schema = Schema(lists) - ``` - - - Unfortunately, raw tables are not available in the .NET SDK yet. - - + )) + ``` + + ```Swift Swift + // To define a raw table, include it in the list of tables passed to the Schema + let lists = RawTable( + name: "todo_lists", + put: PendingStatement( + sql: "INSERT OR REPLACE INTO todo_lists (id, created_by, title, content) VALUES (?, ?, ?, ?)", + parameters: [.id, .column("created_by"), .column("title"), .column("content")] + ), + delete: PendingStatement( + sql: "DELETE FROM todo_lists WHERE id = ?", + parameters: [.id], + ), + ) + + let schema = Schema(lists) + ``` + + + + Unfortunately, raw tables are not available in the .NET SDK yet. + After adding raw tables to the schema, you're also responsible for creating them by executing the corresponding `CREATE TABLE` statement before `connect()`-ing the database. @@ -299,111 +291,107 @@ The `put` statement must use `INSERT ... ON CONFLICT(id) DO UPDATE SET` instead Only synced columns should be referenced in the `put` params. Local-only columns are omitted entirely: - - - ```JavaScript - schema.withRawTables({ - todo_lists: { - put: { - sql: `INSERT INTO todo_lists (id, created_by, title, content) + + ```JavaScript JavaScript + schema.withRawTables({ + todo_lists: { + put: { + sql: `INSERT INTO todo_lists (id, created_by, title, content) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + created_by = excluded.created_by, + title = excluded.title, + content = excluded.content`, + params: ['Id', { Column: 'created_by' }, { Column: 'title' }, { Column: 'content' }] + }, + delete: { + sql: 'DELETE FROM todo_lists WHERE id = ?', + params: ['Id'] + } + } + }); + ``` + + ```dart Dart + final schema = Schema(const [], rawTables: const [ + RawTable( + name: 'todo_lists', + put: PendingStatement( + sql: '''INSERT INTO todo_lists (id, created_by, title, content) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET created_by = excluded.created_by, title = excluded.title, - content = excluded.content`, - params: ['Id', { Column: 'created_by' }, { Column: 'title' }, { Column: 'content' }] - }, - delete: { - sql: 'DELETE FROM todo_lists WHERE id = ?', - params: ['Id'] - } - } - }); - ``` - - - ```dart - final schema = Schema(const [], rawTables: const [ - RawTable( - name: 'todo_lists', - put: PendingStatement( - sql: '''INSERT INTO todo_lists (id, created_by, title, content) - VALUES (?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - created_by = excluded.created_by, - title = excluded.title, - content = excluded.content''', - params: [ - PendingStatementValue.id(), - PendingStatementValue.column('created_by'), - PendingStatementValue.column('title'), - PendingStatementValue.column('content'), - ], - ), - delete: PendingStatement( - sql: 'DELETE FROM todo_lists WHERE id = ?', - params: [ - PendingStatementValue.id(), - ], - ), + content = excluded.content''', + params: [ + PendingStatementValue.id(), + PendingStatementValue.column('created_by'), + PendingStatementValue.column('title'), + PendingStatementValue.column('content'), + ], + ), + delete: PendingStatement( + sql: 'DELETE FROM todo_lists WHERE id = ?', + params: [ + PendingStatementValue.id(), + ], ), - ]); - ``` - - - ```Kotlin - val schema = Schema(listOf( - RawTable( - name = "todo_lists", - put = PendingStatement( - """INSERT INTO todo_lists (id, created_by, title, content) - VALUES (?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - created_by = excluded.created_by, - title = excluded.title, - content = excluded.content""", - listOf( - PendingStatementParameter.Id, - PendingStatementParameter.Column("created_by"), - PendingStatementParameter.Column("title"), - PendingStatementParameter.Column("content") - ) - ), - delete = PendingStatement( - "DELETE FROM todo_lists WHERE id = ?", listOf(PendingStatementParameter.Id) + ), + ]); + ``` + + ```Kotlin Kotlin + val schema = Schema(listOf( + RawTable( + name = "todo_lists", + put = PendingStatement( + """INSERT INTO todo_lists (id, created_by, title, content) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + created_by = excluded.created_by, + title = excluded.title, + content = excluded.content""", + listOf( + PendingStatementParameter.Id, + PendingStatementParameter.Column("created_by"), + PendingStatementParameter.Column("title"), + PendingStatementParameter.Column("content") ) + ), + delete = PendingStatement( + "DELETE FROM todo_lists WHERE id = ?", listOf(PendingStatementParameter.Id) ) - )) - ``` - - - ```Swift - let lists = RawTable( - name: "todo_lists", - put: PendingStatement( - sql: """ - INSERT INTO todo_lists (id, created_by, title, content) - VALUES (?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - created_by = excluded.created_by, - title = excluded.title, - content = excluded.content - """, - parameters: [.id, .column("created_by"), .column("title"), .column("content")] - ), - delete: PendingStatement( - sql: "DELETE FROM todo_lists WHERE id = ?", - parameters: [.id], - ), ) + )) + ``` + + ```Swift Swift + let lists = RawTable( + name: "todo_lists", + put: PendingStatement( + sql: """ + INSERT INTO todo_lists (id, created_by, title, content) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + created_by = excluded.created_by, + title = excluded.title, + content = excluded.content + """, + parameters: [.id, .column("created_by"), .column("title"), .column("content")] + ), + delete: PendingStatement( + sql: "DELETE FROM todo_lists WHERE id = ?", + parameters: [.id], + ), + ) - let schema = Schema(lists) - ``` - - - Unfortunately, raw tables are not available in the .NET SDK yet. - - + let schema = Schema(lists) + ``` + + + + Raw tables are not yet available in the .NET SDK. + ### Exclude local-only columns from triggers From 6fbe2a99fdf5a914e58d76a23c764cc87d1f306d Mon Sep 17 00:00:00 2001 From: bean1352 Date: Fri, 6 Feb 2026 15:02:13 +0200 Subject: [PATCH 3/3] Lowercase code fence language identifiers in raw tables docs --- client-sdks/advanced/raw-tables.mdx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client-sdks/advanced/raw-tables.mdx b/client-sdks/advanced/raw-tables.mdx index 28e964f3..798cb6e8 100644 --- a/client-sdks/advanced/raw-tables.mdx +++ b/client-sdks/advanced/raw-tables.mdx @@ -88,7 +88,7 @@ The PowerSync client as part of our SDKs will automatically run these statements To reference the ID or extract values, prepared statements with parameters are used. `delete` statements can reference the id of the affected row, while `put` statements can also reference individual column values. Declaring these statements and parameters happens as part of the schema passed to PowerSync databases: - ```JavaScript JavaScript + ```javascript JavaScript // Raw tables are not included in the regular Schema() object. // Instead, add them afterwards using withRawTables(). // The values of parameters are described as a JSON array either containing: @@ -141,7 +141,7 @@ To reference the ID or extract values, prepared statements with parameters are u ]); ``` - ```Kotlin Kotlin + ```kotlin Kotlin // To define a raw table, include it in the list of tables passed to the Schema val schema = Schema(listOf( RawTable( @@ -164,7 +164,7 @@ To reference the ID or extract values, prepared statements with parameters are u )) ``` - ```Swift Swift + ```swift Swift // To define a raw table, include it in the list of tables passed to the Schema let lists = RawTable( name: "todo_lists", @@ -194,7 +194,7 @@ PowerSync uses an internal SQLite table to collect local writes. For PowerSync-m The [PowerSync SQLite extension](https://github.com/powersync-ja/powersync-sqlite-core) creates an insert-only virtual table named `powersync_crud` with these columns: -```SQL +```sql CREATE VIRTUAL TABLE powersync_crud( -- The type of operation: 'PUT' or 'DELETE' op TEXT, @@ -212,7 +212,7 @@ CREATE VIRTUAL TABLE powersync_crud( The virtual table associates local mutations with the current transaction and ensures writes made during the sync process (applying server-side changes) don't count as local writes. This means that triggers can be defined on raw tables like so: -```SQL +```sql CREATE TRIGGER todo_lists_insert AFTER INSERT ON todo_lists FOR EACH ROW @@ -292,7 +292,7 @@ The `put` statement must use `INSERT ... ON CONFLICT(id) DO UPDATE SET` instead Only synced columns should be referenced in the `put` params. Local-only columns are omitted entirely: - ```JavaScript JavaScript + ```javascript JavaScript schema.withRawTables({ todo_lists: { put: { @@ -340,7 +340,7 @@ Only synced columns should be referenced in the `put` params. Local-only columns ]); ``` - ```Kotlin Kotlin + ```kotlin Kotlin val schema = Schema(listOf( RawTable( name = "todo_lists", @@ -365,7 +365,7 @@ Only synced columns should be referenced in the `put` params. Local-only columns )) ``` - ```Swift Swift + ```swift Swift let lists = RawTable( name: "todo_lists", put: PendingStatement( @@ -399,7 +399,7 @@ The `json_object()` in both the INSERT and UPDATE triggers should only reference Additionally, the UPDATE trigger needs a `WHEN` clause that checks only synced columns. Without it, changes to local-only columns would fire the trigger and produce unnecessary CRUD entries that get uploaded. The `WHEN` clause must use `IS NOT` instead of `!=` for NULL-safe comparisons. `NULL != NULL` evaluates to `NULL` in SQLite, which would cause the trigger to skip legitimate changes to nullable synced columns. -```SQL +```sql CREATE TRIGGER todo_lists_insert AFTER INSERT ON todo_lists FOR EACH ROW @@ -438,7 +438,7 @@ CREATE TRIGGER todo_lists_delete With this setup, local-only columns can be queried and updated using standard SQL without affecting sync: -```SQL +```sql -- Updating a local-only column does not produce a CRUD entry UPDATE todo_lists SET is_pinned = 1 WHERE id = '...';