@beignet/cli 0.0.22 → 0.0.24
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 +26 -0
- package/README.md +35 -5
- package/dist/choices.d.ts +8 -0
- package/dist/choices.d.ts.map +1 -1
- package/dist/choices.js +4 -0
- package/dist/choices.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +184 -0
- package/dist/inspect.js.map +1 -1
- package/dist/lib.d.ts +2 -2
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +1 -1
- package/dist/lib.js.map +1 -1
- package/dist/make.d.ts +4 -3
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +349 -45
- package/dist/make.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +6 -2
- package/dist/mcp.js.map +1 -1
- package/dist/templates/agents.d.ts.map +1 -1
- package/dist/templates/agents.js +9 -5
- package/dist/templates/agents.js.map +1 -1
- package/dist/templates/base.d.ts.map +1 -1
- package/dist/templates/base.js +4 -2
- package/dist/templates/base.js.map +1 -1
- package/dist/templates/db/mysql.js +2 -2
- package/dist/templates/db/postgres.js +2 -2
- package/dist/templates/db/sqlite.js +2 -2
- package/dist/templates/index.d.ts.map +1 -1
- package/dist/templates/index.js +1 -0
- package/dist/templates/index.js.map +1 -1
- package/dist/templates/server.d.ts +1 -0
- package/dist/templates/server.d.ts.map +1 -1
- package/dist/templates/server.js +90 -20
- package/dist/templates/server.js.map +1 -1
- package/dist/templates/shell.d.ts.map +1 -1
- package/dist/templates/shell.js +60 -29
- package/dist/templates/shell.js.map +1 -1
- package/dist/templates/todos.js +5 -5
- package/package.json +2 -2
- package/src/choices.ts +10 -0
- package/src/index.ts +11 -0
- package/src/inspect.ts +319 -0
- package/src/lib.ts +2 -1
- package/src/make.ts +449 -53
- package/src/mcp.ts +7 -1
- package/src/templates/agents.ts +9 -5
- package/src/templates/base.ts +4 -2
- package/src/templates/db/mysql.ts +2 -2
- package/src/templates/db/postgres.ts +2 -2
- package/src/templates/db/sqlite.ts +2 -2
- package/src/templates/index.ts +1 -0
- package/src/templates/server.ts +90 -20
- package/src/templates/shell.ts +61 -29
- package/src/templates/todos.ts +5 -5
package/src/make.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import type { MakeFeatureAddon } from "./choices.js";
|
|
4
|
+
import type { MakeFeatureAddon, MakeFeatureRecipe } from "./choices.js";
|
|
5
5
|
import {
|
|
6
6
|
type BeignetConfig,
|
|
7
7
|
clientIndexPath,
|
|
@@ -33,8 +33,11 @@ type MakeResourceOptions = {
|
|
|
33
33
|
softDelete?: boolean;
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
export type { MakeFeatureAddon } from "./choices.js";
|
|
37
|
-
export {
|
|
36
|
+
export type { MakeFeatureAddon, MakeFeatureRecipe } from "./choices.js";
|
|
37
|
+
export {
|
|
38
|
+
makeFeatureAddonChoices,
|
|
39
|
+
makeFeatureRecipeChoices,
|
|
40
|
+
} from "./choices.js";
|
|
38
41
|
|
|
39
42
|
type NormalizedMakeFeatureAddon =
|
|
40
43
|
| "policy"
|
|
@@ -51,6 +54,7 @@ type NormalizedMakeFeatureAddon =
|
|
|
51
54
|
|
|
52
55
|
type MakeFeatureOptions = MakeResourceOptions & {
|
|
53
56
|
with?: readonly MakeFeatureAddon[];
|
|
57
|
+
recipe?: MakeFeatureRecipe;
|
|
54
58
|
};
|
|
55
59
|
|
|
56
60
|
type MakeContractOptions = {
|
|
@@ -490,17 +494,26 @@ export async function makeResource(
|
|
|
490
494
|
export async function makeFeature(
|
|
491
495
|
options: MakeFeatureOptions,
|
|
492
496
|
): Promise<MakeFeatureResult> {
|
|
493
|
-
const addons = normalizeMakeFeatureAddons(
|
|
497
|
+
const addons = normalizeMakeFeatureAddons([
|
|
498
|
+
...makeFeatureRecipeAddons(options.recipe),
|
|
499
|
+
...(options.with ?? []),
|
|
500
|
+
...(options.events ? (["events"] as const) : []),
|
|
501
|
+
]);
|
|
502
|
+
const featureOptions = {
|
|
503
|
+
...options,
|
|
504
|
+
events: addons.includes("event"),
|
|
505
|
+
};
|
|
506
|
+
|
|
494
507
|
if (addons.length === 0) {
|
|
495
|
-
return makeFeatureResource(
|
|
508
|
+
return makeFeatureResource(featureOptions);
|
|
496
509
|
}
|
|
497
510
|
|
|
498
511
|
if (options.dryRun) {
|
|
499
|
-
return makeFeatureSlice(
|
|
512
|
+
return makeFeatureSlice(featureOptions, addons);
|
|
500
513
|
}
|
|
501
514
|
|
|
502
|
-
await makeFeatureSlice({ ...
|
|
503
|
-
return makeFeatureSlice(
|
|
515
|
+
await makeFeatureSlice({ ...featureOptions, dryRun: true }, addons);
|
|
516
|
+
return makeFeatureSlice(featureOptions, addons);
|
|
504
517
|
}
|
|
505
518
|
|
|
506
519
|
export async function makePayments(
|
|
@@ -718,6 +731,15 @@ function resourceGenerationOptions(
|
|
|
718
731
|
options: MakeResourceOptions,
|
|
719
732
|
mode: ResourceGenerationMode,
|
|
720
733
|
): ResourceGenerationOptions {
|
|
734
|
+
if (mode === "feature") {
|
|
735
|
+
return {
|
|
736
|
+
auth: false,
|
|
737
|
+
tenant: false,
|
|
738
|
+
events: Boolean(options.events),
|
|
739
|
+
softDelete: false,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
721
743
|
if (mode !== "resource") {
|
|
722
744
|
return { auth: false, tenant: false, events: false, softDelete: false };
|
|
723
745
|
}
|
|
@@ -831,6 +853,28 @@ function normalizeMakeFeatureAddons(
|
|
|
831
853
|
return featureAddonOrder.filter((addon) => normalized.has(addon));
|
|
832
854
|
}
|
|
833
855
|
|
|
856
|
+
function makeFeatureRecipeAddons(
|
|
857
|
+
recipe: MakeFeatureRecipe | undefined,
|
|
858
|
+
): MakeFeatureAddon[] {
|
|
859
|
+
if (recipe === "full-slice") {
|
|
860
|
+
return [
|
|
861
|
+
"policy",
|
|
862
|
+
"factories",
|
|
863
|
+
"seeds",
|
|
864
|
+
"tasks",
|
|
865
|
+
"events",
|
|
866
|
+
"listeners",
|
|
867
|
+
"jobs",
|
|
868
|
+
"notifications",
|
|
869
|
+
"schedules",
|
|
870
|
+
"ui",
|
|
871
|
+
"uploads",
|
|
872
|
+
];
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return [];
|
|
876
|
+
}
|
|
877
|
+
|
|
834
878
|
const featureAddonOrder: readonly NormalizedMakeFeatureAddon[] = [
|
|
835
879
|
"policy",
|
|
836
880
|
"factory",
|
|
@@ -1462,8 +1506,11 @@ export async function makeListener(
|
|
|
1462
1506
|
if (!options.skipEventAssert) {
|
|
1463
1507
|
await assertListenerEventExists(targetDir, names.event, config);
|
|
1464
1508
|
}
|
|
1509
|
+
await updateListenerProviderWiring(targetDir, names, config, {
|
|
1510
|
+
dryRun: true,
|
|
1511
|
+
});
|
|
1465
1512
|
|
|
1466
|
-
|
|
1513
|
+
const result = await makeFeatureArtifact({
|
|
1467
1514
|
targetDir,
|
|
1468
1515
|
config,
|
|
1469
1516
|
force: Boolean(options.force),
|
|
@@ -1478,6 +1525,26 @@ export async function makeListener(
|
|
|
1478
1525
|
"@beignet/core": beignetDependencyVersion,
|
|
1479
1526
|
},
|
|
1480
1527
|
});
|
|
1528
|
+
|
|
1529
|
+
const providerResult = await updateListenerProviderWiring(
|
|
1530
|
+
targetDir,
|
|
1531
|
+
names,
|
|
1532
|
+
config,
|
|
1533
|
+
{
|
|
1534
|
+
dryRun: Boolean(options.dryRun),
|
|
1535
|
+
},
|
|
1536
|
+
);
|
|
1537
|
+
if (providerResult === "created")
|
|
1538
|
+
result.createdFiles.push(providersFilePath(config));
|
|
1539
|
+
if (providerResult === "updated")
|
|
1540
|
+
result.updatedFiles.push(providersFilePath(config));
|
|
1541
|
+
if (providerResult === "skipped")
|
|
1542
|
+
result.skippedFiles.push(providersFilePath(config));
|
|
1543
|
+
if (!result.files.includes(providersFilePath(config))) {
|
|
1544
|
+
result.files.push(providersFilePath(config));
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
return result;
|
|
1481
1548
|
}
|
|
1482
1549
|
|
|
1483
1550
|
async function assertListenerEventExists(
|
|
@@ -1496,6 +1563,98 @@ async function assertListenerEventExists(
|
|
|
1496
1563
|
}
|
|
1497
1564
|
}
|
|
1498
1565
|
|
|
1566
|
+
async function updateListenerProviderWiring(
|
|
1567
|
+
targetDir: string,
|
|
1568
|
+
names: ListenerNames,
|
|
1569
|
+
config: ResolvedBeignetConfig,
|
|
1570
|
+
options: { dryRun: boolean },
|
|
1571
|
+
): Promise<WriteGeneratedFileResult> {
|
|
1572
|
+
const providersFile = providersFilePath(config);
|
|
1573
|
+
const filePath = path.join(targetDir, providersFile);
|
|
1574
|
+
const original = await readOptionalFile(filePath);
|
|
1575
|
+
if (original === undefined) {
|
|
1576
|
+
throw new Error(
|
|
1577
|
+
`Could not find the generated providers file ${providersFile}. Register listeners manually with registerListeners(...), or restore the generated providers file before running make listener.`,
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
const listenerIndex = featureArtifactIndexFile("listener", names, config);
|
|
1582
|
+
const providerName = `${names.featureSingularCamel}ListenersProvider`;
|
|
1583
|
+
let next = original;
|
|
1584
|
+
next = addNamedImport(next, "registerListeners", "@beignet/core/events");
|
|
1585
|
+
next = addNamedImport(next, "createServiceActor", "@beignet/core/ports");
|
|
1586
|
+
next = addNamedImport(next, "createProvider", "@beignet/core/providers");
|
|
1587
|
+
next = addNamedTypeImport(
|
|
1588
|
+
next,
|
|
1589
|
+
"AppContext",
|
|
1590
|
+
aliasModule(config.paths.appContext),
|
|
1591
|
+
);
|
|
1592
|
+
next = addNamedTypeImport(
|
|
1593
|
+
next,
|
|
1594
|
+
"AppServiceContextInput",
|
|
1595
|
+
aliasModule(path.join(path.dirname(config.paths.server), "context.ts")),
|
|
1596
|
+
);
|
|
1597
|
+
next = addNamedTypeImport(next, "AppPorts", aliasModule(config.paths.ports));
|
|
1598
|
+
|
|
1599
|
+
const listenerImport = `import { ${listenerIndex.registryName} } from "${aliasModule(listenerIndex.path)}";`;
|
|
1600
|
+
if (!next.includes(listenerImport)) {
|
|
1601
|
+
next = insertAfterImports(next, listenerImport);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
if (!new RegExp(`\\bconst\\s+${providerName}\\b`).test(next)) {
|
|
1605
|
+
const providerDefinition = `const ${providerName} = createProvider<
|
|
1606
|
+
\tPick<AppPorts, "eventBus" | "logger">,
|
|
1607
|
+
\tAppContext,
|
|
1608
|
+
\tAppServiceContextInput
|
|
1609
|
+
>()({
|
|
1610
|
+
\tname: "${names.feature.kebab}-listeners",
|
|
1611
|
+
\tsetup({ ports, createServiceContext }) {
|
|
1612
|
+
\t\tconst unregister = registerListeners(ports.eventBus, ${listenerIndex.registryName}, {
|
|
1613
|
+
\t\t\tctx: () =>
|
|
1614
|
+
\t\t\t\tcreateServiceContext({
|
|
1615
|
+
\t\t\t\t\tactor: createServiceActor("beignet-listener"),
|
|
1616
|
+
\t\t\t\t}),
|
|
1617
|
+
\t\t\tonError(error, listener) {
|
|
1618
|
+
\t\t\t\tports.logger.error("Event listener failed", {
|
|
1619
|
+
\t\t\t\t\terror,
|
|
1620
|
+
\t\t\t\t\tlistenerName: listener.name,
|
|
1621
|
+
\t\t\t\t});
|
|
1622
|
+
\t\t\t},
|
|
1623
|
+
\t\t});
|
|
1624
|
+
|
|
1625
|
+
\t\treturn {
|
|
1626
|
+
\t\t\tstop() {
|
|
1627
|
+
\t\t\t\tunregister();
|
|
1628
|
+
\t\t\t},
|
|
1629
|
+
\t\t};
|
|
1630
|
+
\t},
|
|
1631
|
+
});
|
|
1632
|
+
`;
|
|
1633
|
+
const withProvider = next.replace(
|
|
1634
|
+
/\nexport const providers = \[/,
|
|
1635
|
+
`\n${providerDefinition}\nexport const providers = [`,
|
|
1636
|
+
);
|
|
1637
|
+
if (withProvider === next) {
|
|
1638
|
+
throw new Error(
|
|
1639
|
+
`Could not find the exported providers array in ${providersFile}. Register ${listenerIndex.registryName} manually with registerListeners(...), or restore the generated providers file before running make listener.`,
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
next = withProvider;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
const appended = appendToNamedArray(next, "providers", providerName);
|
|
1646
|
+
if (appended.kind === "missing") {
|
|
1647
|
+
throw new Error(
|
|
1648
|
+
`Could not find the exported providers array in ${providersFile}. Register ${listenerIndex.registryName} manually with registerListeners(...), or restore the generated providers file before running make listener.`,
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
if (appended.kind === "updated") next = appended.source;
|
|
1652
|
+
|
|
1653
|
+
if (next === original) return "skipped";
|
|
1654
|
+
if (!options.dryRun) await writeFile(filePath, next);
|
|
1655
|
+
return "updated";
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1499
1658
|
export async function makeSchedule(
|
|
1500
1659
|
options: MakeScheduleOptions,
|
|
1501
1660
|
): Promise<MakeScheduleResult> {
|
|
@@ -1570,6 +1729,12 @@ const cronSecretEnvEntry = `\t\tCRON_SECRET: z.string().min(1).optional(),`;
|
|
|
1570
1729
|
// slots in directly below it, matching the documented env shape.
|
|
1571
1730
|
const cronSecretEnvAnchor = `\t\tAPP_URL: z.string().url().default("http://localhost:3000"),`;
|
|
1572
1731
|
|
|
1732
|
+
type FileChangeSet = {
|
|
1733
|
+
createdFiles: string[];
|
|
1734
|
+
updatedFiles: string[];
|
|
1735
|
+
skippedFiles: string[];
|
|
1736
|
+
};
|
|
1737
|
+
|
|
1573
1738
|
type CronSecretWiringPlan = {
|
|
1574
1739
|
files: Array<GeneratedFile & { result: "created" | "updated" }>;
|
|
1575
1740
|
skippedFiles: string[];
|
|
@@ -1703,23 +1868,58 @@ async function updateOutboxPortWiring(
|
|
|
1703
1868
|
targetDir: string,
|
|
1704
1869
|
config: ResolvedBeignetConfig,
|
|
1705
1870
|
options: { dryRun: boolean },
|
|
1706
|
-
): Promise<
|
|
1707
|
-
const
|
|
1871
|
+
): Promise<FileChangeSet> {
|
|
1872
|
+
const changes: FileChangeSet = {
|
|
1873
|
+
createdFiles: [],
|
|
1874
|
+
updatedFiles: [],
|
|
1875
|
+
skippedFiles: [],
|
|
1876
|
+
};
|
|
1708
1877
|
|
|
1709
1878
|
if (await updateOutboxPorts(targetDir, config, options)) {
|
|
1710
|
-
|
|
1879
|
+
changes.updatedFiles.push(config.paths.ports);
|
|
1711
1880
|
}
|
|
1712
1881
|
if (await updateOutboxInfrastructurePorts(targetDir, config, options)) {
|
|
1713
|
-
|
|
1882
|
+
changes.updatedFiles.push(config.paths.infrastructurePorts);
|
|
1714
1883
|
}
|
|
1715
1884
|
if (await updateOutboxDatabaseProvider(targetDir, config, options)) {
|
|
1716
|
-
|
|
1885
|
+
changes.updatedFiles.push(
|
|
1886
|
+
path.join(infrastructureDir(config), "db/provider.ts"),
|
|
1887
|
+
);
|
|
1717
1888
|
}
|
|
1718
1889
|
if (await updateOutboxRepositoriesReturnType(targetDir, config, options)) {
|
|
1719
|
-
|
|
1890
|
+
changes.updatedFiles.push(drizzleRepositoriesPath(config));
|
|
1720
1891
|
}
|
|
1721
1892
|
|
|
1722
|
-
|
|
1893
|
+
const schemaResult = await updateOutboxDrizzleSchema(
|
|
1894
|
+
targetDir,
|
|
1895
|
+
config,
|
|
1896
|
+
options,
|
|
1897
|
+
);
|
|
1898
|
+
if (schemaResult.result === "created") {
|
|
1899
|
+
changes.createdFiles.push(schemaResult.path);
|
|
1900
|
+
}
|
|
1901
|
+
if (schemaResult.result === "updated") {
|
|
1902
|
+
changes.updatedFiles.push(schemaResult.path);
|
|
1903
|
+
}
|
|
1904
|
+
if (schemaResult.result === "skipped") {
|
|
1905
|
+
changes.skippedFiles.push(schemaResult.path);
|
|
1906
|
+
}
|
|
1907
|
+
if (schemaResult.canExport) {
|
|
1908
|
+
const schemaIndexUpdated = await updateOutboxDrizzleSchemaIndex(
|
|
1909
|
+
targetDir,
|
|
1910
|
+
config,
|
|
1911
|
+
options,
|
|
1912
|
+
);
|
|
1913
|
+
if (schemaIndexUpdated) {
|
|
1914
|
+
changes.updatedFiles.push(drizzleSchemaIndexPath(config));
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
return {
|
|
1919
|
+
createdFiles: uniqueStrings(changes.createdFiles),
|
|
1920
|
+
updatedFiles: uniqueStrings(changes.updatedFiles),
|
|
1921
|
+
skippedFiles: uniqueStrings(changes.skippedFiles),
|
|
1922
|
+
};
|
|
1723
1923
|
}
|
|
1724
1924
|
|
|
1725
1925
|
async function applyOutboxPortWiring(
|
|
@@ -1728,11 +1928,19 @@ async function applyOutboxPortWiring(
|
|
|
1728
1928
|
config: ResolvedBeignetConfig,
|
|
1729
1929
|
options: { dryRun: boolean },
|
|
1730
1930
|
): Promise<void> {
|
|
1731
|
-
const
|
|
1732
|
-
for (const file of
|
|
1931
|
+
const changes = await updateOutboxPortWiring(targetDir, config, options);
|
|
1932
|
+
for (const file of changes.createdFiles) {
|
|
1933
|
+
if (!result.createdFiles.includes(file)) result.createdFiles.push(file);
|
|
1934
|
+
if (!result.files.includes(file)) result.files.push(file);
|
|
1935
|
+
}
|
|
1936
|
+
for (const file of changes.updatedFiles) {
|
|
1733
1937
|
if (!result.updatedFiles.includes(file)) result.updatedFiles.push(file);
|
|
1734
1938
|
if (!result.files.includes(file)) result.files.push(file);
|
|
1735
1939
|
}
|
|
1940
|
+
for (const file of changes.skippedFiles) {
|
|
1941
|
+
if (!result.skippedFiles.includes(file)) result.skippedFiles.push(file);
|
|
1942
|
+
if (!result.files.includes(file)) result.files.push(file);
|
|
1943
|
+
}
|
|
1736
1944
|
}
|
|
1737
1945
|
|
|
1738
1946
|
async function updateOutboxPorts(
|
|
@@ -1862,6 +2070,61 @@ async function updateOutboxRepositoriesReturnType(
|
|
|
1862
2070
|
return true;
|
|
1863
2071
|
}
|
|
1864
2072
|
|
|
2073
|
+
async function updateOutboxDrizzleSchema(
|
|
2074
|
+
targetDir: string,
|
|
2075
|
+
config: ResolvedBeignetConfig,
|
|
2076
|
+
options: { dryRun: boolean },
|
|
2077
|
+
): Promise<{
|
|
2078
|
+
path: string;
|
|
2079
|
+
result?: WriteGeneratedFileResult;
|
|
2080
|
+
canExport: boolean;
|
|
2081
|
+
}> {
|
|
2082
|
+
const indexPath = path.join(targetDir, drizzleSchemaIndexPath(config));
|
|
2083
|
+
const schemaPath = outboxDrizzleSchemaFilePath(config);
|
|
2084
|
+
const destination = path.join(targetDir, schemaPath);
|
|
2085
|
+
if (!(await fileExists(indexPath))) {
|
|
2086
|
+
return { path: schemaPath, canExport: false };
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
const database = await detectResourceDatabase(targetDir, config);
|
|
2090
|
+
const content = outboxDrizzleSchemaFile(drizzleDialects[database]);
|
|
2091
|
+
const existing = await readOptionalFile(destination);
|
|
2092
|
+
|
|
2093
|
+
if (existing === undefined) {
|
|
2094
|
+
if (!options.dryRun) {
|
|
2095
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
2096
|
+
await writeFile(destination, content);
|
|
2097
|
+
}
|
|
2098
|
+
return { path: schemaPath, result: "created", canExport: true };
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
if (
|
|
2102
|
+
existing === content ||
|
|
2103
|
+
/\bexport\s+const\s+outboxMessages\b/.test(existing)
|
|
2104
|
+
) {
|
|
2105
|
+
return { path: schemaPath, result: "skipped", canExport: true };
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
return { path: schemaPath, result: "skipped", canExport: false };
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
async function updateOutboxDrizzleSchemaIndex(
|
|
2112
|
+
targetDir: string,
|
|
2113
|
+
config: ResolvedBeignetConfig,
|
|
2114
|
+
options: { dryRun: boolean },
|
|
2115
|
+
): Promise<boolean> {
|
|
2116
|
+
const filePath = path.join(targetDir, drizzleSchemaIndexPath(config));
|
|
2117
|
+
if (!(await fileExists(filePath))) return false;
|
|
2118
|
+
|
|
2119
|
+
const original = await readFile(filePath, "utf8");
|
|
2120
|
+
const exportLine = 'export { outboxMessages } from "./outbox-messages";';
|
|
2121
|
+
if (original.includes(exportLine)) return false;
|
|
2122
|
+
|
|
2123
|
+
const next = `${original.trimEnd()}\n${exportLine}\n`;
|
|
2124
|
+
if (!options.dryRun) await writeFile(filePath, next);
|
|
2125
|
+
return true;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
1865
2128
|
function drizzleOutboxPortFactoryName(database: DatabaseName): string {
|
|
1866
2129
|
if (database === "postgres") return "createDrizzlePostgresOutboxPort";
|
|
1867
2130
|
if (database === "mysql") return "createDrizzleMysqlOutboxPort";
|
|
@@ -2267,11 +2530,12 @@ export async function makeOutbox(
|
|
|
2267
2530
|
createdFiles.push(...cronSecretResult.createdFiles);
|
|
2268
2531
|
updatedFiles.push(...cronSecretResult.updatedFiles);
|
|
2269
2532
|
skippedFiles.push(...cronSecretResult.skippedFiles);
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
);
|
|
2533
|
+
const outboxWiringResult = await updateOutboxPortWiring(targetDir, config, {
|
|
2534
|
+
dryRun: Boolean(options.dryRun),
|
|
2535
|
+
});
|
|
2536
|
+
createdFiles.push(...outboxWiringResult.createdFiles);
|
|
2537
|
+
updatedFiles.push(...outboxWiringResult.updatedFiles);
|
|
2538
|
+
skippedFiles.push(...outboxWiringResult.skippedFiles);
|
|
2275
2539
|
|
|
2276
2540
|
return {
|
|
2277
2541
|
schemaVersion: 1,
|
|
@@ -2286,6 +2550,8 @@ export async function makeOutbox(
|
|
|
2286
2550
|
config.paths.infrastructurePorts,
|
|
2287
2551
|
path.join(infrastructureDir(config), "db/provider.ts"),
|
|
2288
2552
|
drizzleRepositoriesPath(config),
|
|
2553
|
+
outboxDrizzleSchemaFilePath(config),
|
|
2554
|
+
drizzleSchemaIndexPath(config),
|
|
2289
2555
|
],
|
|
2290
2556
|
createdFiles: uniqueStrings(createdFiles),
|
|
2291
2557
|
updatedFiles: uniqueStrings(updatedFiles),
|
|
@@ -5456,9 +5722,14 @@ function featureUiComponentFiles(
|
|
|
5456
5722
|
names: FeatureUiNames,
|
|
5457
5723
|
config: ResolvedBeignetConfig,
|
|
5458
5724
|
): GeneratedFile[] {
|
|
5725
|
+
const clientQueriesPath = featureUiClientQueriesFilePath(names, config);
|
|
5459
5726
|
const componentPath = featureUiComponentFilePath(names, config);
|
|
5460
5727
|
|
|
5461
5728
|
return [
|
|
5729
|
+
{
|
|
5730
|
+
path: clientQueriesPath,
|
|
5731
|
+
content: featureUiClientQueriesFile(names, config),
|
|
5732
|
+
},
|
|
5462
5733
|
{
|
|
5463
5734
|
path: componentPath,
|
|
5464
5735
|
content: featureUiComponentFile(names, config),
|
|
@@ -5721,6 +5992,15 @@ function drizzleResourceSchemaFilePath(
|
|
|
5721
5992
|
);
|
|
5722
5993
|
}
|
|
5723
5994
|
|
|
5995
|
+
function outboxDrizzleSchemaFilePath(config: ResolvedBeignetConfig): string {
|
|
5996
|
+
return path.join(
|
|
5997
|
+
infrastructureDir(config),
|
|
5998
|
+
"db",
|
|
5999
|
+
"schema",
|
|
6000
|
+
"outbox-messages.ts",
|
|
6001
|
+
);
|
|
6002
|
+
}
|
|
6003
|
+
|
|
5724
6004
|
function drizzleResourceRepositoryFilePath(
|
|
5725
6005
|
names: ResourceNames,
|
|
5726
6006
|
config: ResolvedBeignetConfig,
|
|
@@ -5826,6 +6106,20 @@ function featureUiDir(
|
|
|
5826
6106
|
return path.join(resourceFeatureDir(names, config), "components");
|
|
5827
6107
|
}
|
|
5828
6108
|
|
|
6109
|
+
function featureUiClientDir(
|
|
6110
|
+
names: FeatureUiNames,
|
|
6111
|
+
config: ResolvedBeignetConfig,
|
|
6112
|
+
): string {
|
|
6113
|
+
return path.join(resourceFeatureDir(names, config), "client");
|
|
6114
|
+
}
|
|
6115
|
+
|
|
6116
|
+
function featureUiClientQueriesFilePath(
|
|
6117
|
+
names: FeatureUiNames,
|
|
6118
|
+
config: ResolvedBeignetConfig,
|
|
6119
|
+
): string {
|
|
6120
|
+
return path.join(featureUiClientDir(names, config), "queries.ts");
|
|
6121
|
+
}
|
|
6122
|
+
|
|
5829
6123
|
function featureUiComponentFilePath(
|
|
5830
6124
|
names: FeatureUiNames,
|
|
5831
6125
|
config: ResolvedBeignetConfig,
|
|
@@ -6894,7 +7188,7 @@ function listUseCaseFile(
|
|
|
6894
7188
|
`list-${names.pluralKebab}.ts`,
|
|
6895
7189
|
);
|
|
6896
7190
|
|
|
6897
|
-
return `import
|
|
7191
|
+
return `import "@beignet/core/server-only";
|
|
6898
7192
|
import { normalizeCursorPage } from "@beignet/core/pagination";
|
|
6899
7193
|
import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
|
|
6900
7194
|
import {
|
|
@@ -6934,7 +7228,7 @@ function createUseCaseFile(
|
|
|
6934
7228
|
`create-${names.singularKebab}.ts`,
|
|
6935
7229
|
);
|
|
6936
7230
|
|
|
6937
|
-
return `import
|
|
7231
|
+
return `import "@beignet/core/server-only";
|
|
6938
7232
|
import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
|
|
6939
7233
|
import {
|
|
6940
7234
|
Create${names.singularPascal}InputSchema,
|
|
@@ -6966,7 +7260,7 @@ function getUseCaseFile(
|
|
|
6966
7260
|
`get-${names.singularKebab}.ts`,
|
|
6967
7261
|
);
|
|
6968
7262
|
|
|
6969
|
-
return `import
|
|
7263
|
+
return `import "@beignet/core/server-only";
|
|
6970
7264
|
import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
|
|
6971
7265
|
import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
|
|
6972
7266
|
import {
|
|
@@ -7001,7 +7295,7 @@ function updateUseCaseFile(
|
|
|
7001
7295
|
`update-${names.singularKebab}.ts`,
|
|
7002
7296
|
);
|
|
7003
7297
|
|
|
7004
|
-
return `import
|
|
7298
|
+
return `import "@beignet/core/server-only";
|
|
7005
7299
|
import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
|
|
7006
7300
|
import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
|
|
7007
7301
|
${options.events ? `import { ${names.singularPascal}Updated } from "../domain/events";\n` : ""}import {
|
|
@@ -7050,7 +7344,7 @@ function deleteUseCaseFile(
|
|
|
7050
7344
|
`delete-${names.singularKebab}.ts`,
|
|
7051
7345
|
);
|
|
7052
7346
|
|
|
7053
|
-
return `import
|
|
7347
|
+
return `import "@beignet/core/server-only";
|
|
7054
7348
|
import { z } from "zod";
|
|
7055
7349
|
import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
|
|
7056
7350
|
import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
|
|
@@ -7160,7 +7454,7 @@ function inMemoryRepositoryFile(
|
|
|
7160
7454
|
return true;`
|
|
7161
7455
|
: `${options.tenant ? `\t\t\tconst existing = ${names.pluralCamel}.get(id);\n\t\t\tif (existing?.tenantId !== filter.tenantId) return false;\n` : ""} return ${names.pluralCamel}.delete(id);`;
|
|
7162
7456
|
|
|
7163
|
-
return `import
|
|
7457
|
+
return `import "@beignet/core/server-only";
|
|
7164
7458
|
import { cursorPageResult } from "@beignet/core/pagination";
|
|
7165
7459
|
import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
|
|
7166
7460
|
import { encode${names.singularPascal}Cursor } from "${aliasModule(resourceSchemaFilePath(names, config))}";
|
|
@@ -7285,6 +7579,78 @@ ${options.softDelete ? `\tdeletedAt: ${dialect.timestampColumn("deleted_at")},\n
|
|
|
7285
7579
|
`;
|
|
7286
7580
|
}
|
|
7287
7581
|
|
|
7582
|
+
function outboxDrizzleSchemaFile(dialect: DrizzleDialect): string {
|
|
7583
|
+
if (dialect.subpath === "mysql") {
|
|
7584
|
+
return `import { index, int, longtext, mysqlTable, varchar } from "drizzle-orm/mysql-core";
|
|
7585
|
+
|
|
7586
|
+
export const outboxMessages = mysqlTable(
|
|
7587
|
+
\t"outbox_messages",
|
|
7588
|
+
\t{
|
|
7589
|
+
\t\tid: varchar("id", { length: 255 }).primaryKey(),
|
|
7590
|
+
\t\tkind: varchar("kind", { length: 32 }).notNull(),
|
|
7591
|
+
\t\tname: varchar("name", { length: 255 }).notNull(),
|
|
7592
|
+
\t\tpayloadJson: longtext("payload_json").notNull(),
|
|
7593
|
+
\t\tstatus: varchar("status", { length: 32 }).notNull(),
|
|
7594
|
+
\t\tattempts: int("attempts").notNull().default(0),
|
|
7595
|
+
\t\tmaxAttempts: int("max_attempts").notNull().default(3),
|
|
7596
|
+
\t\tavailableAt: varchar("available_at", { length: 32 }).notNull(),
|
|
7597
|
+
\t\tclaimedAt: varchar("claimed_at", { length: 32 }),
|
|
7598
|
+
\t\tlockedUntil: varchar("locked_until", { length: 32 }),
|
|
7599
|
+
\t\tclaimToken: varchar("claim_token", { length: 64 }),
|
|
7600
|
+
\t\tdeliveredAt: varchar("delivered_at", { length: 32 }),
|
|
7601
|
+
\t\tlastErrorJson: longtext("last_error_json"),
|
|
7602
|
+
\t\tcreatedAt: varchar("created_at", { length: 32 }).notNull(),
|
|
7603
|
+
\t\tupdatedAt: varchar("updated_at", { length: 32 }).notNull(),
|
|
7604
|
+
\t},
|
|
7605
|
+
\t(table) => ({
|
|
7606
|
+
\t\tavailableIdx: index("outbox_messages_available_idx").on(
|
|
7607
|
+
\t\t\ttable.status,
|
|
7608
|
+
\t\t\ttable.availableAt,
|
|
7609
|
+
\t\t),
|
|
7610
|
+
\t\tlockedIdx: index("outbox_messages_locked_idx").on(
|
|
7611
|
+
\t\t\ttable.status,
|
|
7612
|
+
\t\t\ttable.lockedUntil,
|
|
7613
|
+
\t\t),
|
|
7614
|
+
\t}),
|
|
7615
|
+
);
|
|
7616
|
+
`;
|
|
7617
|
+
}
|
|
7618
|
+
|
|
7619
|
+
return `import { index, integer, ${dialect.tableFunction}, text } from "${dialect.columnModule}";
|
|
7620
|
+
|
|
7621
|
+
export const outboxMessages = ${dialect.tableFunction}(
|
|
7622
|
+
\t"outbox_messages",
|
|
7623
|
+
\t{
|
|
7624
|
+
\t\tid: text("id").primaryKey(),
|
|
7625
|
+
\t\tkind: text("kind").notNull(),
|
|
7626
|
+
\t\tname: text("name").notNull(),
|
|
7627
|
+
\t\tpayloadJson: text("payload_json").notNull(),
|
|
7628
|
+
\t\tstatus: text("status").notNull(),
|
|
7629
|
+
\t\tattempts: integer("attempts").notNull().default(0),
|
|
7630
|
+
\t\tmaxAttempts: integer("max_attempts").notNull().default(3),
|
|
7631
|
+
\t\tavailableAt: text("available_at").notNull(),
|
|
7632
|
+
\t\tclaimedAt: text("claimed_at"),
|
|
7633
|
+
\t\tlockedUntil: text("locked_until"),
|
|
7634
|
+
\t\tclaimToken: text("claim_token"),
|
|
7635
|
+
\t\tdeliveredAt: text("delivered_at"),
|
|
7636
|
+
\t\tlastErrorJson: text("last_error_json"),
|
|
7637
|
+
\t\tcreatedAt: text("created_at").notNull(),
|
|
7638
|
+
\t\tupdatedAt: text("updated_at").notNull(),
|
|
7639
|
+
\t},
|
|
7640
|
+
\t(table) => ({
|
|
7641
|
+
\t\tavailableIdx: index("outbox_messages_available_idx").on(
|
|
7642
|
+
\t\t\ttable.status,
|
|
7643
|
+
\t\t\ttable.availableAt,
|
|
7644
|
+
\t\t),
|
|
7645
|
+
\t\tlockedIdx: index("outbox_messages_locked_idx").on(
|
|
7646
|
+
\t\t\ttable.status,
|
|
7647
|
+
\t\t\ttable.lockedUntil,
|
|
7648
|
+
\t\t),
|
|
7649
|
+
\t}),
|
|
7650
|
+
);
|
|
7651
|
+
`;
|
|
7652
|
+
}
|
|
7653
|
+
|
|
7288
7654
|
function drizzleResourceWhere(
|
|
7289
7655
|
names: ResourceNames,
|
|
7290
7656
|
options: ResourceGenerationOptions,
|
|
@@ -7467,7 +7833,7 @@ ${options.softDelete ? `\t\t\tconst now = new Date().toISOString();\n\t\t\tconst
|
|
|
7467
7833
|
},
|
|
7468
7834
|
`;
|
|
7469
7835
|
|
|
7470
|
-
return `import
|
|
7836
|
+
return `import "@beignet/core/server-only";
|
|
7471
7837
|
import { cursorPageResult } from "@beignet/core/pagination";
|
|
7472
7838
|
import type { ${dialect.dbTypeName} } from "@beignet/provider-db-drizzle/${dialect.subpath}";
|
|
7473
7839
|
import { ${drizzleImports.join(", ")} } from "drizzle-orm";
|
|
@@ -7702,7 +8068,7 @@ function standaloneUseCaseFile(
|
|
|
7702
8068
|
config: ResolvedBeignetConfig,
|
|
7703
8069
|
filePath: string,
|
|
7704
8070
|
): string {
|
|
7705
|
-
return `import
|
|
8071
|
+
return `import "@beignet/core/server-only";
|
|
7706
8072
|
import { z } from "zod";
|
|
7707
8073
|
import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
|
|
7708
8074
|
|
|
@@ -9569,30 +9935,31 @@ function featureUiComponentFile(
|
|
|
9569
9935
|
names: FeatureUiNames,
|
|
9570
9936
|
config: ResolvedBeignetConfig,
|
|
9571
9937
|
): string {
|
|
9938
|
+
const componentPath = featureUiComponentFilePath(names, config);
|
|
9939
|
+
const clientQueriesPath = featureUiClientQueriesFilePath(names, config);
|
|
9940
|
+
|
|
9572
9941
|
return `"use client";
|
|
9573
9942
|
|
|
9574
9943
|
import { contractErrorMessage } from "@beignet/core/client";
|
|
9575
9944
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9576
9945
|
import { useState } from "react";
|
|
9577
|
-
import {
|
|
9578
|
-
|
|
9579
|
-
|
|
9580
|
-
|
|
9946
|
+
import {
|
|
9947
|
+
\tcreate${names.singularPascal}MutationOptions,
|
|
9948
|
+
\tinvalidate${names.pluralPascal},
|
|
9949
|
+
\tlist${names.pluralPascal}QueryOptions,
|
|
9950
|
+
} from "${relativeModule(componentPath, clientQueriesPath)}";
|
|
9581
9951
|
|
|
9582
9952
|
export function ${names.componentExportName}() {
|
|
9583
9953
|
\tconst [name, setName] = useState("");
|
|
9584
9954
|
\tconst queryClient = useQueryClient();
|
|
9585
|
-
\tconst ${names.pluralCamel}Query = useQuery(
|
|
9586
|
-
\
|
|
9587
|
-
\t)
|
|
9588
|
-
\
|
|
9589
|
-
\t\
|
|
9590
|
-
\t\t\
|
|
9591
|
-
\t\t
|
|
9592
|
-
\t
|
|
9593
|
-
\t\t\t},
|
|
9594
|
-
\t\t}),
|
|
9595
|
-
\t);
|
|
9955
|
+
\tconst ${names.pluralCamel}Query = useQuery(list${names.pluralPascal}QueryOptions());
|
|
9956
|
+
\tconst create${names.singularPascal}Mutation = useMutation({
|
|
9957
|
+
\t\t...create${names.singularPascal}MutationOptions(),
|
|
9958
|
+
\t\tonSuccess: async () => {
|
|
9959
|
+
\t\t\tsetName("");
|
|
9960
|
+
\t\t\tawait invalidate${names.pluralPascal}(queryClient);
|
|
9961
|
+
\t\t},
|
|
9962
|
+
\t});
|
|
9596
9963
|
|
|
9597
9964
|
\treturn (
|
|
9598
9965
|
\t\t<section className="${names.pluralKebab}-panel">
|
|
@@ -9660,6 +10027,34 @@ export function ${names.componentExportName}() {
|
|
|
9660
10027
|
`;
|
|
9661
10028
|
}
|
|
9662
10029
|
|
|
10030
|
+
function featureUiClientQueriesFile(
|
|
10031
|
+
names: FeatureUiNames,
|
|
10032
|
+
config: ResolvedBeignetConfig,
|
|
10033
|
+
): string {
|
|
10034
|
+
const clientQueriesPath = featureUiClientQueriesFilePath(names, config);
|
|
10035
|
+
const contractsPath = resourceContractFilePath(names, config);
|
|
10036
|
+
|
|
10037
|
+
return `import type { QueryClient } from "@tanstack/react-query";
|
|
10038
|
+
import { rq } from "${aliasModule(clientIndexPath(config))}";
|
|
10039
|
+
import {
|
|
10040
|
+
\tcreate${names.singularPascal},
|
|
10041
|
+
\tlist${names.pluralPascal},
|
|
10042
|
+
} from "${relativeModule(clientQueriesPath, contractsPath)}";
|
|
10043
|
+
|
|
10044
|
+
export function list${names.pluralPascal}QueryOptions() {
|
|
10045
|
+
\treturn rq(list${names.pluralPascal}).queryOptions({ query: {} });
|
|
10046
|
+
}
|
|
10047
|
+
|
|
10048
|
+
export function create${names.singularPascal}MutationOptions() {
|
|
10049
|
+
\treturn rq(create${names.singularPascal}).mutationOptions();
|
|
10050
|
+
}
|
|
10051
|
+
|
|
10052
|
+
export function invalidate${names.pluralPascal}(queryClient: QueryClient) {
|
|
10053
|
+
\treturn rq(list${names.pluralPascal}).invalidate(queryClient);
|
|
10054
|
+
}
|
|
10055
|
+
`;
|
|
10056
|
+
}
|
|
10057
|
+
|
|
9663
10058
|
function scheduleRouteFile(
|
|
9664
10059
|
names: ScheduleNames,
|
|
9665
10060
|
config: ResolvedBeignetConfig,
|
|
@@ -9706,7 +10101,7 @@ function routeGroupFile(
|
|
|
9706
10101
|
const contractsPath = resourceContractFilePath(names, config);
|
|
9707
10102
|
const useCasesPath = resourceUseCaseIndexPath(names, config);
|
|
9708
10103
|
|
|
9709
|
-
return `import
|
|
10104
|
+
return `import "@beignet/core/server-only";
|
|
9710
10105
|
import { defineRouteGroup } from "@beignet/next";
|
|
9711
10106
|
import type { AppContext } from "${relativeModule(routeFilePath, config.paths.appContext)}";
|
|
9712
10107
|
import {
|
|
@@ -9743,12 +10138,12 @@ function testFile(
|
|
|
9743
10138
|
path.dirname(config.paths.server),
|
|
9744
10139
|
"context.ts",
|
|
9745
10140
|
);
|
|
9746
|
-
const requestTarget =
|
|
10141
|
+
const requestTarget = "app";
|
|
9747
10142
|
|
|
9748
10143
|
return `import { describe, expect, it } from "bun:test";
|
|
9749
10144
|
import { createUseCaseTester } from "@beignet/core/application";
|
|
9750
10145
|
${mode === "resource" ? `import { isAppError } from "@beignet/core/errors";\n` : ""}import { defineRoutes } from "@beignet/web";
|
|
9751
|
-
import { createTestApp
|
|
10146
|
+
import { createTestApp } from "@beignet/web/testing";
|
|
9752
10147
|
import { createInMemoryDevtools } from "@beignet/devtools";
|
|
9753
10148
|
import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
|
|
9754
10149
|
import { ${options.auth ? "createTestUserActor" : "createTestAnonymousActor"}${options.tenant ? ", createTestTenant" : ""} } from "@beignet/core/ports/testing";
|
|
@@ -9797,13 +10192,15 @@ ${options.tenant ? `\t\t\t{\n\t\t\t\tid: "00000000-0000-4000-8000-000000000104",
|
|
|
9797
10192
|
overrides: {
|
|
9798
10193
|
${names.pluralCamel},
|
|
9799
10194
|
${
|
|
9800
|
-
options.auth
|
|
10195
|
+
options.auth || options.tenant
|
|
9801
10196
|
? `\t\t\t\tauth: {
|
|
9802
10197
|
getSession: async () => ({
|
|
9803
|
-
user: { id: "user_test", name: "Test User" },
|
|
10198
|
+
user: { id: "user_test", name: "Test User"${options.tenant ? `, tenantId: "tenant_test"` : ""} },
|
|
10199
|
+
${options.tenant ? `\t\t\t\t\t\tsession: { tenantId: "tenant_test" },\n` : ""}
|
|
9804
10200
|
}),
|
|
9805
10201
|
},
|
|
9806
|
-
|
|
10202
|
+
${options.auth ? `\t\t\t\tgate: appPorts.gate,\n` : ""}
|
|
10203
|
+
`
|
|
9807
10204
|
: `\t\t\t\tauth: {
|
|
9808
10205
|
getSession: async () => null,
|
|
9809
10206
|
},\n`
|
|
@@ -9953,7 +10350,6 @@ ${
|
|
|
9953
10350
|
routes: defineRoutes<AppContext>([${names.singularCamel}Routes]),
|
|
9954
10351
|
context: appContext,
|
|
9955
10352
|
});
|
|
9956
|
-
${options.tenant ? `\t\tconst requester = createTestRequester(app, {\n\t\t\theaders: { "x-tenant-id": "tenant_test" },\n\t\t});\n` : ""}
|
|
9957
10353
|
const createdViaRoute = await ${requestTarget}.request(create${names.singularPascal}, {
|
|
9958
10354
|
body: { name: "Second ${names.singularPascal}" },
|
|
9959
10355
|
});
|