safe-migrate 0.4.2

Lint PostgreSQL migrations against live database statistics to prevent blocking locks
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
# Subscriptions AST Reference for safe-migrate

## Status

Verified against squawk_syntax 2.58.0 — July 2026

---

## Documentation Contract

1. Only document AST behavior that has been directly verified.
2. Do not infer PostgreSQL semantics from missing AST accessors.
3. Distinguish verified facts from unresolved areas.
4. Assume additional nodes or helpers may exist outside the inspected surface.

---

## Scope Note

PostgreSQL subscriptions are the receiving side of logical replication —
`CREATE SUBSCRIPTION` connects to a remote publisher and replicates its
publication(s) locally. This is the counterpart to publications.md.

---

# Core Nodes

## CreateSubscription

### Verified Accessors (line 5401)

```rust
pub fn literal(&self) -> Option<Literal>
pub fn name(&self) -> Option<Name>
pub fn name_ref(&self) -> Option<NameRef>
pub fn name_refs(&self) -> AstChildren<NameRef>
pub fn with_params(&self) -> Option<WithParams>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn connection_token(&self) -> Option<SyntaxToken>
pub fn create_token(&self) -> Option<SyntaxToken>
pub fn publication_token(&self) -> Option<SyntaxToken>
pub fn server_token(&self) -> Option<SyntaxToken>
pub fn subscription_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
CreateSubscription =
  'create' 'subscription' Name
  ('connection' Literal | 'server' NameRef)
  'publication' (NameRef (',' NameRef)*)
  WithParams? ';'?
```

### PostgreSQL Semantics Caveat

This document's analysis of the `SERVER name` form is based purely on what
the grammar parses, not on independent confirmation that `CREATE
SUBSCRIPTION ... SERVER name ...` is valid real-world PostgreSQL syntax.
Standard PostgreSQL `CREATE SUBSCRIPTION` documentation describes only the
`CONNECTION 'conninfo'` form for specifying the publisher connection — a
`SERVER name` alternative (referencing a foreign server object, similar to
foreign data wrapper syntax) was not independently verified against
PostgreSQL's own documentation in this pass. It's possible this grammar
alternative exists for a non-standard extension, a different PostgreSQL
version, or was added speculatively/defensively by the parser author. This
document treats it as parseable per the grammar regardless of its
real-world applicability, but the disambiguation risk discussed below is
only practically relevant if this form is ever actually encountered in real
migration SQL — worth flagging as a question for the user's own PostgreSQL
version/knowledge rather than asserting as definitely-real syntax.

### Critical Finding — Ambiguous NameRef Disambiguation

The grammar shows **two separate `NameRef`-bearing positions**:
1. The `SERVER name` connection target (single `NameRef`, only present in
   the `SERVER` form, mutually exclusive with `CONNECTION 'literal'`)
2. The `PUBLICATION name, name, ...` list (one or more `NameRef`, always present)

The verified accessor surface exposes **both** `name_ref()` (singular,
`support::child()` — returns the *first* matching `NameRef` child) and
`name_refs()` (plural, `support::children()` — returns *all* matching
`NameRef` children).

**This creates a genuine disambiguation risk identical in pattern to the
`RenameValue` (enums.md) and `AsFuncOption` (functions.md) flat-accessor
findings:**

- If the statement uses `CONNECTION 'literal'` (not `SERVER`), there is only
  one group of `NameRef` children in the subtree — the publication list.
  In this case, `name_ref()` returns the *first publication name* (not a
  server name, since none exists), and `name_refs()` returns the full
  publication list correctly. No ambiguity in this case.

- If the statement uses `SERVER name` instead of `CONNECTION`, there are
  now **two distinct groups of `NameRef` children**: the server name (one)
  and the publication list (one or more). In this case:
  - `name_ref()` returns the *first* `NameRef` in document order, which
    is the **server name** (since `'server' NameRef` appears before
    `'publication' (NameRef...)` in the grammar sequence).
  - `name_refs()` returns **all** `NameRef` children, meaning it would
    include the server name **mixed in with** the publication list — there
    is no accessor that isolates just the publication list when the
    `SERVER` form is used, since both groups share the same underlying
    `NameRef` type and `support::children()` does not distinguish by
    grammar position, only by type.

**This is a confirmed, real extraction ambiguity specific to the `SERVER`
connection-target form.** The `CONNECTION 'literal'` form is unambiguous;
the `SERVER name` form is not, because the flat `name_refs()` accessor
cannot separate "the server name" from "the publication list" — both are
just `NameRef` children of the same node, and `support::children::<NameRef>()`
does not know about grammar-level positional semantics.

### Discrimination Strategy

```rust
fn extract_create_subscription(node: &CreateSubscription) -> CreateSubscriptionFact {
    let uses_server = node.server_token().is_some();
    let uses_connection = node.connection_token().is_some();

    let all_name_refs: Vec<String> = node.name_refs().map(|n| n.text()).collect();

    let (server_name, publications) = if uses_server {
        // First NameRef is the server name; remaining are publications.
        // This relies on document order matching grammar declaration order,
        // which is true for support::children() but should be verified
        // empirically against real parsed output before relying on it,
        // since this is an inferred ordering assumption, not something
        // separately confirmed via a dedicated accessor.
        let mut iter = all_name_refs.into_iter();
        let server = iter.next();
        let pubs: Vec<String> = iter.collect();
        (server, pubs)
    } else {
        // CONNECTION form: no server NameRef exists, all NameRefs are publications.
        (None, all_name_refs)
    };

    CreateSubscriptionFact {
        name: node.name().map(|n| n.text()),
        connection: if uses_connection {
            ConnectionTarget::Literal(node.literal().map(|l| /* extract string */))
        } else {
            ConnectionTarget::Server(server_name)
        },
        publications,
        params: node.with_params().map(|p| /* extract */),
    }
}
```

**This positional-splitting approach (first `NameRef` = server, rest =
publications) is an inference based on grammar declaration order, not a
separately verified guarantee.** Unlike `ForeignKeyConstraint`'s
`from_columns()`/`to_columns()` (which are genuine handwritten accessors
verified directly in squawk.rs to do exactly this kind of positional split),
no equivalent handwritten extension exists for `CreateSubscription` per the
exhaustive `impl ast::*` inventory established in columns.md. This means the
positional-split approach above is the best available strategy but has NOT
been verified against actual parsed output in this pass — it should be
tested against a real `CREATE SUBSCRIPTION ... SERVER ... PUBLICATION ...`
statement before being trusted in production code.

### safe-migrate guidance

```rust
struct CreateSubscriptionFact {
    name: Option<String>,
    connection: ConnectionTarget,        // Literal(conn_string) | Server(name)
    publications: Vec<String>,
    params: Option<Vec<AttributeFact>>,  // includes e.g. enabled, slot_name, copy_data
}
```

A new subscription immediately begins replicating data from the publisher,
including an initial data copy (`copy_data = true` by default) unless
explicitly disabled via `with_params()`. This can be a substantial
operation against the source database depending on table sizes — relevant
context for safe-migrate if cross-database operational impact is ever part
of its risk model, though this is more of an operational/performance
concern than a schema-correctness one.

---

## DropSubscription

### Verified Accessors (line 8030)

```rust
pub fn if_exists(&self) -> Option<IfExists>
pub fn name_ref(&self) -> Option<NameRef>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn cascade_token(&self) -> Option<SyntaxToken>
pub fn drop_token(&self) -> Option<SyntaxToken>
pub fn restrict_token(&self) -> Option<SyntaxToken>
pub fn subscription_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
DropSubscription =
  'drop' 'subscription' IfExists? NameRef
  ('cascade' | 'restrict')? ';'?
```

Single subscription name only — unambiguous, no disambiguation risk (unlike
`CreateSubscription`, only one `NameRef`-bearing position exists here).

### safe-migrate guidance

```rust
struct DropSubscriptionFact {
    name: String,
    if_exists: bool,
}
```

`DROP SUBSCRIPTION` stops replication and (by default) drops the replication
slot on the publisher side too — an external-system side effect similar to
the one noted for `DropPublication`. Worth flagging as having impact beyond
the local database.

---

## AlterSubscription

### Verified Accessors (line 2249)

```rust
pub fn attribute_list(&self) -> Option<AttributeList>
pub fn literal(&self) -> Option<Literal>
pub fn name_ref(&self) -> Option<NameRef>
pub fn name_refs(&self) -> AstChildren<NameRef>
pub fn names(&self) -> AstChildren<Name>
pub fn owner_to(&self) -> Option<OwnerTo>
pub fn rename_to(&self) -> Option<RenameTo>
pub fn set_options(&self) -> Option<SetOptions>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn add_token(&self) -> Option<SyntaxToken>
pub fn alter_token(&self) -> Option<SyntaxToken>
pub fn connection_token(&self) -> Option<SyntaxToken>
pub fn disable_token(&self) -> Option<SyntaxToken>
pub fn drop_token(&self) -> Option<SyntaxToken>
pub fn enable_token(&self) -> Option<SyntaxToken>
pub fn publication_token(&self) -> Option<SyntaxToken>
pub fn refresh_token(&self) -> Option<SyntaxToken>
pub fn server_token(&self) -> Option<SyntaxToken>
pub fn set_token(&self) -> Option<SyntaxToken>
pub fn skip_token(&self) -> Option<SyntaxToken>
pub fn subscription_token(&self) -> Option<SyntaxToken>
pub fn with_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation — CORRECTION

```
AlterSubscription =
  'alter' 'subscription' NameRef
  ( ConnectionTarget            # connection_token() / server_token() + literal()/name_ref()
  | SetPublication              # set_token()/add_token()/drop_token() + publication_token() + name_refs()
  | RefreshPublication          # refresh_token()
  | Enable                      # enable_token()
  | Disable                     # disable_token()
  | SetOptions                  # set_token() + set_options()/attribute_list()
  | Skip                        # skip_token()
  | OwnerTo                     # owner_to()
  | RenameTo                    # rename_to()
  )? ';'?
```

**Correction:** An earlier draft claimed `AlterSubscription` was a black
box carrying "genuinely nothing beyond the subscription's own name." That
was incorrect. The actual node (line 2249) exposes a full set of token
accessors (`enable_token()`, `disable_token()`, `refresh_token()`,
`set_token()`, `skip_token()`, `add_token()`, `drop_token()`,
`connection_token()`, `server_token()`, `publication_token()`) plus child
accessors (`owner_to()`, `rename_to()`, `set_options()`, `attribute_list()`,
`name_refs()`, `literal()`). The operation type CAN be inferred from which
token/child accessors return `Some(...)`:

| Operation | Detecting accessor(s) |
|-----------|----------------------|
| `CONNECTION 'conninfo'` | `connection_token()` + `literal()` |
| `SERVER name` | `server_token()` + `name_ref()` |
| `SET PUBLICATION ...` | `set_token()` + `publication_token()` + `name_refs()` |
| `ADD PUBLICATION ...` | `add_token()` + `publication_token()` + `name_refs()` |
| `DROP PUBLICATION ...` | `drop_token()` + `publication_token()` + `name_refs()` |
| `REFRESH PUBLICATION` | `refresh_token()` |
| `ENABLE` | `enable_token()` |
| `DISABLE` | `disable_token()` |
| `SET (param = value)` | `set_token()` + `set_options()` / `attribute_list()` |
| `SKIP (...)` | `skip_token()` |
| `OWNER TO new_owner` | `owner_to()` |
| `RENAME TO new_name` | `rename_to()` |

The publication/table list for the SET/ADD/DROP PUBLICATION forms is
extractable via `name_refs()` (all `NameRef` children under those forms).
This matches the same rich extraction available for `CreateSubscription`.

Real PostgreSQL `ALTER SUBSCRIPTION` operations are therefore distinguishable
**at the operation-type level** via the token accessors above. (Fine-grained
parameter payloads may still require descending into `set_options()` /
`attribute_list()` / `literal()` — verified available.)

### safe-migrate guidance

```rust
enum AlterSubscriptionOp {
    Connection { conn: Option<Literal> },
    Server { server: Option<NameRef> },
    SetPublication { pubs: Vec<String> },   // via name_refs()
    AddPublication { pubs: Vec<String> },
    DropPublication { pubs: Vec<String> },
    Refresh,
    Enable,
    Disable,
    SetOptions { opts: Option<SetOptions> }, // via set_options()/attribute_list()
    Skip,
    OwnerTo { owner: Option<OwnerTo> },
    RenameTo { to: Option<RenameTo> },
}

struct AlterSubscriptionFact {
    name: String,                  // from name_ref()
    operation: AlterSubscriptionOp, // inferred from token/child accessors
}
```

Because the operation type IS extractable, safe-migrate should branch on the
detected `AlterSubscriptionOp` rather than blanket-tainting every statement.
`ENABLE`/`DISABLE`/`DROP PUBLICATION` remain operationally critical (they
control whether replication is running and what data flows) and should still
be flagged for review — but now the simulator knows *which* operation
occurred, not just that "an alter happened."

---

# Verified Findings Summary

## Confirmed Complete

- `CreateSubscription`: accessor surface fully resolved, though see the
  critical disambiguation finding below for the `SERVER` form
- `DropSubscription`: fully resolved, unambiguous

## Confirmed Partial — Genuine Extraction Ambiguity

- `CreateSubscription` using the `SERVER name` connection form: the server
  name and the publication list cannot be cleanly separated using only the
  generated accessors (`name_ref()` returns the first `NameRef`, which would
  be the server name in this form; `name_refs()` returns all `NameRef`
  children, mixing server name and publication list together). A
  positional-splitting strategy is proposed in this document but has not
  been empirically verified against real parsed output, since no handwritten
  accessor extension exists to do this disambiguation reliably (unlike the
  analogous `ForeignKeyConstraint.from_columns()`/`to_columns()` case). The
  `CONNECTION 'literal'` form does not have this ambiguity.

## Grammar-Confirmed Limitations

- `AlterSubscription`: earlier draft claimed it carried "nothing beyond the
  subscription name" (black box). **Corrected** — the node (line 2249)
  exposes a full operation-distinguishing accessor surface: token accessors
  (`enable_token()`, `disable_token()`, `refresh_token()`, `set_token()`,
  `skip_token()`, `add_token()`, `drop_token()`, `connection_token()`,
  `server_token()`, `publication_token()`) and child accessors
  (`owner_to()`, `rename_to()`, `set_options()`, `attribute_list()`,
  `name_refs()`, `literal()`). The operation type IS extractable, and the
  publication/table list for SET/ADD/DROP PUBLICATION is extractable via
  `name_refs()`. The only genuine limitation is that fine-grained parameter
  payloads require descending into `set_options()`/`attribute_list()`.

## Key Architectural Findings

1. **`CreateSubscription`'s `SERVER` form has a confirmed, real
   disambiguation ambiguity** that has no clean resolution via existing
   accessors — this should be flagged for empirical testing against actual
   parsed output before any safe-migrate code relies on the proposed
   positional-splitting workaround.
2. **`AlterSubscription` is a structured node, not a black box** — its
   operation type is inferable from token/child accessors (see the
   AlterSubscription section). `ENABLE`/`DISABLE`/`DROP PUBLICATION` remain
   operationally critical and should still be flagged for review, but the
   simulator can now branch on the detected operation rather than
   blanket-tainting every statement. (The `AlterPublication` finding in
   publications.md is a separate, still-valid black-box finding.)

## Grammar Cross-Check

This document was written with postgresql.ungram available from the start.
All nodes cross-checked in this single pass. The `AlterSubscription` finding
was re-verified against the actual node (line 2249) and corrected: it is
structured, not a black box.

---

# Remaining Open Questions

1. Whether the positional-splitting strategy for `CreateSubscription`'s
   `SERVER` form (first `NameRef` = server name, remainder = publication
   list) is empirically reliable against real parsed output.

   **Current status: reasonably well-supported but not empirically verified.**

   Supporting evidence: `support::children()` in rowan-based ASTs iterates
   in source-text document order, since the underlying CST preserves the
   complete source text with all tokens. This is confirmed as a reliable
   property by the `ForeignKeyConstraint.from_columns()`/`to_columns()`
   handwritten extension (squawk.rs line 38440), which uses exactly this
   positional ordering guarantee (`nth(0)` = first `ColumnList` = FROM
   columns, `nth(1)` = second `ColumnList` = TO columns). An equivalent
   positional split for `CreateSubscription`'s `NameRef` children follows
   the identical logic.

   The caveat about the `SERVER` form's real-world PostgreSQL validity
   (noted in the `CreateSubscription` section above — standard PostgreSQL
   `CREATE SUBSCRIPTION` may only support `CONNECTION`, not `SERVER`) means
   this disambiguation may never be exercised in practice regardless. It is
   retained as a documented open question because:
   (a) the grammar explicitly supports it, and
   (b) it would be a subtle, silent correctness bug if the `SERVER` form
       ever is encountered and the positional split is wrong.

   Resolution path: run `SourceFile::parse("CREATE SUBSCRIPTION s SERVER
   srv PUBLICATION pub1, pub2")` and inspect the resulting syntax tree's
   `NameRef` children order. This requires a live squawk.rs test environment,
   not static analysis of source text.