Skip to content

Commit cb8fa54

Browse files
sacOO7umair-ably
authored andcommitted
Add mutable-messages flag to channel rules
Add --mutable-messages flag to channel-rules create and update commands, enabling message editing and deletion on channels. Persistence is automatically enabled when mutable messages is set. Validation prevents disabling persistence while mutable messages is active.
1 parent 661aad7 commit cb8fa54

File tree

10 files changed

+327
-8
lines changed

10 files changed

+327
-8
lines changed

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: 42 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,37 @@ 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"] || namespace.mutableMessages)
139+
) {
140+
this.fail(
141+
"Cannot disable persistence when mutable messages is enabled. Mutable messages requires message persistence.",
142+
flags,
143+
"channelRuleUpdate",
144+
{ appId, ruleId: namespace.id },
145+
);
146+
}
147+
123148
if (flags.persisted !== undefined) {
124149
updateData.persisted = flags.persisted;
125150
}
126151

152+
if (flags["mutable-messages"] !== undefined) {
153+
updateData.mutableMessages = flags["mutable-messages"];
154+
if (flags["mutable-messages"]) {
155+
updateData.persisted = true;
156+
if (!this.shouldOutputJson(flags)) {
157+
this.logToStderr(
158+
formatWarning(
159+
"Message persistence is automatically enabled when mutable messages is enabled.",
160+
),
161+
);
162+
}
163+
}
164+
}
165+
127166
if (flags["push-enabled"] !== undefined) {
128167
updateData.pushEnabled = flags["push-enabled"];
129168
}
@@ -199,6 +238,7 @@ export default class ChannelRulesUpdateCommand extends ControlBaseCommand {
199238
exposeTimeSerial: updatedNamespace.exposeTimeSerial,
200239
id: updatedNamespace.id,
201240
modified: new Date(updatedNamespace.modified).toISOString(),
241+
mutableMessages: updatedNamespace.mutableMessages,
202242
persistLast: updatedNamespace.persistLast,
203243
persisted: updatedNamespace.persisted,
204244
populateChannelRegistry: updatedNamespace.populateChannelRegistry,
@@ -210,8 +250,8 @@ export default class ChannelRulesUpdateCommand extends ControlBaseCommand {
210250
flags,
211251
);
212252
} else {
213-
this.log("Channel rule updated successfully:");
214-
this.log(`ID: ${updatedNamespace.id}`);
253+
this.log(formatSuccess("Channel rule updated."));
254+
this.log(`${formatLabel("ID")} ${updatedNamespace.id}`);
215255
for (const line of formatChannelRuleDetails(updatedNamespace, {
216256
formatDate: (t) => this.formatDate(t),
217257
showTimestamps: true,

src/services/control-api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export interface Namespace {
5757
exposeTimeSerial?: boolean;
5858
id: string;
5959
modified: number;
60+
mutableMessages?: boolean;
6061
persistLast?: boolean;
6162
persisted: boolean;
6263
populateChannelRegistry?: boolean;
@@ -231,6 +232,7 @@ export class ControlApi {
231232
conflationInterval?: number;
232233
conflationKey?: string;
233234
exposeTimeSerial?: boolean;
235+
mutableMessages?: boolean;
234236
persistLast?: boolean;
235237
persisted?: boolean;
236238
populateChannelRegistry?: boolean;
@@ -457,6 +459,7 @@ export class ControlApi {
457459
conflationInterval?: number;
458460
conflationKey?: string;
459461
exposeTimeSerial?: boolean;
462+
mutableMessages?: boolean;
460463
persistLast?: boolean;
461464
persisted?: boolean;
462465
populateChannelRegistry?: boolean;

src/utils/channel-rule-display.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export function formatChannelRuleDetails(
4343
`${indent}Push Enabled: ${bool(rule.pushEnabled)}`,
4444
);
4545

46+
if (rule.mutableMessages !== undefined) {
47+
lines.push(`${indent}Mutable Messages: ${bool(rule.mutableMessages)}`);
48+
}
49+
4650
if (rule.authenticated !== undefined) {
4751
lines.push(`${indent}Authenticated: ${bool(rule.authenticated)}`);
4852
}

test/unit/commands/apps/channel-rules/create.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,73 @@ describe("apps:channel-rules:create command", () => {
7979
expect(stdout).toContain("Push Enabled: Yes");
8080
});
8181

82+
it("should create a channel rule with mutable-messages flag and auto-enable persisted", async () => {
83+
const appId = getMockConfigManager().getCurrentAppId()!;
84+
nock("https://control.ably.net")
85+
.post(`/v1/apps/${appId}/namespaces`, (body) => {
86+
return body.mutableMessages === true && body.persisted === true;
87+
})
88+
.reply(201, {
89+
id: mockRuleId,
90+
persisted: true,
91+
pushEnabled: false,
92+
mutableMessages: true,
93+
created: Date.now(),
94+
modified: Date.now(),
95+
});
96+
97+
const { stdout, stderr } = await runCommand(
98+
[
99+
"apps:channel-rules:create",
100+
"--name",
101+
mockRuleName,
102+
"--mutable-messages",
103+
],
104+
import.meta.url,
105+
);
106+
107+
expect(stdout).toContain("Channel rule created.");
108+
expect(stdout).toContain("Persisted: Yes");
109+
expect(stdout).toContain("Mutable Messages: Yes");
110+
expect(stderr).toContain(
111+
"Message persistence is automatically enabled when mutable messages is enabled.",
112+
);
113+
});
114+
115+
it("should include mutableMessages in JSON output when --mutable-messages is used", async () => {
116+
const appId = getMockConfigManager().getCurrentAppId()!;
117+
nock("https://control.ably.net")
118+
.post(`/v1/apps/${appId}/namespaces`, (body) => {
119+
return body.mutableMessages === true && body.persisted === true;
120+
})
121+
.reply(201, {
122+
id: mockRuleId,
123+
persisted: true,
124+
pushEnabled: false,
125+
mutableMessages: true,
126+
created: Date.now(),
127+
modified: Date.now(),
128+
});
129+
130+
const { stdout, stderr } = await runCommand(
131+
[
132+
"apps:channel-rules:create",
133+
"--name",
134+
mockRuleName,
135+
"--mutable-messages",
136+
"--json",
137+
],
138+
import.meta.url,
139+
);
140+
141+
const result = JSON.parse(stdout);
142+
expect(result).toHaveProperty("success", true);
143+
expect(result.rule).toHaveProperty("mutableMessages", true);
144+
expect(result.rule).toHaveProperty("persisted", true);
145+
// Warning should not appear in JSON mode
146+
expect(stderr).not.toContain("Warning");
147+
});
148+
82149
it("should output JSON format when --json flag is used", async () => {
83150
const appId = getMockConfigManager().getCurrentAppId()!;
84151
const mockRule = {

test/unit/commands/apps/channel-rules/list.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,69 @@ describe("apps:channel-rules:list command", () => {
8484
expect(stdout).toContain("Persisted: ✓ Yes");
8585
expect(stdout).toContain("Push Enabled: ✓ Yes");
8686
});
87+
88+
it("should display mutableMessages in rule details", async () => {
89+
const appId = getMockConfigManager().getCurrentAppId()!;
90+
const mockRules = [
91+
{
92+
id: "mutable-chat",
93+
persisted: true,
94+
pushEnabled: false,
95+
mutableMessages: true,
96+
created: Date.now(),
97+
modified: Date.now(),
98+
},
99+
];
100+
101+
nock("https://control.ably.net")
102+
.get(`/v1/apps/${appId}/namespaces`)
103+
.reply(200, mockRules);
104+
105+
const { stdout } = await runCommand(
106+
["apps:channel-rules:list"],
107+
import.meta.url,
108+
);
109+
110+
expect(stdout).toContain("Found 1 channel rules");
111+
expect(stdout).toContain("mutable-chat");
112+
expect(stdout).toContain("Mutable Messages: ✓ Yes");
113+
});
114+
115+
it("should include mutableMessages in JSON output", async () => {
116+
const appId = getMockConfigManager().getCurrentAppId()!;
117+
const mockRules = [
118+
{
119+
id: "mutable-chat",
120+
persisted: true,
121+
pushEnabled: false,
122+
mutableMessages: true,
123+
created: Date.now(),
124+
modified: Date.now(),
125+
},
126+
{
127+
id: "regular-chat",
128+
persisted: false,
129+
pushEnabled: false,
130+
created: Date.now(),
131+
modified: Date.now(),
132+
},
133+
];
134+
135+
nock("https://control.ably.net")
136+
.get(`/v1/apps/${appId}/namespaces`)
137+
.reply(200, mockRules);
138+
139+
const { stdout } = await runCommand(
140+
["apps:channel-rules:list", "--json"],
141+
import.meta.url,
142+
);
143+
144+
const result = JSON.parse(stdout);
145+
expect(result).toHaveProperty("success", true);
146+
expect(result.rules).toHaveLength(2);
147+
expect(result.rules[0]).toHaveProperty("mutableMessages", true);
148+
expect(result.rules[1]).toHaveProperty("mutableMessages", false);
149+
});
87150
});
88151

89152
describe("error handling", () => {

0 commit comments

Comments
 (0)