typespec-rust-emitter 0.10.6 → 0.11.0
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/CHANGELOG.md +47 -0
- package/dist/src/emitter.d.ts +2 -0
- package/dist/src/emitter.js +111 -7
- package/dist/src/emitter.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/test/hello.test.js +108 -1
- package/dist/test/hello.test.js.map +1 -1
- package/example/main.tsp +68 -5
- package/example/output-rust/Cargo.lock +2 -0
- package/example/output-rust/Cargo.toml +2 -0
- package/example/output-rust/src/generated/server.rs +82 -525
- package/example/output-rust/src/generated/types.rs +5 -352
- package/example/output-rust/src/main.rs +148 -2
- package/example/package-lock.json +3 -1
- package/package.json +1 -1
- package/src/emitter.ts +133 -8
- package/src/index.ts +2 -0
- package/src/lib.tsp +2 -0
- package/test/hello.test.ts +119 -1
- package/example/lib/learning/models.tsp +0 -234
- package/example/lib/learning/operations.tsp +0 -364
- package/example/output-rust/src/mod.rs +0 -1
package/src/emitter.ts
CHANGED
|
@@ -39,6 +39,9 @@ interface RustAttrInfo {
|
|
|
39
39
|
const rustDeriveKey = Symbol("rustDerive");
|
|
40
40
|
const rustAttrKey = Symbol("rustAttr");
|
|
41
41
|
const rustImplKey = Symbol("rustImpl");
|
|
42
|
+
const rustSelfReceiverKey = Symbol("rustSelfReceiver");
|
|
43
|
+
|
|
44
|
+
type SelfReceiver = "&self" | "&mut self" | "self";
|
|
42
45
|
|
|
43
46
|
interface RustImplInfo {
|
|
44
47
|
impl: string;
|
|
@@ -168,6 +171,42 @@ export function $rustImpl(
|
|
|
168
171
|
}
|
|
169
172
|
}
|
|
170
173
|
|
|
174
|
+
export function $rustMut(context: DecoratorContext, target: Type) {
|
|
175
|
+
if (target.kind !== "Operation") {
|
|
176
|
+
context.program.reportDiagnostic({
|
|
177
|
+
code: "rust-mut-invalid-target",
|
|
178
|
+
message: `@rustMut can only be applied to operations`,
|
|
179
|
+
severity: "error",
|
|
180
|
+
target: context.decoratorTarget,
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const ns = target.namespace ? getNamespaceFullName(target.namespace) : "";
|
|
186
|
+
if (!ns.startsWith("TypeSpec")) {
|
|
187
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
188
|
+
(target as any)[rustSelfReceiverKey] = "&mut self";
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function $rustOwn(context: DecoratorContext, target: Type) {
|
|
193
|
+
if (target.kind !== "Operation") {
|
|
194
|
+
context.program.reportDiagnostic({
|
|
195
|
+
code: "rust-own-invalid-target",
|
|
196
|
+
message: `@rustOwn can only be applied to operations`,
|
|
197
|
+
severity: "error",
|
|
198
|
+
target: context.decoratorTarget,
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const ns = target.namespace ? getNamespaceFullName(target.namespace) : "";
|
|
204
|
+
if (!ns.startsWith("TypeSpec")) {
|
|
205
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
206
|
+
(target as any)[rustSelfReceiverKey] = "self";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
171
210
|
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
|
|
172
211
|
|
|
173
212
|
interface OperationInfo {
|
|
@@ -298,6 +337,14 @@ function hasAuthDecorator(operation: Operation): boolean {
|
|
|
298
337
|
return false;
|
|
299
338
|
}
|
|
300
339
|
|
|
340
|
+
function getSelfReceiver(operation: Operation): SelfReceiver {
|
|
341
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
342
|
+
const receiver = (operation as any)[rustSelfReceiverKey] as
|
|
343
|
+
| SelfReceiver
|
|
344
|
+
| undefined;
|
|
345
|
+
return receiver ?? "&self";
|
|
346
|
+
}
|
|
347
|
+
|
|
301
348
|
function getOperationParameters(
|
|
302
349
|
program: Program,
|
|
303
350
|
operation: Operation,
|
|
@@ -393,7 +440,28 @@ function getOperationResponses(
|
|
|
393
440
|
anonymousEnums: Map<string, AnonymousStringLiteralUnion>,
|
|
394
441
|
): ResponseInfo[] {
|
|
395
442
|
const responses: ResponseInfo[] = [];
|
|
396
|
-
|
|
443
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
444
|
+
const returnType = operation.returnType as any;
|
|
445
|
+
|
|
446
|
+
// Check for Array return type first (use runtime check since TS doesn't narrow Array)
|
|
447
|
+
if (
|
|
448
|
+
returnType.kind !== "Union" &&
|
|
449
|
+
returnType.kind !== "Model" &&
|
|
450
|
+
returnType.valueType
|
|
451
|
+
) {
|
|
452
|
+
// This is an Array type
|
|
453
|
+
const { type: rustType } = getRustTypeForProperty(
|
|
454
|
+
returnType.valueType,
|
|
455
|
+
program,
|
|
456
|
+
anonymousEnums,
|
|
457
|
+
);
|
|
458
|
+
responses.push({
|
|
459
|
+
statusCode: 200,
|
|
460
|
+
bodyType: rustType,
|
|
461
|
+
bodyDescription: "",
|
|
462
|
+
});
|
|
463
|
+
return responses;
|
|
464
|
+
}
|
|
397
465
|
|
|
398
466
|
if (returnType.kind === "Union") {
|
|
399
467
|
const union = returnType as Union;
|
|
@@ -418,6 +486,7 @@ function getOperationResponses(
|
|
|
418
486
|
});
|
|
419
487
|
return responses;
|
|
420
488
|
}
|
|
489
|
+
let foundStatusCode = false;
|
|
421
490
|
for (const [propName, prop] of model.properties) {
|
|
422
491
|
if (propName === "body") {
|
|
423
492
|
const { type: rustType } = getRustTypeForProperty(
|
|
@@ -430,8 +499,27 @@ function getOperationResponses(
|
|
|
430
499
|
bodyType: rustType,
|
|
431
500
|
bodyDescription: getDoc(program, prop),
|
|
432
501
|
});
|
|
502
|
+
} else if (propName === "statusCode") {
|
|
503
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
504
|
+
const typeAny = prop.type as any;
|
|
505
|
+
if (typeAny.value !== undefined) {
|
|
506
|
+
const statusCode = typeAny.value as number;
|
|
507
|
+
responses.push({
|
|
508
|
+
statusCode,
|
|
509
|
+
bodyType: undefined,
|
|
510
|
+
bodyDescription: undefined,
|
|
511
|
+
});
|
|
512
|
+
foundStatusCode = true;
|
|
513
|
+
}
|
|
433
514
|
}
|
|
434
515
|
}
|
|
516
|
+
if (!foundStatusCode && responses.length === 0) {
|
|
517
|
+
responses.push({
|
|
518
|
+
statusCode: 200,
|
|
519
|
+
bodyType: undefined,
|
|
520
|
+
bodyDescription: undefined,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
435
523
|
}
|
|
436
524
|
|
|
437
525
|
return responses;
|
|
@@ -679,25 +767,26 @@ pub trait Server: Send + Sync {
|
|
|
679
767
|
}
|
|
680
768
|
|
|
681
769
|
const paramsStr = paramParts.join(", ");
|
|
770
|
+
const selfReceiver = getSelfReceiver(op);
|
|
682
771
|
|
|
683
772
|
if (isProtected) {
|
|
684
773
|
if (paramsStr) {
|
|
685
774
|
parts.push(
|
|
686
|
-
` async fn ${fnName}(
|
|
775
|
+
` async fn ${fnName}(${selfReceiver}, claims: Self::Claims, ${paramsStr}) -> Result<${responseName}>;`,
|
|
687
776
|
);
|
|
688
777
|
} else {
|
|
689
778
|
parts.push(
|
|
690
|
-
` async fn ${fnName}(
|
|
779
|
+
` async fn ${fnName}(${selfReceiver}, claims: Self::Claims) -> Result<${responseName}>;`,
|
|
691
780
|
);
|
|
692
781
|
}
|
|
693
782
|
} else {
|
|
694
783
|
if (paramsStr) {
|
|
695
784
|
parts.push(
|
|
696
|
-
` async fn ${fnName}(
|
|
785
|
+
` async fn ${fnName}(${selfReceiver}, ${paramsStr}) -> Result<${responseName}>;`,
|
|
697
786
|
);
|
|
698
787
|
} else {
|
|
699
788
|
parts.push(
|
|
700
|
-
` async fn ${fnName}(
|
|
789
|
+
` async fn ${fnName}(${selfReceiver}) -> Result<${responseName}>;`,
|
|
701
790
|
);
|
|
702
791
|
}
|
|
703
792
|
}
|
|
@@ -806,6 +895,7 @@ function generateRouter(
|
|
|
806
895
|
const handlerFnName = toRustIdent(`${nsName}_${opInfo.name}`);
|
|
807
896
|
const traitFnName = handlerFnName;
|
|
808
897
|
const isProtected = hasAuthDecorator(op);
|
|
898
|
+
const selfReceiver = getSelfReceiver(op);
|
|
809
899
|
|
|
810
900
|
const pathParams = opInfo.parameters.filter((p) => p.location === "path");
|
|
811
901
|
const hasPathParams = pathParams.length > 0;
|
|
@@ -822,6 +912,8 @@ function generateRouter(
|
|
|
822
912
|
const serverArgs: string[] = [];
|
|
823
913
|
|
|
824
914
|
// State is always first (added in handler template)
|
|
915
|
+
const serviceBinding =
|
|
916
|
+
selfReceiver === "&mut self" ? "mut service" : "service";
|
|
825
917
|
|
|
826
918
|
// Extension (claims) comes after State
|
|
827
919
|
if (isProtected) {
|
|
@@ -850,7 +942,10 @@ function generateRouter(
|
|
|
850
942
|
if (hasQueryParams && queryParams.length > 0) {
|
|
851
943
|
const queryTypeName = `${toPascalCase(handlerFnName)}Query`;
|
|
852
944
|
const queryFields = queryParams
|
|
853
|
-
.map(
|
|
945
|
+
.map(
|
|
946
|
+
(p) =>
|
|
947
|
+
` #[serde(rename = "${p.name}")]\n pub ${p.rustName}: ${p.rustType}`,
|
|
948
|
+
)
|
|
854
949
|
.join(",\n");
|
|
855
950
|
queryTypeStructs.push(
|
|
856
951
|
`#[derive(Debug, Clone, serde::Deserialize)]\npub struct ${queryTypeName} {\n${queryFields}\n}`,
|
|
@@ -877,12 +972,37 @@ function generateRouter(
|
|
|
877
972
|
const serverCall = `service.${traitFnName}(${serverArgsStr}).await`;
|
|
878
973
|
|
|
879
974
|
// All handlers use <S> generics, Claims is now an associated type
|
|
880
|
-
|
|
975
|
+
// For &mut self, we need Clone because service is extracted multiple times
|
|
976
|
+
// For self, we can't use Clone (would need Arc/Mutex or different pattern)
|
|
977
|
+
const needsClone = selfReceiver !== "self" ? "+ Clone" : "";
|
|
978
|
+
const handlerCode =
|
|
979
|
+
selfReceiver === "self"
|
|
980
|
+
? `// NOTE: ${handlerFnName} takes self and cannot be used with the router pattern.
|
|
981
|
+
// It consumes the service, so you need to implement your own handler pattern.
|
|
982
|
+
pub async fn ${handlerFnName}_handler<S>(
|
|
881
983
|
axum::extract::State(service): axum::extract::State<S>,
|
|
882
984
|
${extractorLines.join("\n")}
|
|
883
985
|
) -> impl axum::response::IntoResponse
|
|
884
986
|
where
|
|
885
|
-
S: Server +
|
|
987
|
+
S: Server + Send + Sync + 'static,
|
|
988
|
+
S::Claims: Send + Sync + Clone + 'static,
|
|
989
|
+
{
|
|
990
|
+
let result = service.${traitFnName}(${serverArgsStr}).await;
|
|
991
|
+
match result {
|
|
992
|
+
Ok(response) => response.into_response(),
|
|
993
|
+
Err(e) => (
|
|
994
|
+
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
995
|
+
format!("Internal error: {e}"),
|
|
996
|
+
)
|
|
997
|
+
.into_response(),
|
|
998
|
+
}
|
|
999
|
+
}`
|
|
1000
|
+
: `pub async fn ${handlerFnName}_handler<S>(
|
|
1001
|
+
axum::extract::State(${serviceBinding}): axum::extract::State<S>,
|
|
1002
|
+
${extractorLines.join("\n")}
|
|
1003
|
+
) -> impl axum::response::IntoResponse
|
|
1004
|
+
where
|
|
1005
|
+
S: Server${needsClone} + Send + Sync + 'static,
|
|
886
1006
|
S::Claims: Send + Sync + Clone + 'static,
|
|
887
1007
|
{
|
|
888
1008
|
let result = ${serverCall};
|
|
@@ -898,6 +1018,11 @@ where
|
|
|
898
1018
|
|
|
899
1019
|
handlers.push(handlerCode);
|
|
900
1020
|
|
|
1021
|
+
// Don't add routes for self methods (they consume the service)
|
|
1022
|
+
if (selfReceiver === "self") {
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
901
1026
|
const routePath = `"${opInfo.path}"`;
|
|
902
1027
|
let routeStmt = "";
|
|
903
1028
|
if (isProtected) {
|
package/src/index.ts
CHANGED
package/src/lib.tsp
CHANGED
|
@@ -7,3 +7,5 @@ extern dec rustDerives(target: Model | Enum, ...derives: valueof string[]);
|
|
|
7
7
|
extern dec rustAttr(target: Model | Enum | ModelProperty, attr: valueof string);
|
|
8
8
|
extern dec rustAttrs(target: Model | Enum | ModelProperty, ...attrs: valueof string[]);
|
|
9
9
|
extern dec rustImpl(target: Model, impl: valueof string);
|
|
10
|
+
extern dec rustMut(target: Operation);
|
|
11
|
+
extern dec rustOwn(target: Operation);
|
package/test/hello.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { strictEqual } from "node:assert";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import { emit } from "./test-host.js";
|
|
3
|
+
import { emit, emitWithDiagnostics } from "./test-host.js";
|
|
4
4
|
|
|
5
5
|
describe("Rust emitter", () => {
|
|
6
6
|
it("emits basic model", async () => {
|
|
@@ -443,4 +443,122 @@ describe("Rust emitter", () => {
|
|
|
443
443
|
true,
|
|
444
444
|
);
|
|
445
445
|
});
|
|
446
|
+
|
|
447
|
+
it("uses &self by default in trait methods", async () => {
|
|
448
|
+
const results = await emit(`
|
|
449
|
+
import "@typespec/http";
|
|
450
|
+
import "typespec-rust-emitter";
|
|
451
|
+
using TypeSpec.Http;
|
|
452
|
+
|
|
453
|
+
@route("/test")
|
|
454
|
+
namespace Test {
|
|
455
|
+
@get
|
|
456
|
+
op getItem(): { @statusCode status: 200; @body body: string };
|
|
457
|
+
}
|
|
458
|
+
`);
|
|
459
|
+
const server = results["server.rs"];
|
|
460
|
+
strictEqual(server.includes("async fn test_get_item(&self)"), true);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("uses &mut self with @rustMut decorator", async () => {
|
|
464
|
+
const results = await emit(`
|
|
465
|
+
import "@typespec/http";
|
|
466
|
+
import "typespec-rust-emitter";
|
|
467
|
+
using TypeSpec.Http;
|
|
468
|
+
|
|
469
|
+
@route("/test")
|
|
470
|
+
namespace Test {
|
|
471
|
+
@rustMut
|
|
472
|
+
@post
|
|
473
|
+
op createItem(@body name: string): { @statusCode status: 200; @body body: string };
|
|
474
|
+
}
|
|
475
|
+
`);
|
|
476
|
+
const server = results["server.rs"];
|
|
477
|
+
strictEqual(server.includes("test_create_item(&mut self,"), true);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("uses self with @rustOwn decorator", async () => {
|
|
481
|
+
const results = await emit(`
|
|
482
|
+
import "@typespec/http";
|
|
483
|
+
import "typespec-rust-emitter";
|
|
484
|
+
using TypeSpec.Http;
|
|
485
|
+
|
|
486
|
+
@route("/test")
|
|
487
|
+
namespace Test {
|
|
488
|
+
@rustOwn
|
|
489
|
+
@delete
|
|
490
|
+
op deleteItem(): { @statusCode status: 200; @body body: string };
|
|
491
|
+
}
|
|
492
|
+
`);
|
|
493
|
+
const server = results["server.rs"];
|
|
494
|
+
strictEqual(server.includes("test_delete_item(self)"), true);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("@rustMut works with protected routes", async () => {
|
|
498
|
+
const results = await emit(`
|
|
499
|
+
import "@typespec/http";
|
|
500
|
+
import "typespec-rust-emitter";
|
|
501
|
+
using TypeSpec.Http;
|
|
502
|
+
|
|
503
|
+
@route("/test")
|
|
504
|
+
namespace Test {
|
|
505
|
+
@rustMut
|
|
506
|
+
@post
|
|
507
|
+
op createItem(@body name: string, @header Authorization: string): { @statusCode status: 200; @body body: string };
|
|
508
|
+
}
|
|
509
|
+
`);
|
|
510
|
+
const server = results["server.rs"];
|
|
511
|
+
strictEqual(server.includes("test_create_item(&mut self,"), true);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("@rustOwn works with protected routes", async () => {
|
|
515
|
+
const results = await emit(`
|
|
516
|
+
import "@typespec/http";
|
|
517
|
+
import "typespec-rust-emitter";
|
|
518
|
+
using TypeSpec.Http;
|
|
519
|
+
|
|
520
|
+
@route("/test")
|
|
521
|
+
namespace Test {
|
|
522
|
+
@rustOwn
|
|
523
|
+
@delete
|
|
524
|
+
op deleteItem(@query id: string): { @statusCode status: 200; @body body: string };
|
|
525
|
+
}
|
|
526
|
+
`);
|
|
527
|
+
const server = results["server.rs"];
|
|
528
|
+
strictEqual(server.includes("test_delete_item(self,"), true);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("reports error when @rustMut is applied to non-operation", async () => {
|
|
532
|
+
const [, diagnostics] = await emitWithDiagnostics(`
|
|
533
|
+
import "@typespec/http";
|
|
534
|
+
import "typespec-rust-emitter";
|
|
535
|
+
using TypeSpec.Http;
|
|
536
|
+
|
|
537
|
+
@rustMut
|
|
538
|
+
model Test {
|
|
539
|
+
name: string;
|
|
540
|
+
}
|
|
541
|
+
`);
|
|
542
|
+
const hasError = diagnostics.some(
|
|
543
|
+
(d) => d.code === "decorator-wrong-target",
|
|
544
|
+
);
|
|
545
|
+
strictEqual(hasError, true);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("reports error when @rustOwn is applied to non-operation", async () => {
|
|
549
|
+
const [, diagnostics] = await emitWithDiagnostics(`
|
|
550
|
+
import "@typespec/http";
|
|
551
|
+
import "typespec-rust-emitter";
|
|
552
|
+
using TypeSpec.Http;
|
|
553
|
+
|
|
554
|
+
@rustOwn
|
|
555
|
+
model Test {
|
|
556
|
+
name: string;
|
|
557
|
+
}
|
|
558
|
+
`);
|
|
559
|
+
const hasError = diagnostics.some(
|
|
560
|
+
(d) => d.code === "decorator-wrong-target",
|
|
561
|
+
);
|
|
562
|
+
strictEqual(hasError, true);
|
|
563
|
+
});
|
|
446
564
|
});
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
import "typespec-rust-emitter";
|
|
2
|
-
import "@typespec/events";
|
|
3
|
-
|
|
4
|
-
using TypeSpec.Events;
|
|
5
|
-
|
|
6
|
-
namespace Mtlkms.Learning.Models {
|
|
7
|
-
// ── COMMON ─────────────────────────────────────────────────────────────────
|
|
8
|
-
|
|
9
|
-
@error
|
|
10
|
-
model ApiError {
|
|
11
|
-
@doc("Machine-readable error code.")
|
|
12
|
-
code: string;
|
|
13
|
-
|
|
14
|
-
@doc("Human-readable message.")
|
|
15
|
-
message: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
model NotFoundError extends ApiError {
|
|
19
|
-
code: "NOT_FOUND";
|
|
20
|
-
}
|
|
21
|
-
model ValidationError extends ApiError {
|
|
22
|
-
code: "VALIDATION_ERROR";
|
|
23
|
-
}
|
|
24
|
-
model ConflictError extends ApiError {
|
|
25
|
-
code: "CONFLICT";
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// ── SCALARS & ENUMS ───────────────────────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
@format("uuid")
|
|
31
|
-
scalar Uuid extends string;
|
|
32
|
-
|
|
33
|
-
@pattern("^#[0-9A-Fa-f]{6}$")
|
|
34
|
-
scalar HexColor extends string;
|
|
35
|
-
|
|
36
|
-
@rustDerive("sqlx::Type")
|
|
37
|
-
@rustAttr("sqlx(type_name = \"study_status\")")
|
|
38
|
-
enum StudyStatus {
|
|
39
|
-
Starting: "Starting",
|
|
40
|
-
Paused: "Paused",
|
|
41
|
-
Stopped: "Stopped",
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
enum ChartGranularity {
|
|
45
|
-
Day: "day",
|
|
46
|
-
Week: "week",
|
|
47
|
-
Month: "month",
|
|
48
|
-
Year: "year",
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ── AGGREGATION MIXIN ──────────────────────────────────────────────────────
|
|
52
|
-
|
|
53
|
-
@doc("Duration statistics calculated in real-time from timelogs. Unit: seconds.")
|
|
54
|
-
model DurationStats {
|
|
55
|
-
durationTotal: int64;
|
|
56
|
-
durationDay: int64;
|
|
57
|
-
durationWeek: int64;
|
|
58
|
-
durationMonth: int64;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
model CalendarHeatmapItem {
|
|
62
|
-
date: string;
|
|
63
|
-
duration: int64;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ── MODELS: GROUP ─────────────────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
model Group {
|
|
69
|
-
id: int64;
|
|
70
|
-
accountId: Uuid;
|
|
71
|
-
createdAt: utcDateTime;
|
|
72
|
-
deletedAt: utcDateTime | null;
|
|
73
|
-
|
|
74
|
-
@maxLength(255)
|
|
75
|
-
name: string;
|
|
76
|
-
|
|
77
|
-
@maxLength(44)
|
|
78
|
-
icon: string;
|
|
79
|
-
|
|
80
|
-
colorText: HexColor;
|
|
81
|
-
colorBg: HexColor;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
@rustDerive("sqlx::FromRow")
|
|
85
|
-
model GroupStatistics {
|
|
86
|
-
...Group;
|
|
87
|
-
subjectCount: int64;
|
|
88
|
-
status: StudyStatus;
|
|
89
|
-
...DurationStats;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
model CreateGroupBody {
|
|
93
|
-
@maxLength(255)
|
|
94
|
-
name: string;
|
|
95
|
-
|
|
96
|
-
@maxLength(44)
|
|
97
|
-
icon: string;
|
|
98
|
-
|
|
99
|
-
colorText: HexColor;
|
|
100
|
-
colorBg: HexColor;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
model UpdateGroupBody {
|
|
104
|
-
@maxLength(255)
|
|
105
|
-
name?: string;
|
|
106
|
-
|
|
107
|
-
@maxLength(44)
|
|
108
|
-
icon?: string;
|
|
109
|
-
|
|
110
|
-
colorText?: HexColor;
|
|
111
|
-
colorBg?: HexColor;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ── MODELS: SUBJECT ───────────────────────────────────────────────────────
|
|
115
|
-
|
|
116
|
-
model Subject {
|
|
117
|
-
id: int64;
|
|
118
|
-
accountId: Uuid;
|
|
119
|
-
groupId: int64;
|
|
120
|
-
createdAt: utcDateTime;
|
|
121
|
-
deletedAt: utcDateTime | null;
|
|
122
|
-
|
|
123
|
-
@maxLength(255)
|
|
124
|
-
name: string;
|
|
125
|
-
|
|
126
|
-
@maxLength(44)
|
|
127
|
-
icon: string;
|
|
128
|
-
|
|
129
|
-
colorText: HexColor;
|
|
130
|
-
colorBg: HexColor;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
model SubjectStatistics {
|
|
134
|
-
...Subject;
|
|
135
|
-
status: StudyStatus;
|
|
136
|
-
...DurationStats;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
model CreateSubjectBody {
|
|
140
|
-
@maxLength(255)
|
|
141
|
-
name: string;
|
|
142
|
-
|
|
143
|
-
@maxLength(44)
|
|
144
|
-
icon: string;
|
|
145
|
-
|
|
146
|
-
colorText: HexColor;
|
|
147
|
-
colorBg: HexColor;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
model UpdateSubjectBody {
|
|
151
|
-
@maxLength(255)
|
|
152
|
-
name?: string;
|
|
153
|
-
|
|
154
|
-
@maxLength(44)
|
|
155
|
-
icon?: string;
|
|
156
|
-
|
|
157
|
-
colorText?: HexColor;
|
|
158
|
-
colorBg?: HexColor;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ── MODELS: SESSION ───────────────────────────────────────────────────────
|
|
162
|
-
|
|
163
|
-
model Log {
|
|
164
|
-
id: int64;
|
|
165
|
-
accountId: Uuid;
|
|
166
|
-
subjectId: int64;
|
|
167
|
-
startedAt: utcDateTime;
|
|
168
|
-
stoppedAt: utcDateTime | null;
|
|
169
|
-
logContent: string | null;
|
|
170
|
-
status: StudyStatus;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
model Timelog {
|
|
174
|
-
id: int64;
|
|
175
|
-
accountId: Uuid;
|
|
176
|
-
logId: int64;
|
|
177
|
-
subjectId: int64;
|
|
178
|
-
startedAt: utcDateTime;
|
|
179
|
-
stoppedAt: utcDateTime | null;
|
|
180
|
-
durationInSeconds: int64;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
model LogWithTimelogs {
|
|
184
|
-
...Log;
|
|
185
|
-
timelogs: Timelog[];
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
@doc("Event payload for learning data changes.")
|
|
189
|
-
model SessionEvent {
|
|
190
|
-
eventType: "session" | "session_pause" | "session_resume" | "session_stop";
|
|
191
|
-
session: LogWithTimelogs;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
@doc("Event payload for group CRUD.")
|
|
195
|
-
model GroupEvent {
|
|
196
|
-
eventType: "group_created" | "group_updated" | "group_deleted";
|
|
197
|
-
group: Group;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
@doc("Event payload for subject CRUD.")
|
|
201
|
-
model SubjectEvent {
|
|
202
|
-
eventType:
|
|
203
|
-
| "subject_created"
|
|
204
|
-
| "subject_updated"
|
|
205
|
-
| "subject_deleted"
|
|
206
|
-
| "subject_moved";
|
|
207
|
-
subject: Subject;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
@doc("Union of all learning events for SSE.")
|
|
211
|
-
@events
|
|
212
|
-
union LearningEvents {
|
|
213
|
-
sessionEvent: SessionEvent,
|
|
214
|
-
groupEvent: GroupEvent,
|
|
215
|
-
subjectEvent: SubjectEvent,
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
@doc("Response after start/resume — returns the log and the newly opened timelog segment.")
|
|
219
|
-
model SessionOpenedResponse {
|
|
220
|
-
log: Log;
|
|
221
|
-
timelog: Timelog;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
@doc("Response after pause/stop — returns the log and the newly closed timelog segment.")
|
|
225
|
-
model SessionClosedResponse {
|
|
226
|
-
log: Log;
|
|
227
|
-
timelog: Timelog;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
@doc("Optional payload for start or stop operations — contains a note for the session.")
|
|
231
|
-
model SessionNoteBody {
|
|
232
|
-
logContent?: string;
|
|
233
|
-
}
|
|
234
|
-
}
|