Skip to content

Commit 40edffe

Browse files
committed
feat: allow nested callback data key-values
1 parent 58aeca4 commit 40edffe

7 files changed

Lines changed: 182 additions & 23 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@gramio/callback-data",
3-
"version": "0.0.10",
3+
"version": "0.0.11",
44
"main": "dist/index.js",
55
"devDependencies": {
66
"@biomejs/biome": "^1.9.4",

src/index.ts

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ import type {
1515
Schema,
1616
} from "./types.ts";
1717

18+
export type {
19+
InferDataPack,
20+
InferDataUnpack,
21+
} from "./types.ts";
22+
1823
/**
1924
* Class-helper that construct schema and serialize/deserialize with {@link CallbackData.pack} and {@link CallbackData.unpack} methods
2025
*
@@ -220,6 +225,34 @@ export class CallbackData<
220225
return this;
221226
}
222227

228+
data<
229+
Key extends string,
230+
Optional extends boolean = false,
231+
const Data extends CallbackData = never,
232+
>(
233+
key: Key,
234+
data: Data,
235+
options?: FieldOptions<"data", Optional, never>,
236+
): CallbackData<
237+
Prettify<
238+
SchemaType & AddFieldOutput<"data", Key, Optional, never, never, Data>
239+
>,
240+
Prettify<
241+
SchemaTypeInput & AddFieldInput<"data", Key, Optional, never, never, Data>
242+
>
243+
> {
244+
const isOptional = options?.optional ?? false;
245+
246+
this.schema[isOptional ? "optional" : "required"].push({
247+
key,
248+
type: "data",
249+
data,
250+
});
251+
252+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
253+
return this as any;
254+
}
255+
223256
/**
224257
* Method that return {@link RegExp} to match this {@link CallbackData}
225258
*/
@@ -316,16 +349,3 @@ export class CallbackData<
316349
return this as any;
317350
}
318351
}
319-
320-
export type InferDataPack<T extends CallbackData> = T extends CallbackData<
321-
infer SchemaType,
322-
infer SchemaTypeInput
323-
>
324-
? SchemaTypeInput
325-
: never;
326-
export type InferDataUnpack<T extends CallbackData> = T extends CallbackData<
327-
infer SchemaType,
328-
infer SchemaTypeInput
329-
>
330-
? SchemaType
331-
: never;

src/serialization/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ export class CompactSerializer {
119119
}
120120
case "boolean":
121121
return value ? "1" : "0";
122+
case "data": {
123+
if (!field.data) {
124+
throw new Error(`Missing nested schema for field '${field.key}'`);
125+
}
126+
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
127+
const inner = CompactSerializer.serialize(field.data["schema"], value);
128+
return Buffer.from(inner, "utf8").toString("base64url");
129+
}
122130
default:
123131
throw new Error(`Unsupported type: ${field.type}`);
124132
}
@@ -163,6 +171,13 @@ export class CompactSerializer {
163171
}
164172
case "boolean":
165173
return value === "1";
174+
case "data": {
175+
if (!field.data) {
176+
throw new Error(`Missing nested schema for field '${field.key}'`);
177+
}
178+
const inner = Buffer.from(value, "base64url").toString("utf8");
179+
return CompactSerializer.deserialize(field.data["schema"], inner);
180+
}
166181
default:
167182
throw new Error(`Unsupported type: ${field.type}`);
168183
}

src/types.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,46 @@
1+
import type { CallbackData } from "./index.ts";
2+
13
export type Prettify<T> = { [Key in keyof T]: T[Key] } & {};
24

3-
type AllowedTypes = "string" | "number" | "boolean" | "enum" | "uuid";
5+
type AllowedTypes = "string" | "number" | "boolean" | "enum" | "uuid" | "data";
46

57
export interface FieldTypeToTsType<
68
Enum extends unknown[] | readonly unknown[],
9+
Data extends CallbackData = never,
710
> {
811
string: string;
912
number: number;
1013
boolean: boolean;
1114
enum: Enum;
1215
uuid: string;
16+
data: InferDataPack<Data>;
1317
}
1418

1519
export type AddFieldOutput<
1620
T extends AllowedTypes,
1721
Key extends string,
1822
Optional extends boolean = false,
1923
Enum extends unknown[] = never,
20-
Default extends FieldTypeToTsType<Enum>[T] = never,
24+
Default extends FieldTypeToTsType<Enum, Data>[T] = never,
25+
Data extends CallbackData = never,
2126
> = [Default] extends [never]
2227
? Optional extends true
23-
? { [K in Key]?: FieldTypeToTsType<Enum>[T] }
24-
: { [K in Key]: FieldTypeToTsType<Enum>[T] }
25-
: { [K in Key]: FieldTypeToTsType<Enum>[T] };
28+
? { [K in Key]?: FieldTypeToTsType<Enum, Data>[T] }
29+
: { [K in Key]: FieldTypeToTsType<Enum, Data>[T] }
30+
: { [K in Key]: FieldTypeToTsType<Enum, Data>[T] };
2631

2732
export type AddFieldInput<
2833
T extends AllowedTypes,
2934
Key extends string,
3035
Optional extends boolean = false,
3136
Enum extends unknown[] = never,
32-
Default extends FieldTypeToTsType<Enum>[T] = never,
37+
Default extends FieldTypeToTsType<Enum, Data>[T] = never,
38+
Data extends CallbackData = never,
3339
> = [Default] extends [never]
3440
? Optional extends true
35-
? { [K in Key]?: FieldTypeToTsType<Enum>[T] }
36-
: { [K in Key]: FieldTypeToTsType<Enum>[T] }
37-
: { [K in Key]?: FieldTypeToTsType<Enum>[T] };
41+
? { [K in Key]?: FieldTypeToTsType<Enum, Data>[T] }
42+
: { [K in Key]: FieldTypeToTsType<Enum, Data>[T] }
43+
: { [K in Key]?: FieldTypeToTsType<Enum, Data>[T] };
3844

3945
export type EnumField<T extends unknown[]> = {
4046
enumValues: T;
@@ -60,11 +66,13 @@ export type Schema = {
6066
key: string;
6167
type: AllowedTypes;
6268
enumValues?: string[] | readonly string[];
69+
data?: CallbackData;
6370
}[];
6471
optional: {
6572
key: string;
6673
type: AllowedTypes;
6774
enumValues?: string[] | readonly string[];
75+
data?: CallbackData;
6876
default?: any;
6977
}[];
7078
};
@@ -74,3 +82,16 @@ export type IsOptionalType<T> = {
7482
}[keyof T] extends true
7583
? true
7684
: false;
85+
86+
export type InferDataPack<T extends CallbackData> = T extends CallbackData<
87+
infer SchemaType,
88+
infer SchemaTypeInput
89+
>
90+
? SchemaTypeInput
91+
: never;
92+
export type InferDataUnpack<T extends CallbackData> = T extends CallbackData<
93+
infer SchemaType,
94+
infer SchemaTypeInput
95+
>
96+
? SchemaType
97+
: never;

tests/compat-serializer.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2+
import { CallbackData } from "../src/index.ts";
23
import { CompactSerializer } from "../src/serialization/index.ts";
34
import type { Schema } from "../src/types.ts";
45
import { generateMixedUUIDs, getBytesLength } from "./utils.ts";
@@ -256,4 +257,48 @@ describe("CompactSerializer", () => {
256257

257258
expect(serialized).toBe("0");
258259
});
260+
261+
test("nested data serialization/deserialization (required)", () => {
262+
const child = new CallbackData("child").string("text");
263+
const schema: Schema = {
264+
required: [{ key: "child", type: "data", data: child }],
265+
optional: [],
266+
};
267+
268+
const obj = { child: { text: "Hello;World" } };
269+
const serialized = CompactSerializer.serialize(schema, obj);
270+
const deserialized = CompactSerializer.deserialize(schema, serialized);
271+
272+
expect(typeof serialized).toBe("string");
273+
expect(deserialized).toEqual(obj);
274+
});
275+
276+
test("nested data serialization/deserialization (optional omitted)", () => {
277+
const child = new CallbackData("child").string("text");
278+
const schema: Schema = {
279+
required: [],
280+
optional: [{ key: "child", type: "data", data: child }],
281+
};
282+
283+
const serialized = CompactSerializer.serialize(schema, {});
284+
const deserialized = CompactSerializer.deserialize(schema, serialized);
285+
286+
expect(serialized).toBe("0");
287+
expect(deserialized).toEqual({});
288+
});
289+
290+
test("nested data present in optional field", () => {
291+
const child = new CallbackData("child").string("text");
292+
const schema: Schema = {
293+
required: [],
294+
optional: [{ key: "child", type: "data", data: child }],
295+
};
296+
297+
const obj = { child: { text: "Value" } };
298+
const serialized = CompactSerializer.serialize(schema, obj);
299+
const deserialized = CompactSerializer.deserialize(schema, serialized);
300+
301+
expect(serialized.startsWith("1;")).toBeTrue();
302+
expect(deserialized).toEqual(obj);
303+
});
259304
});

tests/index.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,50 @@ describe("Serialization/Deserialization", () => {
357357
});
358358
});
359359

360+
describe("Nested data", () => {
361+
test("should pack/unpack required nested data", () => {
362+
const child = new CallbackData("child").string("text");
363+
const parent = new CallbackData("parent").data("child", child);
364+
const input = { child: { text: "Hello;World" } };
365+
const packed = parent.pack(input);
366+
const unpacked = parent.unpack(packed);
367+
expect(unpacked).toEqual(input);
368+
});
369+
370+
test("should omit optional nested data when not provided", () => {
371+
const child = new CallbackData("child").string("text");
372+
const parent = new CallbackData("parent").data("child", child, {
373+
optional: true,
374+
});
375+
const packed = parent.pack({});
376+
const unpacked = parent.unpack(packed);
377+
expect(unpacked).toEqual({});
378+
});
379+
380+
test("should pack/unpack optional nested data when provided", () => {
381+
const child = new CallbackData("child").string("text");
382+
const parent = new CallbackData("parent").data("child", child, {
383+
optional: true,
384+
});
385+
const input = { child: { text: "Value" } };
386+
const packed = parent.pack(input);
387+
const unpacked = parent.unpack(packed);
388+
expect(unpacked).toEqual(input);
389+
});
390+
391+
test("should handle multi-level nested data", () => {
392+
const grand = new CallbackData("grand").number("id");
393+
const child = new CallbackData("child").string("text").data("inner", grand);
394+
const parent = new CallbackData("parent")
395+
.boolean("flag")
396+
.data("child", child);
397+
const input = { flag: true, child: { text: "t", inner: { id: 123 } } };
398+
const packed = parent.pack(input);
399+
const unpacked = parent.unpack(packed);
400+
expect(unpacked).toEqual(input);
401+
});
402+
});
403+
360404
describe("CallbackData.extend", () => {
361405
test("should merge schemas and types from two CallbackData instances", () => {
362406
const schemaA = new CallbackData("A").string("foo").number("bar");

tests/types/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,17 @@ expectTypeOf(defaultUnpackedDataWithDefaults).toEqualTypeOf<{
151151
test: "GramIO" | "Telegram" | "Bun" | "Elysia" | "Kravets" | "Biome";
152152
}>();
153153
}
154+
155+
{
156+
const cd = new CallbackData("test").string("test");
157+
158+
const cd2 = new CallbackData("test2").string("test").data("test2", cd);
159+
160+
cd2.pack({
161+
test: "test",
162+
test2: {
163+
test: "test",
164+
},
165+
});
166+
cd2.unpack("test");
167+
}

0 commit comments

Comments
 (0)