Skip to content

Commit 51a86f4

Browse files
authored
Merge pull request #159 from ably/feature/channel-rule-mutable-messages
[DX-935] Add mutable-messages channel rule
2 parents 0d30bc9 + 4d5aafa commit 51a86f4

22 files changed

Lines changed: 438 additions & 35 deletions

File tree

.claude/skills/ably-new-command/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ if (this.shouldOutputJson(flags)) {
189189
|--------|-------|---------|
190190
| `formatProgress(msg)` | Action in progress — appends `...` automatically | `formatProgress("Attaching to channel")` |
191191
| `formatSuccess(msg)` | Green checkmarkalways end with `.` (period, not `!`) | `formatSuccess("Subscribed to channel " + formatResource(name) + ".")` |
192+
| `formatWarning(msg)` | Yellow ``for non-fatal warnings. Don't prefix with "Warning:" | `formatWarning("Persistence is automatically enabled.")` |
192193
| `formatListening(msg)` | Dim textauto-appends "Press Ctrl+C to exit." | `formatListening("Listening for messages.")` |
193194
| `formatResource(name)` | Cyanfor resource names, never use quotes | `formatResource(channelName)` |
194195
| `formatTimestamp(ts)` | Dim `[timestamp]`for event streams | `formatTimestamp(isoString)` |
@@ -370,7 +371,7 @@ pnpm test:unit # Run tests
370371
- [ ] Correct flag set (`productApiFlags` vs `ControlBaseCommand.globalFlags`)
371372
- [ ] `clientIdFlag` only if command needs client identity
372373
- [ ] All human output wrapped in `if (!this.shouldOutputJson(flags))`
373-
- [ ] Output helpers used correctly (`formatProgress`, `formatSuccess`, `formatListening`, `formatResource`, `formatTimestamp`, `formatClientId`, `formatEventType`, `formatLabel`, `formatHeading`, `formatIndex`)
374+
- [ ] Output helpers used correctly (`formatProgress`, `formatSuccess`, `formatWarning`, `formatListening`, `formatResource`, `formatTimestamp`, `formatClientId`, `formatEventType`, `formatLabel`, `formatHeading`, `formatIndex`)
374375
- [ ] `success()` messages end with `.` (period)
375376
- [ ] Resource names use `resource(name)`, never quoted
376377
- [ ] JSON output uses `logJsonResult()` (one-shot) or `logJsonEvent()` (streaming), not direct `formatJsonRecord()`

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ All output helpers use the `format` prefix and are exported from `src/utils/outp
186186

187187
- **Progress**: `formatProgress("Attaching to channel: " + formatResource(name))`no color on action text, appends `...` automatically. Never manually write `"Doing something..."`always use `formatProgress("Doing something")`.
188188
- **Success**: `formatSuccess("Message published to channel " + formatResource(name) + ".")`green checkmark, **must** end with `.` (not `!`). Never use `chalk.green(...)` directlyalways use `formatSuccess()`.
189+
- **Warnings**: `formatWarning("Message text here.")`yellow `` symbol. Never use `chalk.yellow("Warning: ...")` directlyalways use `formatWarning()`. Don't include "Warning:" prefix in the message — the symbol conveys it.
189190
- **Listening**: `formatListening("Listening for messages.")`dim, includes "Press Ctrl+C to exit." Don't combine listening text inside a `formatSuccess()` call — use a separate `formatListening()` call.
190191
- **Resource names**: Always `formatResource(name)` (cyan), never quotedincluding in `logCliEvent` messages.
191192
- **Timestamps**: `formatTimestamp(ts)`dim `[timestamp]` for event streams. `formatMessageTimestamp(message.timestamp)`converts Ably message timestamp (number|undefined) to ISO string.
@@ -229,7 +230,6 @@ this.error() ← oclif exit (ONLY inside fail, nowhere else)
229230
- **`runControlCommand<T>`** returns `Promise<T>` (not nullable) — calls `this.fail()` internally on error.
230231
231232
### Additional output patterns (direct chalk, not helpers)
232-
- **Warnings**: `chalk.yellow("Warning: ...")` — for non-fatal warnings
233233
- **No app error**: `'No app specified. Use --app flag or select an app with "ably apps switch"'`
234234
235235
### Help output theme

src/base-command.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { CommandError } from "./errors/command-error.js";
1313
import { coreGlobalFlags } from "./flags.js";
1414
import { InteractiveHelper } from "./services/interactive-helper.js";
1515
import { BaseFlags, CommandConfig } from "./types/cli.js";
16-
import { buildJsonRecord } from "./utils/output.js";
16+
import { buildJsonRecord, formatWarning } from "./utils/output.js";
1717
import { getCliVersion } from "./utils/version.js";
1818
import Spaces from "@ably/spaces";
1919
import { ChatClient } from "@ably/chat";
@@ -1265,7 +1265,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
12651265
// Log timeout only if not in JSON mode
12661266
if (!this.shouldOutputJson({})) {
12671267
// TODO: Pass actual flags here
1268-
this.log(chalk.yellow("Cleanup operation timed out."));
1268+
this.log(formatWarning("Cleanup operation timed out."));
12691269
}
12701270
reject(new Error("Cleanup timed out")); // Reject promise on timeout
12711271
}, effectiveTimeout);
@@ -1352,7 +1352,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
13521352
break;
13531353
}
13541354
case "disconnected": {
1355-
this.log(chalk.yellow("! Disconnected from Ably"));
1355+
this.log(formatWarning("Disconnected from Ably"));
13561356
break;
13571357
}
13581358
case "failed": {
@@ -1364,7 +1364,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
13641364
break;
13651365
}
13661366
case "suspended": {
1367-
this.log(chalk.yellow("! Connection suspended"));
1367+
this.log(formatWarning("Connection suspended"));
13681368
break;
13691369
}
13701370
case "connecting": {

src/base-topic-command.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import inquirer from "inquirer";
33
import pkg from "fast-levenshtein";
44
import { InteractiveBaseCommand } from "./interactive-base-command.js";
55
import { runInquirerWithReadlineRestore } from "./utils/readline-helper.js";
6+
import { formatWarning } from "./utils/output.js";
67
import * as readline from "node:readline";
78
import {
89
WEB_CLI_RESTRICTED_COMMANDS,
@@ -104,7 +105,7 @@ export abstract class BaseTopicCommand extends InteractiveBaseCommand {
104105
// In interactive mode, we need to ensure the message is visible
105106
// Write directly to stderr to avoid readline interference
106107
if (isInteractiveMode) {
107-
process.stderr.write(chalk.yellow(`Warning: ${warningMessage}\n`));
108+
process.stderr.write(`${formatWarning(warningMessage)}\n`);
108109
} else {
109110
this.warn(warningMessage);
110111
}

src/chat-base-command.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { productApiFlags } from "./flags.js";
55
import { BaseFlags } from "./types/cli.js";
66
import chalk from "chalk";
77

8-
import { formatSuccess, formatListening } from "./utils/output.js";
8+
import {
9+
formatSuccess,
10+
formatListening,
11+
formatWarning,
12+
} from "./utils/output.js";
913
import isTestMode from "./utils/test-mode.js";
1014

1115
export abstract class ChatBaseCommand extends AblyBaseCommand {
@@ -128,7 +132,7 @@ export abstract class ChatBaseCommand extends AblyBaseCommand {
128132
}
129133
case RoomStatus.Detached: {
130134
if (!this.shouldOutputJson(flags)) {
131-
this.log(chalk.yellow("Disconnected from Ably"));
135+
this.log(formatWarning("Disconnected from Ably"));
132136
}
133137
break;
134138
}

src/commands/apps/channel-rules/create.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ import { Flags } from "@oclif/core";
22

33
import { ControlBaseCommand } from "../../../control-base-command.js";
44
import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js";
5-
import { formatSuccess } from "../../../utils/output.js";
5+
import {
6+
formatLabel,
7+
formatSuccess,
8+
formatWarning,
9+
} from "../../../utils/output.js";
610

711
export default class ChannelRulesCreateCommand extends ControlBaseCommand {
812
static description = "Create a channel rule";
913

1014
static examples = [
1115
'$ ably apps channel-rules create --name "chat" --persisted',
16+
'$ ably apps channel-rules create --name "chat" --mutable-messages',
1217
'$ ably apps channel-rules create --name "events" --push-enabled',
1318
'$ ably apps channel-rules create --name "notifications" --persisted --push-enabled --app "My App"',
1419
'$ ably apps channel-rules create --name "chat" --persisted --json',
@@ -55,6 +60,11 @@ export default class ChannelRulesCreateCommand extends ControlBaseCommand {
5560
"Whether to expose the time serial for messages on channels matching this rule",
5661
required: false,
5762
}),
63+
"mutable-messages": Flags.boolean({
64+
description:
65+
"Whether messages on channels matching this rule can be updated or deleted after publishing. Automatically enables message persistence.",
66+
required: false,
67+
}),
5868
name: Flags.string({
5969
description: "Name of the channel rule",
6070
required: true,
@@ -94,6 +104,22 @@ export default class ChannelRulesCreateCommand extends ControlBaseCommand {
94104

95105
try {
96106
const controlApi = this.createControlApi(flags);
107+
108+
// When mutableMessages is enabled, persisted must also be enabled
109+
const mutableMessages = flags["mutable-messages"];
110+
let persisted = flags.persisted;
111+
112+
if (mutableMessages) {
113+
persisted = true;
114+
if (!this.shouldOutputJson(flags)) {
115+
this.logToStderr(
116+
formatWarning(
117+
"Message persistence is automatically enabled when mutable messages is enabled.",
118+
),
119+
);
120+
}
121+
}
122+
97123
const namespaceData = {
98124
authenticated: flags.authenticated,
99125
batchingEnabled: flags["batching-enabled"],
@@ -103,8 +129,9 @@ export default class ChannelRulesCreateCommand extends ControlBaseCommand {
103129
conflationInterval: flags["conflation-interval"],
104130
conflationKey: flags["conflation-key"],
105131
exposeTimeSerial: flags["expose-time-serial"],
132+
mutableMessages,
106133
persistLast: flags["persist-last"],
107-
persisted: flags.persisted,
134+
persisted,
108135
populateChannelRegistry: flags["populate-channel-registry"],
109136
pushEnabled: flags["push-enabled"],
110137
tlsOnly: flags["tls-only"],
@@ -129,6 +156,7 @@ export default class ChannelRulesCreateCommand extends ControlBaseCommand {
129156
created: new Date(createdNamespace.created).toISOString(),
130157
exposeTimeSerial: createdNamespace.exposeTimeSerial,
131158
id: createdNamespace.id,
159+
mutableMessages: createdNamespace.mutableMessages,
132160
name: flags.name,
133161
persistLast: createdNamespace.persistLast,
134162
persisted: createdNamespace.persisted,
@@ -142,7 +170,7 @@ export default class ChannelRulesCreateCommand extends ControlBaseCommand {
142170
);
143171
} else {
144172
this.log(formatSuccess("Channel rule created."));
145-
this.log(`ID: ${createdNamespace.id}`);
173+
this.log(`${formatLabel("ID")} ${createdNamespace.id}`);
146174
for (const line of formatChannelRuleDetails(createdNamespace, {
147175
formatDate: (t) => this.formatDate(t),
148176
})) {

src/commands/apps/channel-rules/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default class ChannelRulesIndexCommand extends BaseTopicCommand {
99
static examples = [
1010
"ably apps channel-rules list",
1111
'ably apps channel-rules create --name "chat" --persisted',
12+
"ably apps channel-rules update chat --mutable-messages",
1213
"ably apps channel-rules update chat --push-enabled",
1314
"ably apps channel-rules delete chat",
1415
];

src/commands/apps/channel-rules/list.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ interface ChannelRuleOutput {
1616
exposeTimeSerial: boolean;
1717
id: string;
1818
modified: string;
19+
mutableMessages: boolean;
1920
persistLast: boolean;
2021
persisted: boolean;
2122
populateChannelRegistry: boolean;
@@ -65,6 +66,7 @@ export default class ChannelRulesListCommand extends ControlBaseCommand {
6566
exposeTimeSerial: rule.exposeTimeSerial || false,
6667
id: rule.id,
6768
modified: new Date(rule.modified).toISOString(),
69+
mutableMessages: rule.mutableMessages || false,
6870
persistLast: rule.persistLast || false,
6971
persisted: rule.persisted || false,
7072
populateChannelRegistry: rule.populateChannelRegistry || false,

src/commands/apps/channel-rules/update.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { Args, Flags } from "@oclif/core";
22

33
import { ControlBaseCommand } from "../../../control-base-command.js";
44
import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js";
5+
import {
6+
formatLabel,
7+
formatSuccess,
8+
formatWarning,
9+
} from "../../../utils/output.js";
510

611
export default class ChannelRulesUpdateCommand extends ControlBaseCommand {
712
static args = {
@@ -15,6 +20,7 @@ export default class ChannelRulesUpdateCommand extends ControlBaseCommand {
1520

1621
static examples = [
1722
"$ ably apps channel-rules update chat --persisted",
23+
"$ ably apps channel-rules update chat --mutable-messages",
1824
"$ ably apps channel-rules update events --push-enabled=false",
1925
'$ ably apps channel-rules update notifications --persisted --push-enabled --app "My App"',
2026
"$ ably apps channel-rules update chat --persisted --json",
@@ -65,6 +71,12 @@ export default class ChannelRulesUpdateCommand extends ControlBaseCommand {
6571
"Whether to expose the time serial for messages on channels matching this rule",
6672
required: false,
6773
}),
74+
"mutable-messages": Flags.boolean({
75+
allowNo: true,
76+
description:
77+
"Whether messages on channels matching this rule can be updated or deleted after publishing. Automatically enables message persistence.",
78+
required: false,
79+
}),
6880
"persist-last": Flags.boolean({
6981
allowNo: true,
7082
description:
@@ -120,10 +132,39 @@ export default class ChannelRulesUpdateCommand extends ControlBaseCommand {
120132
const updateData: Record<string, boolean | number | string | undefined> =
121133
{};
122134

135+
// Validation for mutable-messages flag, checks with supplied/existing mutableMessages flag
136+
if (
137+
flags.persisted === false &&
138+
(flags["mutable-messages"] === true ||
139+
(flags["mutable-messages"] === undefined &&
140+
namespace.mutableMessages))
141+
) {
142+
this.fail(
143+
"Cannot disable persistence when mutable messages is enabled. Mutable messages requires message persistence.",
144+
flags,
145+
"channelRuleUpdate",
146+
{ appId, ruleId: namespace.id },
147+
);
148+
}
149+
123150
if (flags.persisted !== undefined) {
124151
updateData.persisted = flags.persisted;
125152
}
126153

154+
if (flags["mutable-messages"] !== undefined) {
155+
updateData.mutableMessages = flags["mutable-messages"];
156+
if (flags["mutable-messages"]) {
157+
updateData.persisted = true;
158+
if (!this.shouldOutputJson(flags)) {
159+
this.logToStderr(
160+
formatWarning(
161+
"Message persistence is automatically enabled when mutable messages is enabled.",
162+
),
163+
);
164+
}
165+
}
166+
}
167+
127168
if (flags["push-enabled"] !== undefined) {
128169
updateData.pushEnabled = flags["push-enabled"];
129170
}
@@ -199,6 +240,7 @@ export default class ChannelRulesUpdateCommand extends ControlBaseCommand {
199240
exposeTimeSerial: updatedNamespace.exposeTimeSerial,
200241
id: updatedNamespace.id,
201242
modified: new Date(updatedNamespace.modified).toISOString(),
243+
mutableMessages: updatedNamespace.mutableMessages,
202244
persistLast: updatedNamespace.persistLast,
203245
persisted: updatedNamespace.persisted,
204246
populateChannelRegistry: updatedNamespace.populateChannelRegistry,
@@ -210,8 +252,8 @@ export default class ChannelRulesUpdateCommand extends ControlBaseCommand {
210252
flags,
211253
);
212254
} else {
213-
this.log("Channel rule updated successfully:");
214-
this.log(`ID: ${updatedNamespace.id}`);
255+
this.log(formatSuccess("Channel rule updated."));
256+
this.log(`${formatLabel("ID")} ${updatedNamespace.id}`);
215257
for (const line of formatChannelRuleDetails(updatedNamespace, {
216258
formatDate: (t) => this.formatDate(t),
217259
showTimestamps: true,

src/commands/connections/test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
formatProgress,
99
formatResource,
1010
formatSuccess,
11+
formatWarning,
1112
} from "../../utils/output.js";
1213

1314
export default class ConnectionsTest extends AblyBaseCommand {
@@ -172,7 +173,9 @@ export default class ConnectionsTest extends AblyBaseCommand {
172173
);
173174
} else if (partialSuccess) {
174175
this.log(
175-
`${chalk.yellow("!")} Some connection tests succeeded, but others failed`,
176+
formatWarning(
177+
"Some connection tests succeeded, but others failed",
178+
),
176179
);
177180
} else {
178181
this.log(`${chalk.red("✗")} All connection tests failed`);

0 commit comments

Comments
 (0)