@cedarjs/cli 3.0.0-canary.13429 → 3.0.0-canary.13430

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.
@@ -0,0 +1,31 @@
1
+ import { recordTelemetryAttributes } from "@cedarjs/cli-helpers";
2
+ const command = "live-queries";
3
+ const description = "Setup live query invalidation with Postgres notifications";
4
+ function builder(yargs) {
5
+ yargs.option("force", {
6
+ alias: "f",
7
+ default: false,
8
+ description: "Overwrite existing configuration",
9
+ type: "boolean"
10
+ }).option("verbose", {
11
+ alias: "v",
12
+ default: false,
13
+ description: "Print more logs",
14
+ type: "boolean"
15
+ });
16
+ }
17
+ async function handler(options) {
18
+ recordTelemetryAttributes({
19
+ command: "setup live-queries",
20
+ force: options.force,
21
+ verbose: options.verbose
22
+ });
23
+ const { handler: handler2 } = await import("./liveQueriesHandler.js");
24
+ return handler2(options);
25
+ }
26
+ export {
27
+ builder,
28
+ command,
29
+ description,
30
+ handler
31
+ };
@@ -0,0 +1,282 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { Listr } from "listr2";
4
+ import { addApiPackages } from "@cedarjs/cli-helpers";
5
+ import { getMigrationsPath, getSchemaPath } from "@cedarjs/project-config";
6
+ import { errorTelemetry } from "@cedarjs/telemetry";
7
+ import c from "../../../lib/colors.js";
8
+ import { getPaths, transformTSToJS, writeFile } from "../../../lib/index.js";
9
+ import { isTypeScriptProject } from "../../../lib/project.js";
10
+ const getApiPackageJson = () => {
11
+ const apiPackageJsonPath = path.join(getPaths().api.base, "package.json");
12
+ return JSON.parse(fs.readFileSync(apiPackageJsonPath, "utf-8"));
13
+ };
14
+ const hasPackage = (packageJson, packageName) => {
15
+ return Boolean(
16
+ packageJson.dependencies?.[packageName] || packageJson.devDependencies?.[packageName]
17
+ );
18
+ };
19
+ const getPrismaProvider = async () => {
20
+ try {
21
+ const prismaConfigPath = getPaths().api.prismaConfig;
22
+ if (!prismaConfigPath) {
23
+ return void 0;
24
+ }
25
+ let schemaPath = await getSchemaPath(prismaConfigPath);
26
+ if (!schemaPath) {
27
+ return void 0;
28
+ }
29
+ let stat;
30
+ try {
31
+ stat = fs.statSync(schemaPath);
32
+ } catch (e) {
33
+ stat = void 0;
34
+ }
35
+ if (stat && stat.isDirectory()) {
36
+ const candidate = path.join(schemaPath, "schema.prisma");
37
+ if (fs.existsSync(candidate)) {
38
+ schemaPath = candidate;
39
+ } else {
40
+ return void 0;
41
+ }
42
+ }
43
+ if (!fs.existsSync(schemaPath)) {
44
+ return void 0;
45
+ }
46
+ const content = fs.readFileSync(schemaPath, "utf-8");
47
+ const match = content.match(/^\s*provider\s*=\s*["']([^"']+)["']/im);
48
+ if (match && match[1]) {
49
+ return match[1].toLowerCase();
50
+ }
51
+ return void 0;
52
+ } catch {
53
+ return void 0;
54
+ }
55
+ };
56
+ const findExistingLiveQueryMigration = ({ migrationsDirectoryPath }) => {
57
+ if (!fs.existsSync(migrationsDirectoryPath)) {
58
+ return void 0;
59
+ }
60
+ const globPattern = path.join(migrationsDirectoryPath, "*", "migration.sql").replaceAll("\\", "/");
61
+ const migrationFilePaths = fs.globSync(globPattern);
62
+ return migrationFilePaths.find((migrationFilePath) => {
63
+ const content = fs.readFileSync(migrationFilePath, "utf-8");
64
+ return content.includes("cedar_notify_table_change");
65
+ });
66
+ };
67
+ const generateMigrationFolderName = () => {
68
+ const now = /* @__PURE__ */ new Date();
69
+ const year = String(now.getFullYear());
70
+ const month = String(now.getMonth() + 1).padStart(2, "0");
71
+ const day = String(now.getDate()).padStart(2, "0");
72
+ const hour = String(now.getHours()).padStart(2, "0");
73
+ const minute = String(now.getMinutes()).padStart(2, "0");
74
+ const second = String(now.getSeconds()).padStart(2, "0");
75
+ return `${year}${month}${day}${hour}${minute}${second}_live_queries_notifications`;
76
+ };
77
+ const addLiveQueryListenerToGraphqlHandler = ({ force }) => {
78
+ const graphqlHandlerPath = path.join(
79
+ getPaths().api.functions,
80
+ `graphql.${isTypeScriptProject() ? "ts" : "js"}`
81
+ );
82
+ if (!fs.existsSync(graphqlHandlerPath)) {
83
+ return {
84
+ skipped: true,
85
+ reason: "GraphQL handler not found"
86
+ };
87
+ }
88
+ const contentLines = fs.readFileSync(graphqlHandlerPath, "utf-8").split("\n");
89
+ const importLineRegex = /^import {.*startLiveQueryListener.*} from ['"]src\/lib\/liveQueriesListener['"];?$/;
90
+ const multilineImportRegex = /^} from ['"]src\/lib\/liveQueriesListener['"];?$/;
91
+ const hasImport = contentLines.some((line) => {
92
+ return importLineRegex.test(line) || multilineImportRegex.test(line);
93
+ });
94
+ const hasStartCall = contentLines.some(
95
+ (line) => line.trim().startsWith("startLiveQueryListener(") || line.trim().startsWith("void startLiveQueryListener(")
96
+ );
97
+ if (hasImport && hasStartCall && !force) {
98
+ return {
99
+ skipped: true,
100
+ reason: "Listener is already wired into GraphQL handler"
101
+ };
102
+ }
103
+ const handlerIndex = contentLines.findLastIndex(
104
+ (line) => line === "export const handler = createGraphQLHandler({"
105
+ );
106
+ if (handlerIndex === -1) {
107
+ return {
108
+ skipped: true,
109
+ reason: "Unexpected syntax. Handler not found"
110
+ };
111
+ }
112
+ const lastImportIndex = contentLines.slice(0, handlerIndex).findLastIndex((line) => line.startsWith("import "));
113
+ if (lastImportIndex === -1) {
114
+ return {
115
+ skipped: true,
116
+ reason: "Unexpected syntax. No imports found"
117
+ };
118
+ }
119
+ if (!hasImport) {
120
+ contentLines.splice(
121
+ lastImportIndex + 1,
122
+ 0,
123
+ "import { startLiveQueryListener } from 'src/lib/liveQueriesListener'"
124
+ );
125
+ }
126
+ const handlerIndexAfterImport = hasImport ? handlerIndex : handlerIndex + 1;
127
+ if (!hasStartCall) {
128
+ contentLines.splice(
129
+ handlerIndexAfterImport,
130
+ 0,
131
+ "",
132
+ "void startLiveQueryListener()"
133
+ );
134
+ }
135
+ fs.writeFileSync(graphqlHandlerPath, contentLines.join("\n"));
136
+ return {
137
+ skipped: false
138
+ };
139
+ };
140
+ const handler = async ({ force }) => {
141
+ const projectIsTypescript = isTypeScriptProject();
142
+ const apiPackageJson = getApiPackageJson();
143
+ const migrationsPath = await getMigrationsPath(getPaths().api.prismaConfig);
144
+ const hasRealtimeDependency = hasPackage(apiPackageJson, "@cedarjs/realtime");
145
+ const hasPgDependency = hasPackage(apiPackageJson, "pg");
146
+ const ext = projectIsTypescript ? "ts" : "js";
147
+ const migrationTemplatePath = path.resolve(
148
+ import.meta.dirname,
149
+ "templates",
150
+ "migration.sql.template"
151
+ );
152
+ const listenerTemplatePath = path.resolve(
153
+ import.meta.dirname,
154
+ "templates",
155
+ "liveQueriesListener.ts.template"
156
+ );
157
+ const existingMigrationPath = findExistingLiveQueryMigration({
158
+ migrationsDirectoryPath: migrationsPath
159
+ });
160
+ const migrationDirPath = path.join(
161
+ migrationsPath,
162
+ generateMigrationFolderName()
163
+ );
164
+ const migrationPath = path.join(migrationDirPath, "migration.sql");
165
+ const listenerPath = path.join(
166
+ getPaths().api.lib,
167
+ `liveQueriesListener.${ext}`
168
+ );
169
+ const tasks = new Listr(
170
+ [
171
+ {
172
+ title: "Checking for @cedarjs/realtime in api workspace...",
173
+ task: () => {
174
+ if (!hasRealtimeDependency) {
175
+ throw new Error(
176
+ `@cedarjs/realtime is not installed in your api workspace. Please run ${c.highlight("yarn cedar setup realtime")} first.`
177
+ );
178
+ }
179
+ }
180
+ },
181
+ {
182
+ title: "Checking that your database provider is PostgreSQL...",
183
+ task: async () => {
184
+ const prismaProvider = await getPrismaProvider();
185
+ const unsupportedProviders = /* @__PURE__ */ new Set([
186
+ "sqlite",
187
+ "mysql",
188
+ "mongodb",
189
+ "sqlserver",
190
+ "cockroachdb"
191
+ ]);
192
+ if (prismaProvider && unsupportedProviders.has(prismaProvider)) {
193
+ throw new Error(
194
+ `Only PostgreSQL is supported for now (found provider "${prismaProvider}").`
195
+ );
196
+ }
197
+ }
198
+ },
199
+ {
200
+ ...addApiPackages(["pg@^8.18.0"]),
201
+ title: "Adding pg dependency to your api side...",
202
+ skip: () => {
203
+ if (hasPgDependency) {
204
+ return "pg is already installed";
205
+ }
206
+ }
207
+ },
208
+ {
209
+ title: "Adding live query notification migration...",
210
+ task: () => {
211
+ const migrationTemplate = fs.readFileSync(
212
+ migrationTemplatePath,
213
+ "utf-8"
214
+ );
215
+ const targetPath = force && existingMigrationPath ? existingMigrationPath : migrationPath;
216
+ writeFile(targetPath, migrationTemplate, {
217
+ overwriteExisting: force
218
+ });
219
+ },
220
+ skip: () => {
221
+ if (existingMigrationPath && !force) {
222
+ const migrationPath2 = path.relative(
223
+ getPaths().base,
224
+ existingMigrationPath
225
+ );
226
+ return `Existing live query migration found: ${migrationPath2}`;
227
+ }
228
+ }
229
+ },
230
+ {
231
+ title: `Adding api/src/lib/liveQueriesListener.${ext}...`,
232
+ task: async () => {
233
+ const listenerTemplate = fs.readFileSync(
234
+ listenerTemplatePath,
235
+ "utf-8"
236
+ );
237
+ const listenerContent = projectIsTypescript ? listenerTemplate : await transformTSToJS(listenerPath, listenerTemplate);
238
+ writeFile(listenerPath, listenerContent, {
239
+ overwriteExisting: force
240
+ });
241
+ }
242
+ },
243
+ {
244
+ title: "Wiring listener startup into GraphQL handler...",
245
+ task: (_ctx, task) => {
246
+ const result = addLiveQueryListenerToGraphqlHandler({ force });
247
+ if (result.skipped) {
248
+ task.skip(result.reason);
249
+ }
250
+ }
251
+ },
252
+ {
253
+ title: "One more thing...",
254
+ task: (_ctx, task) => {
255
+ task.title = `One more thing...
256
+
257
+ ${c.success("\nLive query notifications configured!\n")}
258
+
259
+ Apply the migration to activate Postgres notifications:
260
+ ${c.highlight("\n\xA0\xA0yarn cedar prisma migrate dev\n")}
261
+
262
+ Then run the API server and use @live queries with invalidation keys
263
+ based on your GraphQL types and fields.
264
+ `;
265
+ }
266
+ }
267
+ ],
268
+ {
269
+ rendererOptions: { collapseSubtasks: false }
270
+ }
271
+ );
272
+ try {
273
+ await tasks.run();
274
+ } catch (e) {
275
+ errorTelemetry(process.argv, e.message);
276
+ console.error(c.error(e.message));
277
+ process.exit(e?.exitCode || 1);
278
+ }
279
+ };
280
+ export {
281
+ handler
282
+ };
@@ -0,0 +1,137 @@
1
+ import { Client } from 'pg'
2
+
3
+ import { liveQueryStore } from '@cedarjs/realtime'
4
+
5
+ import { logger } from 'src/lib/logger'
6
+
7
+ const LIVE_QUERY_CHANNEL = 'table_change'
8
+ const RECONNECT_DELAY_MS = 5000
9
+
10
+ let client: Client | undefined
11
+ let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
12
+ let started = false
13
+ let connectionGeneration = 0
14
+
15
+ interface GetKeysToInvalidateArgs {
16
+ table: string
17
+ recordId: string
18
+ }
19
+
20
+ function getKeysToInvalidate({ table, recordId }: GetKeysToInvalidateArgs) {
21
+ const keys = [
22
+ `Query.${table}`,
23
+ `Query.${table.toLocaleLowerCase()}`,
24
+ `${table}:${recordId}`
25
+ ]
26
+
27
+ return keys
28
+ }
29
+
30
+ function reconnect(generation: number) {
31
+ if (reconnectTimeout) {
32
+ return
33
+ }
34
+
35
+ reconnectTimeout = setTimeout(async () => {
36
+ reconnectTimeout = undefined
37
+
38
+ if (generation !== connectionGeneration) {
39
+ return
40
+ }
41
+
42
+ await connect()
43
+ }, RECONNECT_DELAY_MS)
44
+ }
45
+
46
+ interface NotificationPayload {
47
+ table: string
48
+ operation: string
49
+ recordId: string
50
+ }
51
+
52
+ async function onNotification(payload: string | undefined) {
53
+ if (!payload) {
54
+ return
55
+ }
56
+
57
+ try {
58
+ const parsed: NotificationPayload = JSON.parse(payload)
59
+ const keys = getKeysToInvalidate(parsed)
60
+
61
+ await liveQueryStore?.invalidate(keys)
62
+
63
+ logger.debug(
64
+ { operation: parsed.operation, table: parsed.table, keys },
65
+ 'Invalidated live query keys from Postgres notification'
66
+ )
67
+ } catch (error) {
68
+ logger.error(
69
+ { error, payload },
70
+ 'Failed to process Postgres notification payload'
71
+ )
72
+ }
73
+ }
74
+
75
+ async function connect() {
76
+ connectionGeneration = connectionGeneration + 1
77
+ const generation = connectionGeneration
78
+
79
+ try {
80
+ if (reconnectTimeout) {
81
+ clearTimeout(reconnectTimeout)
82
+ reconnectTimeout = undefined
83
+ }
84
+
85
+ if (client) {
86
+ const previousClient = client
87
+ client = undefined
88
+ previousClient.removeAllListeners()
89
+ await previousClient.end().catch(() => {})
90
+ }
91
+
92
+ const nextClient = new Client({
93
+ connectionString: process.env.DATABASE_URL,
94
+ })
95
+
96
+ nextClient.on('notification', async (msg) => {
97
+ await onNotification(msg.payload)
98
+ })
99
+
100
+ nextClient.on('error', (error) => {
101
+ logger.error(
102
+ { error },
103
+ 'Postgres live query listener encountered an error'
104
+ )
105
+ reconnect(generation)
106
+ })
107
+
108
+ nextClient.on('end', () => {
109
+ logger.warn('Postgres live query listener disconnected')
110
+ reconnect(generation)
111
+ })
112
+
113
+ await nextClient.connect()
114
+ await nextClient.query(`LISTEN ${LIVE_QUERY_CHANNEL}`)
115
+
116
+ if (generation !== connectionGeneration) {
117
+ nextClient.removeAllListeners()
118
+ await nextClient.end().catch(() => {})
119
+ return
120
+ }
121
+
122
+ client = nextClient
123
+ logger.info('Postgres live query listener connected')
124
+ } catch (error) {
125
+ logger.error({ error }, 'Failed to connect Postgres live query listener')
126
+ reconnect(generation)
127
+ }
128
+ }
129
+
130
+ export async function startLiveQueryListener() {
131
+ if (started) {
132
+ return
133
+ }
134
+
135
+ started = true
136
+ await connect()
137
+ }
@@ -0,0 +1,98 @@
1
+ CREATE OR REPLACE FUNCTION cedar_notify_table_change() RETURNS TRIGGER AS $$
2
+ DECLARE
3
+ record_id text;
4
+ BEGIN
5
+ IF (TG_OP = 'DELETE') THEN
6
+ record_id := OLD.id::text;
7
+ ELSE
8
+ record_id := NEW.id::text;
9
+ END IF;
10
+
11
+ PERFORM pg_notify(
12
+ 'table_change',
13
+ json_build_object(
14
+ 'table', TG_TABLE_NAME,
15
+ 'operation', TG_OP,
16
+ 'recordId', record_id
17
+ )::text
18
+ );
19
+
20
+ RETURN NULL;
21
+ END;
22
+ $$ LANGUAGE plpgsql;
23
+
24
+ CREATE OR REPLACE FUNCTION cedar_attach_notify_triggers() RETURNS void AS $$
25
+ DECLARE
26
+ table_record record;
27
+ BEGIN
28
+ FOR table_record IN
29
+ SELECT table_schema, table_name
30
+ FROM information_schema.tables
31
+ WHERE table_type = 'BASE TABLE'
32
+ AND table_schema NOT IN ('pg_catalog', 'information_schema')
33
+ AND table_name <> '_prisma_migrations'
34
+ LOOP
35
+ EXECUTE format(
36
+ 'DROP TRIGGER IF EXISTS cedar_notify_change_trigger ON %I.%I',
37
+ table_record.table_schema,
38
+ table_record.table_name
39
+ );
40
+
41
+ EXECUTE format(
42
+ 'CREATE TRIGGER cedar_notify_change_trigger
43
+ AFTER INSERT OR UPDATE OR DELETE ON %I.%I
44
+ FOR EACH ROW EXECUTE FUNCTION cedar_notify_table_change()',
45
+ table_record.table_schema,
46
+ table_record.table_name
47
+ );
48
+ END LOOP;
49
+ END;
50
+ $$ LANGUAGE plpgsql;
51
+
52
+ SELECT cedar_attach_notify_triggers();
53
+
54
+ CREATE OR REPLACE FUNCTION cedar_event_on_table_create() RETURNS event_trigger AS $$
55
+ DECLARE
56
+ cmd record;
57
+ schema_name text;
58
+ table_name text;
59
+ BEGIN
60
+ FOR cmd IN SELECT * FROM pg_event_trigger_ddl_commands() LOOP
61
+ IF cmd.object_type = 'table' THEN
62
+ schema_name := cmd.schema_name;
63
+ table_name := regexp_replace(cmd.object_identity, '^.*\\.', '');
64
+
65
+ IF schema_name IS NULL THEN
66
+ CONTINUE;
67
+ END IF;
68
+
69
+ IF schema_name IN ('pg_catalog', 'information_schema') THEN
70
+ CONTINUE;
71
+ END IF;
72
+
73
+ IF table_name = '_prisma_migrations' THEN
74
+ CONTINUE;
75
+ END IF;
76
+
77
+ EXECUTE format(
78
+ 'DROP TRIGGER IF EXISTS cedar_notify_change_trigger ON %I.%I',
79
+ schema_name,
80
+ table_name
81
+ );
82
+
83
+ EXECUTE format(
84
+ 'CREATE TRIGGER cedar_notify_change_trigger
85
+ AFTER INSERT OR UPDATE OR DELETE ON %I.%I
86
+ FOR EACH ROW EXECUTE FUNCTION cedar_notify_table_change()',
87
+ schema_name,
88
+ table_name
89
+ );
90
+ END IF;
91
+ END LOOP;
92
+ END;
93
+ $$ LANGUAGE plpgsql;
94
+
95
+ CREATE EVENT TRIGGER cedar_on_create_table
96
+ ON ddl_command_end
97
+ WHEN TAG IN ('CREATE TABLE', 'CREATE TABLE AS')
98
+ EXECUTE FUNCTION cedar_event_on_table_create();
@@ -8,6 +8,7 @@ import * as setupGenerator from "./setup/generator/generator.js";
8
8
  import * as setupGraphql from "./setup/graphql/graphql.js";
9
9
  import * as setupI18n from "./setup/i18n/i18n.js";
10
10
  import * as setupJobs from "./setup/jobs/jobs.js";
11
+ import * as setupLiveQueries from "./setup/live-queries/liveQueries.js";
11
12
  import * as setupMailer from "./setup/mailer/mailer.js";
12
13
  import * as setupMiddleware from "./setup/middleware/middleware.js";
13
14
  import * as setupMonitoring from "./setup/monitoring/monitoring.js";
@@ -20,7 +21,7 @@ import * as setupUploads from "./setup/uploads/uploads.js";
20
21
  import * as setupVite from "./setup/vite/vite.js";
21
22
  const command = "setup <command>";
22
23
  const description = "Initialize project config and install packages";
23
- const builder = (yargs) => yargs.command(setupAuth).command(setupCache).command(setupDeploy).command(setupDocker).command(setupGenerator).command(setupGraphql).command(setupI18n).command(setupJobs).command(setupMailer).command(setupMiddleware).command(setupMonitoring).command(setupPackage).command(setupRealtime).command(setupServerFile).command(setupTsconfig).command(setupUi).command(setupUploads).command(setupVite).demandCommand().middleware(detectRxVersion).epilogue(
24
+ const builder = (yargs) => yargs.command(setupAuth).command(setupCache).command(setupDeploy).command(setupDocker).command(setupGenerator).command(setupGraphql).command(setupI18n).command(setupJobs).command(setupLiveQueries).command(setupMailer).command(setupMiddleware).command(setupMonitoring).command(setupPackage).command(setupRealtime).command(setupServerFile).command(setupTsconfig).command(setupUi).command(setupUploads).command(setupVite).demandCommand().middleware(detectRxVersion).epilogue(
24
25
  `Also see the ${terminalLink(
25
26
  "CedarJS CLI Reference",
26
27
  "https://cedarjs.com/docs/cli-commands#setup"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cedarjs/cli",
3
- "version": "3.0.0-canary.13429+ab1a723d3",
3
+ "version": "3.0.0-canary.13430+f819694cc",
4
4
  "description": "The CedarJS Command Line",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,15 +34,15 @@
34
34
  "@babel/parser": "7.29.0",
35
35
  "@babel/preset-typescript": "7.28.5",
36
36
  "@babel/runtime-corejs3": "7.29.0",
37
- "@cedarjs/api-server": "3.0.0-canary.13429",
38
- "@cedarjs/cli-helpers": "3.0.0-canary.13429",
39
- "@cedarjs/fastify-web": "3.0.0-canary.13429",
40
- "@cedarjs/internal": "3.0.0-canary.13429",
41
- "@cedarjs/prerender": "3.0.0-canary.13429",
42
- "@cedarjs/project-config": "3.0.0-canary.13429",
43
- "@cedarjs/structure": "3.0.0-canary.13429",
44
- "@cedarjs/telemetry": "3.0.0-canary.13429",
45
- "@cedarjs/web-server": "3.0.0-canary.13429",
37
+ "@cedarjs/api-server": "3.0.0-canary.13430",
38
+ "@cedarjs/cli-helpers": "3.0.0-canary.13430",
39
+ "@cedarjs/fastify-web": "3.0.0-canary.13430",
40
+ "@cedarjs/internal": "3.0.0-canary.13430",
41
+ "@cedarjs/prerender": "3.0.0-canary.13430",
42
+ "@cedarjs/project-config": "3.0.0-canary.13430",
43
+ "@cedarjs/structure": "3.0.0-canary.13430",
44
+ "@cedarjs/telemetry": "3.0.0-canary.13430",
45
+ "@cedarjs/web-server": "3.0.0-canary.13430",
46
46
  "@listr2/prompt-adapter-enquirer": "2.0.16",
47
47
  "@opentelemetry/api": "1.9.0",
48
48
  "@opentelemetry/core": "1.30.1",
@@ -106,5 +106,5 @@
106
106
  "publishConfig": {
107
107
  "access": "public"
108
108
  },
109
- "gitHead": "ab1a723d3487483219e736867b1fd7fdaec7b960"
109
+ "gitHead": "f819694cc2036c57e405c37e92ee1fc578521573"
110
110
  }