@frockbot/configuration-core 0.3.10 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/configuration-core",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,8 +12,8 @@
12
12
  "typecheck": "tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "dependencies": {
15
- "@frockbot/connection-core": "0.3.10",
16
- "@frockbot/kernel-composition": "0.3.10"
15
+ "@frockbot/connection-core": "0.3.11",
16
+ "@frockbot/kernel-composition": "0.3.11"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/bun": "1.3.6",
@@ -182,4 +182,40 @@ describe("bot/set-profile", () => {
182
182
  ),
183
183
  ).toEqual({ name: "Housework" });
184
184
  });
185
+
186
+ test("pins with an instant and unpins with the empty string", () => {
187
+ const at = "2026-09-03T10:15:00.000Z";
188
+ expect(
189
+ decodeBotSettingsViewV1(settings({ name: "Housework", pinnedAt: at })),
190
+ ).toMatchObject({ profile: { pinnedAt: at } });
191
+ expect(() =>
192
+ decodeBotSettingsViewV1(
193
+ settings({ name: "Housework", pinnedAt: "whenever" }),
194
+ ),
195
+ ).toThrow("profile.pinnedAt is invalid");
196
+
197
+ const current: BotProfile = { name: "Housework", pinnedAt: at };
198
+ // A later save must not reshuffle the pinned row: the instant is durable,
199
+ // so only an explicit change moves it.
200
+ expect(
201
+ applyBotProfilePatchV1(current, { title: "Chief" }, "user").pinnedAt,
202
+ ).toBe(at);
203
+ expect(applyBotProfilePatchV1(current, { pinnedAt: "" }, "user")).toEqual({
204
+ name: "Housework",
205
+ });
206
+ expect(
207
+ applyBotProfilePatchV1({ name: "Housework" }, { pinnedAt: at }, "user")
208
+ .pinnedAt,
209
+ ).toBe(at);
210
+ expect(() =>
211
+ decodeConfigurationCommandV1({
212
+ schemaVersion: 1,
213
+ type: "bot/set-profile",
214
+ commandId: "command-1",
215
+ botId: "primary",
216
+ expectedRevision: 3,
217
+ profile: { pinnedAt: "soon" },
218
+ }),
219
+ ).toThrow("profile.pinnedAt is invalid");
220
+ });
185
221
  });
package/src/index.ts CHANGED
@@ -99,6 +99,12 @@ export interface BotProfile {
99
99
  namedBy?: BotNameProvenanceV1;
100
100
  /** Keeps the Bot out of the default sidebar list without archiving it. */
101
101
  hiddenFromSidebar?: boolean;
102
+ /**
103
+ * When the User pinned this Bot, as an ISO 8601 instant. Absent means not
104
+ * pinned. The instant rather than a flag, so the pinned row keeps a stable
105
+ * order — earliest pin first — without a second ordering field.
106
+ */
107
+ pinnedAt?: string;
102
108
  }
103
109
 
104
110
  /**
@@ -112,6 +118,8 @@ export interface BotProfilePatchV1 {
112
118
  description?: string;
113
119
  title?: string;
114
120
  hiddenFromSidebar?: boolean;
121
+ /** An ISO 8601 instant pins the Bot; the empty string unpins it. */
122
+ pinnedAt?: string;
115
123
  }
116
124
 
117
125
  export interface BotNotificationPolicy {
@@ -1065,6 +1073,7 @@ const BOT_PROFILE_OPTIONAL_FIELDS = [
1065
1073
  "title",
1066
1074
  "namedBy",
1067
1075
  "hiddenFromSidebar",
1076
+ "pinnedAt",
1068
1077
  ] as const;
1069
1078
 
1070
1079
  function botProfile(value: unknown): BotProfile {
@@ -1096,9 +1105,21 @@ function botProfile(value: unknown): BotProfile {
1096
1105
  "profile.hiddenFromSidebar",
1097
1106
  ),
1098
1107
  }),
1108
+ ...(profile.pinnedAt === undefined
1109
+ ? {}
1110
+ : { pinnedAt: profileTimestamp(profile.pinnedAt, "profile.pinnedAt") }),
1099
1111
  };
1100
1112
  }
1101
1113
 
1114
+ /** An ISO 8601 instant a profile field carries. Never a duration or a count. */
1115
+ function profileTimestamp(value: unknown, label: string): string {
1116
+ const candidate = text(value, label, 64);
1117
+ if (!Number.isFinite(Date.parse(candidate))) {
1118
+ throw new ConfigurationDecodeError(`${label} is invalid`);
1119
+ }
1120
+ return candidate;
1121
+ }
1122
+
1102
1123
  /**
1103
1124
  * A patch field that carries text: a non-empty string sets it, and the empty
1104
1125
  * string clears it. A partial update has no other way to say "remove this".
@@ -1123,7 +1144,7 @@ function botProfilePatch(value: unknown): BotProfilePatchV1 {
1123
1144
  value,
1124
1145
  "profile",
1125
1146
  [],
1126
- ["name", "label", "description", "title", "hiddenFromSidebar"],
1147
+ ["name", "label", "description", "title", "hiddenFromSidebar", "pinnedAt"],
1127
1148
  );
1128
1149
  if (Reflect.ownKeys(patch).length === 0) {
1129
1150
  throw new ConfigurationDecodeError("profile has invalid fields");
@@ -1148,6 +1169,15 @@ function botProfilePatch(value: unknown): BotProfilePatchV1 {
1148
1169
  "profile.hiddenFromSidebar",
1149
1170
  ),
1150
1171
  }),
1172
+ // An instant pins; the empty string unpins, the same way text clears.
1173
+ ...(patch.pinnedAt === undefined
1174
+ ? {}
1175
+ : {
1176
+ pinnedAt:
1177
+ patchText(patch.pinnedAt, "profile.pinnedAt", 64) === ""
1178
+ ? ""
1179
+ : profileTimestamp(patch.pinnedAt, "profile.pinnedAt"),
1180
+ }),
1151
1181
  };
1152
1182
  }
1153
1183
 
@@ -1167,7 +1197,7 @@ export function applyBotProfilePatchV1(
1167
1197
  next.name = patch.name;
1168
1198
  next.namedBy = namedBy;
1169
1199
  }
1170
- for (const key of ["label", "description", "title"] as const) {
1200
+ for (const key of ["label", "description", "title", "pinnedAt"] as const) {
1171
1201
  const value = patch[key];
1172
1202
  if (value === undefined) continue;
1173
1203
  if (value === "") delete next[key];