Skip to content

Commit dbdfb28

Browse files
SimonWoolfclaude
andcommitted
feat(rules)!: create channel rules as match expressions
Channel rules now come in two modes. A `matcher` rule's id is a match expression, with the same syntax as a capability resource ("chat:*", "*:presence", "foo:*:baz"); a `namespace` rule's id is a single segment that matches that channel plus everything beneath it. New rules should be matchers - the dashboard only creates matchers - so `rules create` now always does. `rules update` sends no mode, so a rule keeps the one it was created with. `create`, `update` and `list` show each rule's mode, since the same-looking id means different things in each. BREAKING CHANGE: `ably apps rules create "chat"` used to create a rule applying to `chat` and every channel beneath it (`chat:room1`, ...). It now applies to the channel `chat` alone. To select the channels beneath it, use `ably apps rules create "chat:*"`. Existing rules are unaffected and keep matching as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent e2f422a commit dbdfb28

10 files changed

Lines changed: 103 additions & 13 deletions

File tree

‎src/commands/apps/rules/create.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@ export default class RulesCreateCommand extends ControlBaseCommand {
99

1010
static args = {
1111
ruleName: Args.string({
12-
description: "Name of the rule",
12+
description:
13+
'Match expression selecting the channels the rule applies to, e.g. "chat:*" or "*:presence"',
1314
required: true,
1415
}),
1516
};
1617

1718
static examples = [
18-
'$ ably apps rules create "chat" --persisted',
19-
'$ ably apps rules create "chat" --mutable-messages',
20-
'$ ably apps rules create "events" --push-enabled',
21-
'$ ably apps rules create "notifications" --persisted --push-enabled --app "My App"',
22-
'$ ably apps rules create "chat" --persisted --json',
19+
'$ ably apps rules create "chat:*" --persisted',
20+
'$ ably apps rules create "chat:*" --mutable-messages',
21+
'$ ably apps rules create "*:presence" --push-enabled',
22+
'$ ably apps rules create "notifications:*" --persisted --push-enabled --app "My App"',
23+
'$ ably apps rules create "chat:*" --persisted --json',
2324
];
2425

2526
static flags = {
@@ -119,6 +120,7 @@ export default class RulesCreateCommand extends ControlBaseCommand {
119120
conflationEnabled: flags["conflation-enabled"],
120121
conflationInterval: flags["conflation-interval"],
121122
conflationKey: flags["conflation-key"],
123+
mode: "matcher" as const,
122124
mutableMessages,
123125
persistLast: flags["persist-last"],
124126
persisted,
@@ -145,6 +147,7 @@ export default class RulesCreateCommand extends ControlBaseCommand {
145147
conflationKey: createdNamespace.conflationKey ?? null,
146148
created: new Date(createdNamespace.created).toISOString(),
147149
id: createdNamespace.id,
150+
mode: createdNamespace.mode ?? null,
148151
modified: new Date(createdNamespace.modified).toISOString(),
149152
mutableMessages: createdNamespace.mutableMessages || false,
150153
persistLast: createdNamespace.persistLast || false,

‎src/commands/apps/rules/index.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ export default class RulesIndexCommand extends BaseTopicCommand {
44
protected topicName = "apps:rules";
55
protected commandGroup = "rules";
66

7-
static description = "Manage Ably rules (namespaces)";
7+
static description = "Manage Ably channel rules";
88

99
static examples = [
1010
"$ ably apps rules list",
11-
'$ ably apps rules create "chat" --persisted',
12-
'$ ably apps rules update "chat" --push-enabled',
13-
'$ ably apps rules delete "chat"',
11+
'$ ably apps rules create "chat:*" --persisted',
12+
'$ ably apps rules update "chat:*" --push-enabled',
13+
'$ ably apps rules delete "chat:*"',
1414
];
1515
}

‎src/commands/apps/rules/list.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { Flags } from "@oclif/core";
2-
import type { Namespace } from "../../../services/control-api.js";
2+
import type {
3+
ChannelRuleMode,
4+
Namespace,
5+
} from "../../../services/control-api.js";
36

47
import { ControlBaseCommand } from "../../../control-base-command.js";
58
import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js";
@@ -18,6 +21,7 @@ interface ChannelRuleOutput {
1821
conflationKey: null | string;
1922
created: string;
2023
id: string;
24+
mode: ChannelRuleMode | null;
2125
modified: string;
2226
mutableMessages: boolean;
2327
persistLast: boolean;
@@ -76,6 +80,7 @@ export default class RulesListCommand extends ControlBaseCommand {
7680
conflationKey: rule.conflationKey ?? null,
7781
created: new Date(rule.created).toISOString(),
7882
id: rule.id,
83+
mode: rule.mode ?? null,
7984
modified: new Date(rule.modified).toISOString(),
8085
mutableMessages: rule.mutableMessages || false,
8186
persistLast: rule.persistLast || false,

‎src/commands/apps/rules/update.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export default class RulesUpdateCommand extends ControlBaseCommand {
238238
conflationKey: updatedNamespace.conflationKey ?? null,
239239
created: new Date(updatedNamespace.created).toISOString(),
240240
id: updatedNamespace.id,
241+
mode: updatedNamespace.mode ?? null,
241242
modified: new Date(updatedNamespace.modified).toISOString(),
242243
mutableMessages: updatedNamespace.mutableMessages || false,
243244
persistLast: updatedNamespace.persistLast || false,

‎src/services/control-api.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ export interface Key {
6565
status: string;
6666
}
6767

68+
// `matcher` rules select channels by match expression (e.g. "chat:*");
69+
// `namespace` rules predate them and match a first segment plus everything
70+
// beneath it. A rule's mode is fixed at creation.
71+
export type ChannelRuleMode = "matcher" | "namespace";
72+
6873
export interface Namespace {
6974
appId: string;
7075
authenticated?: boolean;
@@ -76,6 +81,7 @@ export interface Namespace {
7681
created: number;
7782
exposeTimeSerial?: boolean;
7883
id: string;
84+
mode?: ChannelRuleMode;
7985
modified: number;
8086
mutableMessages?: boolean;
8187
persistLast?: boolean;
@@ -338,6 +344,7 @@ export class ControlApi {
338344
conflationInterval?: number;
339345
conflationKey?: string;
340346
exposeTimeSerial?: boolean;
347+
mode?: ChannelRuleMode;
341348
mutableMessages?: boolean;
342349
persistLast?: boolean;
343350
persisted?: boolean;

‎src/utils/channel-rule-display.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,16 @@ export function formatChannelRuleDetails(
3737
showTimestamps = true,
3838
} = options;
3939
const bool = bold ? boolFieldBold : boolField;
40-
const lines: string[] = [
40+
const lines: string[] = [];
41+
42+
if (rule.mode) {
43+
lines.push(`${indent}${formatLabel("Mode")} ${rule.mode}`);
44+
}
45+
46+
lines.push(
4147
`${indent}${formatLabel("Persisted")} ${bool(rule.persisted)}`,
4248
`${indent}${formatLabel("Push Enabled")} ${bool(rule.pushEnabled)}`,
43-
];
49+
);
4450

4551
if (rule.mutableMessages !== undefined) {
4652
lines.push(

‎test/fixtures/control-api.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* These provide realistic default values that can be overridden per-test.
55
*/
66

7+
import type { ChannelRuleMode } from "../../src/services/control-api.js";
8+
79
export interface MockApp {
810
id: string;
911
accountId: string;
@@ -134,6 +136,7 @@ export interface MockNamespace {
134136
id: string;
135137
persisted: boolean;
136138
pushEnabled: boolean;
139+
mode?: ChannelRuleMode;
137140
mutableMessages?: boolean;
138141
created: number;
139142
modified: number;

‎test/unit/commands/apps/rules/create.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,23 @@ describe("apps:rules:create command", () => {
157157
expect(result!.rule).toHaveProperty("persisted", true);
158158
expect(result!.rule).toHaveProperty("mutableMessages", true);
159159
});
160+
161+
it("should always create a matcher-mode rule", async () => {
162+
const appId = getMockConfigManager().getCurrentAppId()!;
163+
nockControl()
164+
.post(`/v1/apps/${appId}/namespaces`, (body) => {
165+
return body.id === "chat:*" && body.mode === "matcher";
166+
})
167+
.reply(201, mockNamespace({ id: "chat:*", mode: "matcher" }));
168+
169+
const { stdout } = await runCommand(
170+
["apps:rules:create", "chat:*", "--json"],
171+
import.meta.url,
172+
);
173+
174+
const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!;
175+
expect(result.rule).toHaveProperty("mode", "matcher");
176+
});
160177
});
161178

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

‎test/unit/commands/apps/rules/list.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,31 @@ describe("apps:rules:list command", () => {
122122
expect(result).toHaveProperty("success", true);
123123
expect(result.rules).toHaveLength(2);
124124
});
125+
126+
it("should show each rule's mode", async () => {
127+
const appId = getMockConfigManager().getCurrentAppId()!;
128+
nockControl()
129+
.get(`/v1/apps/${appId}/namespaces`)
130+
.times(2)
131+
.reply(200, [
132+
mockNamespace({ id: "chat:*", mode: "matcher" }),
133+
mockNamespace({ id: "legacy", mode: "namespace" }),
134+
]);
135+
136+
const { stdout } = await runCommand(["apps:rules:list"], import.meta.url);
137+
expect(stdout).toContain("Mode: matcher");
138+
expect(stdout).toContain("Mode: namespace");
139+
140+
const { stdout: jsonStdout } = await runCommand(
141+
["apps:rules:list", "--json"],
142+
import.meta.url,
143+
);
144+
const result = parseJsonOutput(jsonStdout);
145+
expect(result.rules.map((r: { mode: string }) => r.mode)).toEqual([
146+
"matcher",
147+
"namespace",
148+
]);
149+
});
125150
});
126151

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

‎test/unit/commands/apps/rules/update.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,29 @@ describe("apps:rules:update command", () => {
209209
expect(result).toHaveProperty("success", true);
210210
expect(result!.rule).toHaveProperty("persisted", true);
211211
});
212+
213+
it("should not send a mode, so an older namespace-mode rule stays one", async () => {
214+
const appId = getMockConfigManager().getCurrentAppId()!;
215+
nockControl()
216+
.get(`/v1/apps/${appId}/namespaces`)
217+
.reply(200, [mockNamespace({ id: mockRuleId, mode: "namespace" })]);
218+
219+
nockControl()
220+
.patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`, (body) => {
221+
return body.persisted === true && !("mode" in body);
222+
})
223+
.reply(
224+
200,
225+
mockNamespace({ id: mockRuleId, mode: "namespace", persisted: true }),
226+
);
227+
228+
const { stdout } = await runCommand(
229+
["apps:rules:update", mockRuleId, "--persisted"],
230+
import.meta.url,
231+
);
232+
233+
expect(stdout).toContain("Mode: namespace");
234+
});
212235
});
213236

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

0 commit comments

Comments
 (0)