@positronic/cloudflare 0.0.2 → 0.0.4
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/dist/src/api.js +1270 -0
- package/dist/src/brain-runner-do.js +654 -0
- package/dist/src/dev-server.js +1357 -0
- package/{src/index.ts → dist/src/index.js} +1 -6
- package/dist/src/manifest.js +278 -0
- package/dist/src/monitor-do.js +408 -0
- package/{src/node-index.ts → dist/src/node-index.js} +3 -7
- package/dist/src/r2-loader.js +207 -0
- package/dist/src/schedule-do.js +705 -0
- package/dist/src/sqlite-adapter.js +69 -0
- package/dist/types/api.d.ts +21 -0
- package/dist/types/api.d.ts.map +1 -0
- package/dist/types/brain-runner-do.d.ts +25 -0
- package/dist/types/brain-runner-do.d.ts.map +1 -0
- package/dist/types/dev-server.d.ts +45 -0
- package/dist/types/dev-server.d.ts.map +1 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/manifest.d.ts +11 -0
- package/dist/types/manifest.d.ts.map +1 -0
- package/dist/types/monitor-do.d.ts +16 -0
- package/dist/types/monitor-do.d.ts.map +1 -0
- package/dist/types/node-index.d.ts +10 -0
- package/dist/types/node-index.d.ts.map +1 -0
- package/dist/types/r2-loader.d.ts +10 -0
- package/dist/types/r2-loader.d.ts.map +1 -0
- package/dist/types/schedule-do.d.ts +47 -0
- package/dist/types/schedule-do.d.ts.map +1 -0
- package/dist/types/sqlite-adapter.d.ts +10 -0
- package/dist/types/sqlite-adapter.d.ts.map +1 -0
- package/package.json +8 -4
- package/src/api.ts +0 -579
- package/src/brain-runner-do.ts +0 -309
- package/src/dev-server.ts +0 -776
- package/src/manifest.ts +0 -69
- package/src/monitor-do.ts +0 -268
- package/src/r2-loader.ts +0 -27
- package/src/schedule-do.ts +0 -377
- package/src/sqlite-adapter.ts +0 -50
- package/test-project/package-lock.json +0 -3010
- package/test-project/package.json +0 -21
- package/test-project/src/index.ts +0 -70
- package/test-project/src/runner.ts +0 -24
- package/test-project/tests/api.test.ts +0 -1005
- package/test-project/tests/r2loader.test.ts +0 -73
- package/test-project/tests/resources-api.test.ts +0 -671
- package/test-project/tests/spec.test.ts +0 -135
- package/test-project/tests/tsconfig.json +0 -7
- package/test-project/tsconfig.json +0 -20
- package/test-project/vitest.config.ts +0 -12
- package/test-project/wrangler.jsonc +0 -53
- package/tsconfig.json +0 -11
package/src/schedule-do.ts
DELETED
|
@@ -1,377 +0,0 @@
|
|
|
1
|
-
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
-
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
-
import { parseCronExpression, type Cron } from 'cron-schedule';
|
|
4
|
-
import { BRAIN_EVENTS, type BrainEvent } from '@positronic/core';
|
|
5
|
-
import type { BrainRunnerDO } from './brain-runner-do.js';
|
|
6
|
-
|
|
7
|
-
export interface Env {
|
|
8
|
-
BRAIN_RUNNER_DO: DurableObjectNamespace<BrainRunnerDO>;
|
|
9
|
-
IS_TEST?: string;
|
|
10
|
-
NODE_ENV?: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
interface Schedule {
|
|
14
|
-
id: string;
|
|
15
|
-
brainName: string;
|
|
16
|
-
cronExpression: string;
|
|
17
|
-
enabled: boolean;
|
|
18
|
-
createdAt: number;
|
|
19
|
-
nextRunAt?: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface ScheduledRun {
|
|
23
|
-
id: number;
|
|
24
|
-
scheduleId: string;
|
|
25
|
-
brainRunId?: string;
|
|
26
|
-
status: 'triggered' | 'failed' | 'complete';
|
|
27
|
-
ranAt: number;
|
|
28
|
-
completedAt?: number;
|
|
29
|
-
error?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const ALARM_INTERVAL = 60 * 1000;
|
|
33
|
-
|
|
34
|
-
export class ScheduleDO extends DurableObject<Env> {
|
|
35
|
-
private readonly storage: SqlStorage;
|
|
36
|
-
|
|
37
|
-
constructor(state: DurableObjectState, env: Env) {
|
|
38
|
-
super(state, env);
|
|
39
|
-
this.storage = state.storage.sql;
|
|
40
|
-
|
|
41
|
-
// Initialize database schema
|
|
42
|
-
this.storage.exec(`
|
|
43
|
-
CREATE TABLE IF NOT EXISTS schedules (
|
|
44
|
-
id TEXT PRIMARY KEY,
|
|
45
|
-
brain_name TEXT NOT NULL,
|
|
46
|
-
cron_expression TEXT NOT NULL,
|
|
47
|
-
enabled INTEGER NOT NULL DEFAULT 1,
|
|
48
|
-
created_at INTEGER NOT NULL,
|
|
49
|
-
next_run_at INTEGER
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
CREATE INDEX IF NOT EXISTS idx_schedules_brain
|
|
53
|
-
ON schedules(brain_name);
|
|
54
|
-
|
|
55
|
-
CREATE INDEX IF NOT EXISTS idx_schedules_enabled
|
|
56
|
-
ON schedules(enabled);
|
|
57
|
-
|
|
58
|
-
CREATE TABLE IF NOT EXISTS scheduled_runs (
|
|
59
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
-
schedule_id TEXT NOT NULL,
|
|
61
|
-
brain_run_id TEXT UNIQUE,
|
|
62
|
-
status TEXT NOT NULL CHECK(status IN ('triggered', 'failed', 'complete')),
|
|
63
|
-
ran_at INTEGER NOT NULL,
|
|
64
|
-
completed_at INTEGER,
|
|
65
|
-
error TEXT,
|
|
66
|
-
FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE CASCADE
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
CREATE INDEX IF NOT EXISTS idx_runs_schedule
|
|
70
|
-
ON scheduled_runs(schedule_id, ran_at DESC);
|
|
71
|
-
`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async createSchedule(
|
|
75
|
-
brainName: string,
|
|
76
|
-
cronExpression: string
|
|
77
|
-
): Promise<Schedule> {
|
|
78
|
-
const id = uuidv4();
|
|
79
|
-
const createdAt = Date.now();
|
|
80
|
-
if (this.env.IS_TEST !== 'true') {
|
|
81
|
-
const alarm = await this.ctx.storage.getAlarm();
|
|
82
|
-
if (!alarm) {
|
|
83
|
-
await this.ctx.storage.setAlarm(Date.now() + ALARM_INTERVAL);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
// Note: Cron expression is validated at the API level before calling this method
|
|
87
|
-
// Calculate next run time
|
|
88
|
-
const cron = parseCronExpression(cronExpression);
|
|
89
|
-
const nextRunAt = this.calculateNextRunTime(cron, createdAt);
|
|
90
|
-
|
|
91
|
-
this.storage.exec(
|
|
92
|
-
`INSERT INTO schedules (id, brain_name, cron_expression, enabled, created_at, next_run_at)
|
|
93
|
-
VALUES (?, ?, ?, 1, ?, ?)`,
|
|
94
|
-
id,
|
|
95
|
-
brainName,
|
|
96
|
-
cronExpression,
|
|
97
|
-
createdAt,
|
|
98
|
-
nextRunAt
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
id,
|
|
103
|
-
brainName,
|
|
104
|
-
cronExpression,
|
|
105
|
-
enabled: true,
|
|
106
|
-
createdAt,
|
|
107
|
-
nextRunAt,
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async getSchedule(scheduleId: string): Promise<Schedule | null> {
|
|
112
|
-
const results = this.storage
|
|
113
|
-
.exec(
|
|
114
|
-
`SELECT id, brain_name, cron_expression, enabled, created_at, next_run_at
|
|
115
|
-
FROM schedules WHERE id = ?`,
|
|
116
|
-
scheduleId
|
|
117
|
-
)
|
|
118
|
-
.toArray();
|
|
119
|
-
|
|
120
|
-
if (results.length === 0) {
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const result = results[0];
|
|
125
|
-
|
|
126
|
-
return {
|
|
127
|
-
id: result.id as string,
|
|
128
|
-
brainName: result.brain_name as string,
|
|
129
|
-
cronExpression: result.cron_expression as string,
|
|
130
|
-
enabled: result.enabled === 1,
|
|
131
|
-
createdAt: result.created_at as number,
|
|
132
|
-
nextRunAt: result.next_run_at as number | undefined,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async deleteSchedule(scheduleId: string): Promise<boolean> {
|
|
137
|
-
// Check if schedule exists first
|
|
138
|
-
const existing = await this.getSchedule(scheduleId);
|
|
139
|
-
if (!existing) {
|
|
140
|
-
return false;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
this.storage.exec(`DELETE FROM schedules WHERE id = ?`, scheduleId);
|
|
144
|
-
|
|
145
|
-
return true;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
async listSchedules(): Promise<{ schedules: Schedule[]; count: number }> {
|
|
149
|
-
if (this.env.NODE_ENV === 'development') {
|
|
150
|
-
console.log('[ScheduleDO] Checking alarm');
|
|
151
|
-
const alarm = await this.ctx.storage.getAlarm();
|
|
152
|
-
if (alarm) {
|
|
153
|
-
console.log('[ScheduleDO] Deleting alarm');
|
|
154
|
-
await this.ctx.storage.deleteAlarm();
|
|
155
|
-
console.log('[ScheduleDO] Running alarm handler');
|
|
156
|
-
await this.alarm();
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const schedules = this.storage
|
|
161
|
-
.exec(
|
|
162
|
-
`SELECT id, brain_name, cron_expression, enabled, created_at, next_run_at
|
|
163
|
-
FROM schedules
|
|
164
|
-
ORDER BY created_at DESC`
|
|
165
|
-
)
|
|
166
|
-
.toArray()
|
|
167
|
-
.map((row) => ({
|
|
168
|
-
id: row.id as string,
|
|
169
|
-
brainName: row.brain_name as string,
|
|
170
|
-
cronExpression: row.cron_expression as string,
|
|
171
|
-
enabled: row.enabled === 1,
|
|
172
|
-
createdAt: row.created_at as number,
|
|
173
|
-
nextRunAt: row.next_run_at as number | undefined,
|
|
174
|
-
}));
|
|
175
|
-
|
|
176
|
-
return {
|
|
177
|
-
schedules,
|
|
178
|
-
count: schedules.length,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async getAllRuns(
|
|
183
|
-
scheduleId?: string,
|
|
184
|
-
limit: number = 100
|
|
185
|
-
): Promise<{ runs: ScheduledRun[]; count: number }> {
|
|
186
|
-
let query = `
|
|
187
|
-
SELECT id, schedule_id, brain_run_id, status, ran_at, completed_at, error
|
|
188
|
-
FROM scheduled_runs
|
|
189
|
-
`;
|
|
190
|
-
const params: any[] = [];
|
|
191
|
-
|
|
192
|
-
if (scheduleId) {
|
|
193
|
-
query += ` WHERE schedule_id = ?`;
|
|
194
|
-
params.push(scheduleId);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
query += ` ORDER BY ran_at DESC LIMIT ?`;
|
|
198
|
-
params.push(limit);
|
|
199
|
-
|
|
200
|
-
const runs = this.storage
|
|
201
|
-
.exec(query, ...params)
|
|
202
|
-
.toArray()
|
|
203
|
-
.map((row) => ({
|
|
204
|
-
id: row.id as number,
|
|
205
|
-
scheduleId: row.schedule_id as string,
|
|
206
|
-
brainRunId: row.brain_run_id as string | undefined,
|
|
207
|
-
status: row.status as 'triggered' | 'failed' | 'complete',
|
|
208
|
-
ranAt: row.ran_at as number,
|
|
209
|
-
completedAt: row.completed_at as number | undefined,
|
|
210
|
-
error: row.error as string | undefined,
|
|
211
|
-
}));
|
|
212
|
-
|
|
213
|
-
// Get total count
|
|
214
|
-
let countQuery = `SELECT COUNT(*) as count FROM scheduled_runs`;
|
|
215
|
-
const countParams: any[] = [];
|
|
216
|
-
|
|
217
|
-
if (scheduleId) {
|
|
218
|
-
countQuery += ` WHERE schedule_id = ?`;
|
|
219
|
-
countParams.push(scheduleId);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const countResult = this.storage.exec(countQuery, ...countParams).one();
|
|
223
|
-
const count = (countResult?.count as number) || 0;
|
|
224
|
-
|
|
225
|
-
return { runs, count };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Handle the alarm trigger - runs every minute in a perpetual cycle
|
|
229
|
-
async alarm(): Promise<void> {
|
|
230
|
-
try {
|
|
231
|
-
// This alarm runs every minute to check for schedules that need to be executed.
|
|
232
|
-
// Since cron expressions have minute-level granularity at most (e.g., * * * * *),
|
|
233
|
-
// checking every minute ensures we never miss a scheduled run.
|
|
234
|
-
|
|
235
|
-
// Get all enabled schedules that are due
|
|
236
|
-
const now = Date.now();
|
|
237
|
-
|
|
238
|
-
const dueSchedules = this.storage
|
|
239
|
-
.exec(
|
|
240
|
-
`SELECT id, brain_name, cron_expression
|
|
241
|
-
FROM schedules
|
|
242
|
-
WHERE enabled = 1 AND next_run_at <= ?`,
|
|
243
|
-
now
|
|
244
|
-
)
|
|
245
|
-
.toArray();
|
|
246
|
-
|
|
247
|
-
// Process each due schedule
|
|
248
|
-
for (const schedule of dueSchedules) {
|
|
249
|
-
const scheduleId = schedule.id as string;
|
|
250
|
-
const brainName = schedule.brain_name as string;
|
|
251
|
-
const cronExpression = schedule.cron_expression as string;
|
|
252
|
-
|
|
253
|
-
try {
|
|
254
|
-
// Trigger the brain run
|
|
255
|
-
const brainRunId = await this.triggerBrainRun(brainName);
|
|
256
|
-
|
|
257
|
-
// Record successful run
|
|
258
|
-
this.storage.exec(
|
|
259
|
-
`INSERT INTO scheduled_runs (schedule_id, brain_run_id, status, ran_at)
|
|
260
|
-
VALUES (?, ?, 'triggered', ?)`,
|
|
261
|
-
scheduleId,
|
|
262
|
-
brainRunId,
|
|
263
|
-
now
|
|
264
|
-
);
|
|
265
|
-
} catch (error) {
|
|
266
|
-
// Record failed run
|
|
267
|
-
const errorMessage =
|
|
268
|
-
error instanceof Error ? error.message : 'Unknown error';
|
|
269
|
-
this.storage.exec(
|
|
270
|
-
`INSERT INTO scheduled_runs (schedule_id, status, ran_at, error)
|
|
271
|
-
VALUES (?, 'failed', ?, ?)`,
|
|
272
|
-
scheduleId,
|
|
273
|
-
now,
|
|
274
|
-
errorMessage
|
|
275
|
-
);
|
|
276
|
-
|
|
277
|
-
console.error(
|
|
278
|
-
`[ScheduleDO] Failed to trigger brain ${brainName}:`,
|
|
279
|
-
error
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Calculate and update next run time
|
|
284
|
-
const cron = parseCronExpression(cronExpression);
|
|
285
|
-
const nextRunAt = this.calculateNextRunTime(cron, now);
|
|
286
|
-
|
|
287
|
-
this.storage.exec(
|
|
288
|
-
`UPDATE schedules SET next_run_at = ? WHERE id = ?`,
|
|
289
|
-
nextRunAt,
|
|
290
|
-
scheduleId
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
} finally {
|
|
294
|
-
// Always schedule the next alarm for 1 minute from now
|
|
295
|
-
// This creates a perpetual cycle that checks for due schedules every minute
|
|
296
|
-
// The finally block ensures this happens even if there's an error above
|
|
297
|
-
// Skip in test environment to avoid isolated storage issues
|
|
298
|
-
if (this.env.IS_TEST !== 'true') {
|
|
299
|
-
await this.ctx.storage.setAlarm(Date.now() + ALARM_INTERVAL);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
private async triggerBrainRun(brainName: string): Promise<string> {
|
|
305
|
-
const brainRunId = uuidv4();
|
|
306
|
-
const namespace = this.env.BRAIN_RUNNER_DO;
|
|
307
|
-
const doId = namespace.idFromName(brainRunId);
|
|
308
|
-
const stub = namespace.get(doId);
|
|
309
|
-
console.log(
|
|
310
|
-
`[ScheduleDO] Triggering brain run ${brainName} with id ${brainRunId}`
|
|
311
|
-
);
|
|
312
|
-
await stub.start(brainName, brainRunId);
|
|
313
|
-
|
|
314
|
-
return brainRunId;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Called by ScheduleAdapter when brain events occur
|
|
318
|
-
async handleBrainEvent(event: BrainEvent<any>): Promise<void> {
|
|
319
|
-
// We only care about completion events for scheduled runs
|
|
320
|
-
if (
|
|
321
|
-
event.type !== BRAIN_EVENTS.COMPLETE &&
|
|
322
|
-
event.type !== BRAIN_EVENTS.ERROR
|
|
323
|
-
) {
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Check if this brain run was triggered by a schedule
|
|
328
|
-
const result = this.storage
|
|
329
|
-
.exec(
|
|
330
|
-
`SELECT id FROM scheduled_runs WHERE brain_run_id = ?`,
|
|
331
|
-
event.brainRunId
|
|
332
|
-
)
|
|
333
|
-
.toArray();
|
|
334
|
-
|
|
335
|
-
if (result.length === 0) {
|
|
336
|
-
// This brain run wasn't triggered by a schedule, ignore it
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const scheduledRun = result[0];
|
|
341
|
-
|
|
342
|
-
const completedAt = Date.now();
|
|
343
|
-
const status = event.type === BRAIN_EVENTS.COMPLETE ? 'complete' : 'failed';
|
|
344
|
-
const error =
|
|
345
|
-
event.type === BRAIN_EVENTS.ERROR
|
|
346
|
-
? event.error
|
|
347
|
-
? JSON.stringify(event.error)
|
|
348
|
-
: 'Unknown error'
|
|
349
|
-
: null;
|
|
350
|
-
|
|
351
|
-
// Update the scheduled run record
|
|
352
|
-
this.storage.exec(
|
|
353
|
-
`UPDATE scheduled_runs
|
|
354
|
-
SET status = ?, completed_at = ?, error = ?
|
|
355
|
-
WHERE brain_run_id = ?`,
|
|
356
|
-
status,
|
|
357
|
-
completedAt,
|
|
358
|
-
error,
|
|
359
|
-
event.brainRunId
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
private isValidCronExpression(expression: string): boolean {
|
|
364
|
-
try {
|
|
365
|
-
// Try to parse the expression - if it throws, it's invalid
|
|
366
|
-
parseCronExpression(expression);
|
|
367
|
-
return true;
|
|
368
|
-
} catch (error) {
|
|
369
|
-
return false;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
private calculateNextRunTime(cron: Cron, afterTime: number): number {
|
|
374
|
-
const nextDate = cron.getNextDate(new Date(afterTime));
|
|
375
|
-
return nextDate.getTime();
|
|
376
|
-
}
|
|
377
|
-
}
|
package/src/sqlite-adapter.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import type { Adapter, BrainEvent } from '@positronic/core';
|
|
2
|
-
import type { SqlStorage } from '@cloudflare/workers-types';
|
|
3
|
-
|
|
4
|
-
// Define the new schema with a single events table
|
|
5
|
-
const initSQL = `
|
|
6
|
-
CREATE TABLE IF NOT EXISTS brain_events (
|
|
7
|
-
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
8
|
-
event_type TEXT NOT NULL,
|
|
9
|
-
serialized_event TEXT NOT NULL CHECK(json_valid(serialized_event)),
|
|
10
|
-
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
11
|
-
);
|
|
12
|
-
`;
|
|
13
|
-
|
|
14
|
-
export class BrainRunSQLiteAdapter implements Adapter {
|
|
15
|
-
private sql: SqlStorage;
|
|
16
|
-
private schemaInitialized = false; // Track schema initialization
|
|
17
|
-
|
|
18
|
-
constructor(sql: SqlStorage) {
|
|
19
|
-
this.sql = sql;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
private initializeSchema() {
|
|
23
|
-
if (!this.schemaInitialized) {
|
|
24
|
-
this.sql.exec(initSQL);
|
|
25
|
-
this.schemaInitialized = true;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
public dispatch(event: BrainEvent) {
|
|
30
|
-
try {
|
|
31
|
-
this.initializeSchema();
|
|
32
|
-
|
|
33
|
-
const insertSql = `
|
|
34
|
-
INSERT INTO brain_events (
|
|
35
|
-
event_type,
|
|
36
|
-
serialized_event
|
|
37
|
-
) VALUES (?, ?);`;
|
|
38
|
-
|
|
39
|
-
this.sql.exec(insertSql, event.type, JSON.stringify(event));
|
|
40
|
-
} catch (e) {
|
|
41
|
-
console.error(
|
|
42
|
-
'[SQL_ADAPTER] Error handling brain event:',
|
|
43
|
-
e,
|
|
44
|
-
'Event data:',
|
|
45
|
-
JSON.stringify(event)
|
|
46
|
-
);
|
|
47
|
-
throw e;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|