@pgflow/core 0.1.18 → 0.1.20
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/README.md +31 -19
- package/dist/ATLAS.md +32 -0
- package/dist/CHANGELOG.md +16 -0
- package/dist/README.md +31 -19
- package/dist/database-types.d.ts +116 -45
- package/dist/database-types.d.ts.map +1 -1
- package/dist/database-types.js +8 -1
- package/dist/package.json +2 -2
- package/dist/supabase/migrations/20250429164909_pgflow_initial.sql +579 -0
- package/package.json +3 -3
- package/dist/supabase/migrations/000000_schema.sql +0 -149
- package/dist/supabase/migrations/000005_create_flow.sql +0 -29
- package/dist/supabase/migrations/000010_add_step.sql +0 -48
- package/dist/supabase/migrations/000015_start_ready_steps.sql +0 -45
- package/dist/supabase/migrations/000020_start_flow.sql +0 -46
- package/dist/supabase/migrations/000030_read_with_poll_backport.sql +0 -70
- package/dist/supabase/migrations/000040_poll_for_tasks.sql +0 -100
- package/dist/supabase/migrations/000045_maybe_complete_run.sql +0 -30
- package/dist/supabase/migrations/000050_complete_task.sql +0 -98
- package/dist/supabase/migrations/000055_calculate_retry_delay.sql +0 -11
- package/dist/supabase/migrations/000060_fail_task.sql +0 -124
- package/dist/supabase/migrations/000_edge_worker_initial.sql +0 -86
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
-- Add new schema named "pgflow"
|
|
2
|
+
CREATE SCHEMA "pgflow";
|
|
3
|
+
-- Add new schema named "pgmq"
|
|
4
|
+
CREATE SCHEMA "pgmq";
|
|
5
|
+
-- Create extension "pgmq"
|
|
6
|
+
CREATE EXTENSION "pgmq" WITH SCHEMA "pgmq" VERSION "1.4.4";
|
|
7
|
+
-- Create "read_with_poll" function
|
|
8
|
+
CREATE FUNCTION "pgflow"."read_with_poll" ("queue_name" text, "vt" integer, "qty" integer, "max_poll_seconds" integer DEFAULT 5, "poll_interval_ms" integer DEFAULT 100, "conditional" jsonb DEFAULT '{}') RETURNS SETOF pgmq.message_record LANGUAGE plpgsql AS $$
|
|
9
|
+
DECLARE
|
|
10
|
+
r pgmq.message_record;
|
|
11
|
+
stop_at TIMESTAMP;
|
|
12
|
+
sql TEXT;
|
|
13
|
+
qtable TEXT := pgmq.format_table_name(queue_name, 'q');
|
|
14
|
+
BEGIN
|
|
15
|
+
stop_at := clock_timestamp() + make_interval(secs => max_poll_seconds);
|
|
16
|
+
LOOP
|
|
17
|
+
IF (SELECT clock_timestamp() >= stop_at) THEN
|
|
18
|
+
RETURN;
|
|
19
|
+
END IF;
|
|
20
|
+
|
|
21
|
+
sql := FORMAT(
|
|
22
|
+
$QUERY$
|
|
23
|
+
WITH cte AS
|
|
24
|
+
(
|
|
25
|
+
SELECT msg_id
|
|
26
|
+
FROM pgmq.%I
|
|
27
|
+
WHERE vt <= clock_timestamp() AND CASE
|
|
28
|
+
WHEN %L != '{}'::jsonb THEN (message @> %2$L)::integer
|
|
29
|
+
ELSE 1
|
|
30
|
+
END = 1
|
|
31
|
+
ORDER BY msg_id ASC
|
|
32
|
+
LIMIT $1
|
|
33
|
+
FOR UPDATE SKIP LOCKED
|
|
34
|
+
)
|
|
35
|
+
UPDATE pgmq.%I m
|
|
36
|
+
SET
|
|
37
|
+
vt = clock_timestamp() + %L,
|
|
38
|
+
read_ct = read_ct + 1
|
|
39
|
+
FROM cte
|
|
40
|
+
WHERE m.msg_id = cte.msg_id
|
|
41
|
+
RETURNING m.msg_id, m.read_ct, m.enqueued_at, m.vt, m.message;
|
|
42
|
+
$QUERY$,
|
|
43
|
+
qtable, conditional, qtable, make_interval(secs => vt)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
FOR r IN
|
|
47
|
+
EXECUTE sql USING qty
|
|
48
|
+
LOOP
|
|
49
|
+
RETURN NEXT r;
|
|
50
|
+
END LOOP;
|
|
51
|
+
IF FOUND THEN
|
|
52
|
+
RETURN;
|
|
53
|
+
ELSE
|
|
54
|
+
PERFORM pg_sleep(poll_interval_ms::numeric / 1000);
|
|
55
|
+
END IF;
|
|
56
|
+
END LOOP;
|
|
57
|
+
END;
|
|
58
|
+
$$;
|
|
59
|
+
-- Create composite type "step_task_record"
|
|
60
|
+
CREATE TYPE "pgflow"."step_task_record" AS ("flow_slug" text, "run_id" uuid, "step_slug" text, "input" jsonb, "msg_id" bigint);
|
|
61
|
+
-- Create "is_valid_slug" function
|
|
62
|
+
CREATE FUNCTION "pgflow"."is_valid_slug" ("slug" text) RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
|
|
63
|
+
begin
|
|
64
|
+
return
|
|
65
|
+
slug is not null
|
|
66
|
+
and slug <> ''
|
|
67
|
+
and length(slug) <= 128
|
|
68
|
+
and slug ~ '^[a-zA-Z_][a-zA-Z0-9_]*$'
|
|
69
|
+
and slug NOT IN ('run'); -- reserved words
|
|
70
|
+
end;
|
|
71
|
+
$$;
|
|
72
|
+
-- Create "flows" table
|
|
73
|
+
CREATE TABLE "pgflow"."flows" ("flow_slug" text NOT NULL, "opt_max_attempts" integer NOT NULL DEFAULT 3, "opt_base_delay" integer NOT NULL DEFAULT 1, "opt_timeout" integer NOT NULL DEFAULT 60, "created_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("flow_slug"), CONSTRAINT "opt_base_delay_is_nonnegative" CHECK (opt_base_delay >= 0), CONSTRAINT "opt_max_attempts_is_nonnegative" CHECK (opt_max_attempts >= 0), CONSTRAINT "opt_timeout_is_positive" CHECK (opt_timeout > 0), CONSTRAINT "slug_is_valid" CHECK (pgflow.is_valid_slug(flow_slug)));
|
|
74
|
+
-- Create "steps" table
|
|
75
|
+
CREATE TABLE "pgflow"."steps" ("flow_slug" text NOT NULL, "step_slug" text NOT NULL, "step_type" text NOT NULL DEFAULT 'single', "step_index" integer NOT NULL DEFAULT 0, "deps_count" integer NOT NULL DEFAULT 0, "opt_max_attempts" integer NULL, "opt_base_delay" integer NULL, "opt_timeout" integer NULL, "created_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("flow_slug", "step_slug"), CONSTRAINT "steps_flow_slug_step_index_key" UNIQUE ("flow_slug", "step_index"), CONSTRAINT "steps_flow_slug_fkey" FOREIGN KEY ("flow_slug") REFERENCES "pgflow"."flows" ("flow_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "opt_base_delay_is_nonnegative" CHECK ((opt_base_delay IS NULL) OR (opt_base_delay >= 0)), CONSTRAINT "opt_max_attempts_is_nonnegative" CHECK ((opt_max_attempts IS NULL) OR (opt_max_attempts >= 0)), CONSTRAINT "opt_timeout_is_positive" CHECK ((opt_timeout IS NULL) OR (opt_timeout > 0)), CONSTRAINT "steps_deps_count_check" CHECK (deps_count >= 0), CONSTRAINT "steps_step_slug_check" CHECK (pgflow.is_valid_slug(step_slug)), CONSTRAINT "steps_step_type_check" CHECK (step_type = 'single'::text));
|
|
76
|
+
-- Create "deps" table
|
|
77
|
+
CREATE TABLE "pgflow"."deps" ("flow_slug" text NOT NULL, "dep_slug" text NOT NULL, "step_slug" text NOT NULL, "created_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("flow_slug", "dep_slug", "step_slug"), CONSTRAINT "deps_flow_slug_dep_slug_fkey" FOREIGN KEY ("flow_slug", "dep_slug") REFERENCES "pgflow"."steps" ("flow_slug", "step_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "deps_flow_slug_fkey" FOREIGN KEY ("flow_slug") REFERENCES "pgflow"."flows" ("flow_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "deps_flow_slug_step_slug_fkey" FOREIGN KEY ("flow_slug", "step_slug") REFERENCES "pgflow"."steps" ("flow_slug", "step_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "deps_check" CHECK (dep_slug <> step_slug));
|
|
78
|
+
-- Create index "idx_deps_by_flow_dep" to table: "deps"
|
|
79
|
+
CREATE INDEX "idx_deps_by_flow_dep" ON "pgflow"."deps" ("flow_slug", "dep_slug");
|
|
80
|
+
-- Create index "idx_deps_by_flow_step" to table: "deps"
|
|
81
|
+
CREATE INDEX "idx_deps_by_flow_step" ON "pgflow"."deps" ("flow_slug", "step_slug");
|
|
82
|
+
-- Create "runs" table
|
|
83
|
+
CREATE TABLE "pgflow"."runs" ("run_id" uuid NOT NULL DEFAULT gen_random_uuid(), "flow_slug" text NOT NULL, "status" text NOT NULL DEFAULT 'started', "input" jsonb NOT NULL, "output" jsonb NULL, "remaining_steps" integer NOT NULL DEFAULT 0, "started_at" timestamptz NOT NULL DEFAULT now(), "completed_at" timestamptz NULL, "failed_at" timestamptz NULL, PRIMARY KEY ("run_id"), CONSTRAINT "runs_flow_slug_fkey" FOREIGN KEY ("flow_slug") REFERENCES "pgflow"."flows" ("flow_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "completed_at_is_after_started_at" CHECK ((completed_at IS NULL) OR (completed_at >= started_at)), CONSTRAINT "completed_at_or_failed_at" CHECK (NOT ((completed_at IS NOT NULL) AND (failed_at IS NOT NULL))), CONSTRAINT "failed_at_is_after_started_at" CHECK ((failed_at IS NULL) OR (failed_at >= started_at)), CONSTRAINT "runs_remaining_steps_check" CHECK (remaining_steps >= 0), CONSTRAINT "status_is_valid" CHECK (status = ANY (ARRAY['started'::text, 'failed'::text, 'completed'::text])));
|
|
84
|
+
-- Create index "idx_runs_flow_slug" to table: "runs"
|
|
85
|
+
CREATE INDEX "idx_runs_flow_slug" ON "pgflow"."runs" ("flow_slug");
|
|
86
|
+
-- Create index "idx_runs_status" to table: "runs"
|
|
87
|
+
CREATE INDEX "idx_runs_status" ON "pgflow"."runs" ("status");
|
|
88
|
+
-- Create "step_states" table
|
|
89
|
+
CREATE TABLE "pgflow"."step_states" ("flow_slug" text NOT NULL, "run_id" uuid NOT NULL, "step_slug" text NOT NULL, "status" text NOT NULL DEFAULT 'created', "remaining_tasks" integer NOT NULL DEFAULT 1, "remaining_deps" integer NOT NULL DEFAULT 0, "created_at" timestamptz NOT NULL DEFAULT now(), "started_at" timestamptz NULL, "completed_at" timestamptz NULL, "failed_at" timestamptz NULL, PRIMARY KEY ("run_id", "step_slug"), CONSTRAINT "step_states_flow_slug_fkey" FOREIGN KEY ("flow_slug") REFERENCES "pgflow"."flows" ("flow_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "step_states_flow_slug_step_slug_fkey" FOREIGN KEY ("flow_slug", "step_slug") REFERENCES "pgflow"."steps" ("flow_slug", "step_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "step_states_run_id_fkey" FOREIGN KEY ("run_id") REFERENCES "pgflow"."runs" ("run_id") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "completed_at_is_after_started_at" CHECK ((completed_at IS NULL) OR (completed_at >= started_at)), CONSTRAINT "completed_at_or_failed_at" CHECK (NOT ((completed_at IS NOT NULL) AND (failed_at IS NOT NULL))), CONSTRAINT "failed_at_is_after_started_at" CHECK ((failed_at IS NULL) OR (failed_at >= started_at)), CONSTRAINT "started_at_is_after_created_at" CHECK ((started_at IS NULL) OR (started_at >= created_at)), CONSTRAINT "status_and_remaining_tasks_match" CHECK ((status <> 'completed'::text) OR (remaining_tasks = 0)), CONSTRAINT "status_is_valid" CHECK (status = ANY (ARRAY['created'::text, 'started'::text, 'completed'::text, 'failed'::text])), CONSTRAINT "step_states_remaining_deps_check" CHECK (remaining_deps >= 0), CONSTRAINT "step_states_remaining_tasks_check" CHECK (remaining_tasks >= 0));
|
|
90
|
+
-- Create index "idx_step_states_failed" to table: "step_states"
|
|
91
|
+
CREATE INDEX "idx_step_states_failed" ON "pgflow"."step_states" ("run_id", "step_slug") WHERE (status = 'failed'::text);
|
|
92
|
+
-- Create index "idx_step_states_flow_slug" to table: "step_states"
|
|
93
|
+
CREATE INDEX "idx_step_states_flow_slug" ON "pgflow"."step_states" ("flow_slug");
|
|
94
|
+
-- Create index "idx_step_states_ready" to table: "step_states"
|
|
95
|
+
CREATE INDEX "idx_step_states_ready" ON "pgflow"."step_states" ("run_id", "status", "remaining_deps") WHERE ((status = 'created'::text) AND (remaining_deps = 0));
|
|
96
|
+
-- Create "step_tasks" table
|
|
97
|
+
CREATE TABLE "pgflow"."step_tasks" ("flow_slug" text NOT NULL, "run_id" uuid NOT NULL, "step_slug" text NOT NULL, "message_id" bigint NULL, "task_index" integer NOT NULL DEFAULT 0, "status" text NOT NULL DEFAULT 'queued', "attempts_count" integer NOT NULL DEFAULT 0, "error_message" text NULL, "output" jsonb NULL, "queued_at" timestamptz NOT NULL DEFAULT now(), "completed_at" timestamptz NULL, "failed_at" timestamptz NULL, PRIMARY KEY ("run_id", "step_slug", "task_index"), CONSTRAINT "step_tasks_flow_slug_fkey" FOREIGN KEY ("flow_slug") REFERENCES "pgflow"."flows" ("flow_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "step_tasks_run_id_fkey" FOREIGN KEY ("run_id") REFERENCES "pgflow"."runs" ("run_id") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "step_tasks_run_id_step_slug_fkey" FOREIGN KEY ("run_id", "step_slug") REFERENCES "pgflow"."step_states" ("run_id", "step_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "attempts_count_nonnegative" CHECK (attempts_count >= 0), CONSTRAINT "completed_at_is_after_queued_at" CHECK ((completed_at IS NULL) OR (completed_at >= queued_at)), CONSTRAINT "completed_at_or_failed_at" CHECK (NOT ((completed_at IS NOT NULL) AND (failed_at IS NOT NULL))), CONSTRAINT "failed_at_is_after_queued_at" CHECK ((failed_at IS NULL) OR (failed_at >= queued_at)), CONSTRAINT "only_single_task_per_step" CHECK (task_index = 0), CONSTRAINT "output_valid_only_for_completed" CHECK ((output IS NULL) OR (status = 'completed'::text)), CONSTRAINT "valid_status" CHECK (status = ANY (ARRAY['queued'::text, 'completed'::text, 'failed'::text])));
|
|
98
|
+
-- Create index "idx_step_tasks_completed" to table: "step_tasks"
|
|
99
|
+
CREATE INDEX "idx_step_tasks_completed" ON "pgflow"."step_tasks" ("run_id", "step_slug") WHERE (status = 'completed'::text);
|
|
100
|
+
-- Create index "idx_step_tasks_failed" to table: "step_tasks"
|
|
101
|
+
CREATE INDEX "idx_step_tasks_failed" ON "pgflow"."step_tasks" ("run_id", "step_slug") WHERE (status = 'failed'::text);
|
|
102
|
+
-- Create index "idx_step_tasks_flow_run_step" to table: "step_tasks"
|
|
103
|
+
CREATE INDEX "idx_step_tasks_flow_run_step" ON "pgflow"."step_tasks" ("flow_slug", "run_id", "step_slug");
|
|
104
|
+
-- Create index "idx_step_tasks_message_id" to table: "step_tasks"
|
|
105
|
+
CREATE INDEX "idx_step_tasks_message_id" ON "pgflow"."step_tasks" ("message_id");
|
|
106
|
+
-- Create index "idx_step_tasks_queued" to table: "step_tasks"
|
|
107
|
+
CREATE INDEX "idx_step_tasks_queued" ON "pgflow"."step_tasks" ("run_id", "step_slug") WHERE (status = 'queued'::text);
|
|
108
|
+
-- Create "poll_for_tasks" function
|
|
109
|
+
CREATE FUNCTION "pgflow"."poll_for_tasks" ("queue_name" text, "vt" integer, "qty" integer, "max_poll_seconds" integer DEFAULT 5, "poll_interval_ms" integer DEFAULT 100) RETURNS SETOF "pgflow"."step_task_record" LANGUAGE sql SET "search_path" = '' AS $$
|
|
110
|
+
with read_messages as (
|
|
111
|
+
select *
|
|
112
|
+
from pgflow.read_with_poll(
|
|
113
|
+
queue_name,
|
|
114
|
+
vt,
|
|
115
|
+
qty,
|
|
116
|
+
max_poll_seconds,
|
|
117
|
+
poll_interval_ms
|
|
118
|
+
)
|
|
119
|
+
),
|
|
120
|
+
tasks as (
|
|
121
|
+
select
|
|
122
|
+
task.flow_slug,
|
|
123
|
+
task.run_id,
|
|
124
|
+
task.step_slug,
|
|
125
|
+
task.task_index,
|
|
126
|
+
task.message_id
|
|
127
|
+
from pgflow.step_tasks as task
|
|
128
|
+
join read_messages as message on message.msg_id = task.message_id
|
|
129
|
+
where task.message_id = message.msg_id
|
|
130
|
+
and task.status = 'queued'
|
|
131
|
+
),
|
|
132
|
+
increment_attempts as (
|
|
133
|
+
update pgflow.step_tasks
|
|
134
|
+
set attempts_count = attempts_count + 1
|
|
135
|
+
from tasks
|
|
136
|
+
where step_tasks.message_id = tasks.message_id
|
|
137
|
+
and status = 'queued'
|
|
138
|
+
),
|
|
139
|
+
runs as (
|
|
140
|
+
select
|
|
141
|
+
r.run_id,
|
|
142
|
+
r.input
|
|
143
|
+
from pgflow.runs r
|
|
144
|
+
where r.run_id in (select run_id from tasks)
|
|
145
|
+
),
|
|
146
|
+
deps as (
|
|
147
|
+
select
|
|
148
|
+
st.run_id,
|
|
149
|
+
st.step_slug,
|
|
150
|
+
dep.dep_slug,
|
|
151
|
+
dep_task.output as dep_output
|
|
152
|
+
from tasks st
|
|
153
|
+
join pgflow.deps dep on dep.flow_slug = st.flow_slug and dep.step_slug = st.step_slug
|
|
154
|
+
join pgflow.step_tasks dep_task on
|
|
155
|
+
dep_task.run_id = st.run_id and
|
|
156
|
+
dep_task.step_slug = dep.dep_slug and
|
|
157
|
+
dep_task.status = 'completed'
|
|
158
|
+
),
|
|
159
|
+
deps_outputs as (
|
|
160
|
+
select
|
|
161
|
+
d.run_id,
|
|
162
|
+
d.step_slug,
|
|
163
|
+
jsonb_object_agg(d.dep_slug, d.dep_output) as deps_output
|
|
164
|
+
from deps d
|
|
165
|
+
group by d.run_id, d.step_slug
|
|
166
|
+
),
|
|
167
|
+
timeouts as (
|
|
168
|
+
select
|
|
169
|
+
task.message_id,
|
|
170
|
+
coalesce(step.opt_timeout, flow.opt_timeout) + 2 as vt_delay
|
|
171
|
+
from tasks task
|
|
172
|
+
join pgflow.flows flow on flow.flow_slug = task.flow_slug
|
|
173
|
+
join pgflow.steps step on step.flow_slug = task.flow_slug and step.step_slug = task.step_slug
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
select
|
|
177
|
+
st.flow_slug,
|
|
178
|
+
st.run_id,
|
|
179
|
+
st.step_slug,
|
|
180
|
+
jsonb_build_object('run', r.input) ||
|
|
181
|
+
coalesce(dep_out.deps_output, '{}'::jsonb) as input,
|
|
182
|
+
st.message_id as msg_id
|
|
183
|
+
from tasks st
|
|
184
|
+
join runs r on st.run_id = r.run_id
|
|
185
|
+
left join deps_outputs dep_out on
|
|
186
|
+
dep_out.run_id = st.run_id and
|
|
187
|
+
dep_out.step_slug = st.step_slug
|
|
188
|
+
cross join lateral (
|
|
189
|
+
-- TODO: this is slow because it calls set_vt for each row, and set_vt
|
|
190
|
+
-- builds dynamic query from string every time it is called
|
|
191
|
+
-- implement set_vt_batch(msgs_ids bigint[], vt_delays int[])
|
|
192
|
+
select pgmq.set_vt(queue_name, st.message_id,
|
|
193
|
+
(select t.vt_delay from timeouts t where t.message_id = st.message_id)
|
|
194
|
+
)
|
|
195
|
+
) set_vt;
|
|
196
|
+
$$;
|
|
197
|
+
-- Create "add_step" function
|
|
198
|
+
CREATE FUNCTION "pgflow"."add_step" ("flow_slug" text, "step_slug" text, "deps_slugs" text[], "max_attempts" integer DEFAULT NULL::integer, "base_delay" integer DEFAULT NULL::integer, "timeout" integer DEFAULT NULL::integer) RETURNS "pgflow"."steps" LANGUAGE sql SET "search_path" = '' AS $$
|
|
199
|
+
WITH
|
|
200
|
+
next_index AS (
|
|
201
|
+
SELECT COALESCE(MAX(step_index) + 1, 0) as idx
|
|
202
|
+
FROM pgflow.steps
|
|
203
|
+
WHERE flow_slug = add_step.flow_slug
|
|
204
|
+
),
|
|
205
|
+
create_step AS (
|
|
206
|
+
INSERT INTO pgflow.steps (flow_slug, step_slug, step_index, deps_count, opt_max_attempts, opt_base_delay, opt_timeout)
|
|
207
|
+
SELECT add_step.flow_slug, add_step.step_slug, idx, COALESCE(array_length(deps_slugs, 1), 0), max_attempts, base_delay, timeout
|
|
208
|
+
FROM next_index
|
|
209
|
+
ON CONFLICT (flow_slug, step_slug)
|
|
210
|
+
DO UPDATE SET step_slug = pgflow.steps.step_slug
|
|
211
|
+
RETURNING *
|
|
212
|
+
),
|
|
213
|
+
insert_deps AS (
|
|
214
|
+
INSERT INTO pgflow.deps (flow_slug, dep_slug, step_slug)
|
|
215
|
+
SELECT add_step.flow_slug, d.dep_slug, add_step.step_slug
|
|
216
|
+
FROM unnest(deps_slugs) AS d(dep_slug)
|
|
217
|
+
ON CONFLICT (flow_slug, dep_slug, step_slug) DO NOTHING
|
|
218
|
+
RETURNING 1
|
|
219
|
+
)
|
|
220
|
+
-- Return the created step
|
|
221
|
+
SELECT * FROM create_step;
|
|
222
|
+
$$;
|
|
223
|
+
-- Create "add_step" function
|
|
224
|
+
CREATE FUNCTION "pgflow"."add_step" ("flow_slug" text, "step_slug" text, "max_attempts" integer DEFAULT NULL::integer, "base_delay" integer DEFAULT NULL::integer, "timeout" integer DEFAULT NULL::integer) RETURNS "pgflow"."steps" LANGUAGE sql SET "search_path" = '' AS $$
|
|
225
|
+
-- Call the original function with an empty array
|
|
226
|
+
SELECT * FROM pgflow.add_step(flow_slug, step_slug, ARRAY[]::text[], max_attempts, base_delay, timeout);
|
|
227
|
+
$$;
|
|
228
|
+
-- Create "calculate_retry_delay" function
|
|
229
|
+
CREATE FUNCTION "pgflow"."calculate_retry_delay" ("base_delay" numeric, "attempts_count" integer) RETURNS integer LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ select floor(base_delay * power(2, attempts_count))::int $$;
|
|
230
|
+
-- Create "maybe_complete_run" function
|
|
231
|
+
CREATE FUNCTION "pgflow"."maybe_complete_run" ("run_id" uuid) RETURNS void LANGUAGE sql SET "search_path" = '' AS $$
|
|
232
|
+
-- Update run status to completed and set output when there are no remaining steps
|
|
233
|
+
-- All done in a single declarative SQL statement
|
|
234
|
+
UPDATE pgflow.runs
|
|
235
|
+
SET
|
|
236
|
+
status = 'completed',
|
|
237
|
+
completed_at = now(),
|
|
238
|
+
output = (
|
|
239
|
+
-- Get outputs from final steps (steps that are not dependencies for other steps)
|
|
240
|
+
SELECT jsonb_object_agg(st.step_slug, st.output)
|
|
241
|
+
FROM pgflow.step_tasks st
|
|
242
|
+
JOIN pgflow.step_states ss ON ss.run_id = st.run_id AND ss.step_slug = st.step_slug
|
|
243
|
+
JOIN pgflow.runs r ON r.run_id = ss.run_id AND r.flow_slug = ss.flow_slug
|
|
244
|
+
WHERE st.run_id = maybe_complete_run.run_id
|
|
245
|
+
AND st.status = 'completed'
|
|
246
|
+
AND NOT EXISTS (
|
|
247
|
+
SELECT 1
|
|
248
|
+
FROM pgflow.deps d
|
|
249
|
+
WHERE d.flow_slug = ss.flow_slug
|
|
250
|
+
AND d.dep_slug = ss.step_slug
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
WHERE pgflow.runs.run_id = maybe_complete_run.run_id
|
|
254
|
+
AND pgflow.runs.remaining_steps = 0
|
|
255
|
+
AND pgflow.runs.status != 'completed';
|
|
256
|
+
$$;
|
|
257
|
+
-- Create "start_ready_steps" function
|
|
258
|
+
CREATE FUNCTION "pgflow"."start_ready_steps" ("run_id" uuid) RETURNS void LANGUAGE sql SET "search_path" = '' AS $$
|
|
259
|
+
WITH ready_steps AS (
|
|
260
|
+
SELECT *
|
|
261
|
+
FROM pgflow.step_states AS step_state
|
|
262
|
+
WHERE step_state.run_id = start_ready_steps.run_id
|
|
263
|
+
AND step_state.status = 'created'
|
|
264
|
+
AND step_state.remaining_deps = 0
|
|
265
|
+
ORDER BY step_state.step_slug
|
|
266
|
+
FOR UPDATE
|
|
267
|
+
),
|
|
268
|
+
started_step_states AS (
|
|
269
|
+
UPDATE pgflow.step_states
|
|
270
|
+
SET status = 'started',
|
|
271
|
+
started_at = now()
|
|
272
|
+
FROM ready_steps
|
|
273
|
+
WHERE pgflow.step_states.run_id = start_ready_steps.run_id
|
|
274
|
+
AND pgflow.step_states.step_slug = ready_steps.step_slug
|
|
275
|
+
RETURNING pgflow.step_states.*
|
|
276
|
+
),
|
|
277
|
+
sent_messages AS (
|
|
278
|
+
SELECT
|
|
279
|
+
started_step.flow_slug,
|
|
280
|
+
started_step.run_id,
|
|
281
|
+
started_step.step_slug,
|
|
282
|
+
pgmq.send(started_step.flow_slug, jsonb_build_object(
|
|
283
|
+
'flow_slug', started_step.flow_slug,
|
|
284
|
+
'run_id', started_step.run_id,
|
|
285
|
+
'step_slug', started_step.step_slug,
|
|
286
|
+
'task_index', 0
|
|
287
|
+
)) AS msg_id
|
|
288
|
+
FROM started_step_states AS started_step
|
|
289
|
+
)
|
|
290
|
+
INSERT INTO pgflow.step_tasks (flow_slug, run_id, step_slug, message_id)
|
|
291
|
+
SELECT
|
|
292
|
+
sent_messages.flow_slug,
|
|
293
|
+
sent_messages.run_id,
|
|
294
|
+
sent_messages.step_slug,
|
|
295
|
+
sent_messages.msg_id
|
|
296
|
+
FROM sent_messages;
|
|
297
|
+
$$;
|
|
298
|
+
-- Create "complete_task" function
|
|
299
|
+
CREATE FUNCTION "pgflow"."complete_task" ("run_id" uuid, "step_slug" text, "task_index" integer, "output" jsonb) RETURNS SETOF "pgflow"."step_tasks" LANGUAGE plpgsql SET "search_path" = '' AS $$
|
|
300
|
+
begin
|
|
301
|
+
|
|
302
|
+
WITH run_lock AS (
|
|
303
|
+
SELECT * FROM pgflow.runs
|
|
304
|
+
WHERE pgflow.runs.run_id = complete_task.run_id
|
|
305
|
+
FOR UPDATE
|
|
306
|
+
),
|
|
307
|
+
step_lock AS (
|
|
308
|
+
SELECT * FROM pgflow.step_states
|
|
309
|
+
WHERE pgflow.step_states.run_id = complete_task.run_id
|
|
310
|
+
AND pgflow.step_states.step_slug = complete_task.step_slug
|
|
311
|
+
FOR UPDATE
|
|
312
|
+
),
|
|
313
|
+
task AS (
|
|
314
|
+
UPDATE pgflow.step_tasks
|
|
315
|
+
SET
|
|
316
|
+
status = 'completed',
|
|
317
|
+
completed_at = now(),
|
|
318
|
+
output = complete_task.output
|
|
319
|
+
WHERE pgflow.step_tasks.run_id = complete_task.run_id
|
|
320
|
+
AND pgflow.step_tasks.step_slug = complete_task.step_slug
|
|
321
|
+
AND pgflow.step_tasks.task_index = complete_task.task_index
|
|
322
|
+
RETURNING *
|
|
323
|
+
),
|
|
324
|
+
step_state AS (
|
|
325
|
+
UPDATE pgflow.step_states
|
|
326
|
+
SET
|
|
327
|
+
status = CASE
|
|
328
|
+
WHEN pgflow.step_states.remaining_tasks = 1 THEN 'completed' -- Will be 0 after decrement
|
|
329
|
+
ELSE 'started'
|
|
330
|
+
END,
|
|
331
|
+
completed_at = CASE
|
|
332
|
+
WHEN pgflow.step_states.remaining_tasks = 1 THEN now() -- Will be 0 after decrement
|
|
333
|
+
ELSE NULL
|
|
334
|
+
END,
|
|
335
|
+
remaining_tasks = pgflow.step_states.remaining_tasks - 1
|
|
336
|
+
FROM task
|
|
337
|
+
WHERE pgflow.step_states.run_id = complete_task.run_id
|
|
338
|
+
AND pgflow.step_states.step_slug = complete_task.step_slug
|
|
339
|
+
RETURNING pgflow.step_states.*
|
|
340
|
+
),
|
|
341
|
+
-- Find all dependent steps if the current step was completed
|
|
342
|
+
dependent_steps AS (
|
|
343
|
+
SELECT d.step_slug AS dependent_step_slug
|
|
344
|
+
FROM pgflow.deps d
|
|
345
|
+
JOIN step_state s ON s.status = 'completed' AND d.flow_slug = s.flow_slug
|
|
346
|
+
WHERE d.dep_slug = complete_task.step_slug
|
|
347
|
+
ORDER BY d.step_slug -- Ensure consistent ordering
|
|
348
|
+
),
|
|
349
|
+
-- Lock dependent steps before updating
|
|
350
|
+
dependent_steps_lock AS (
|
|
351
|
+
SELECT * FROM pgflow.step_states
|
|
352
|
+
WHERE pgflow.step_states.run_id = complete_task.run_id
|
|
353
|
+
AND pgflow.step_states.step_slug IN (SELECT dependent_step_slug FROM dependent_steps)
|
|
354
|
+
FOR UPDATE
|
|
355
|
+
),
|
|
356
|
+
-- Update all dependent steps
|
|
357
|
+
dependent_steps_update AS (
|
|
358
|
+
UPDATE pgflow.step_states
|
|
359
|
+
SET remaining_deps = pgflow.step_states.remaining_deps - 1
|
|
360
|
+
FROM dependent_steps
|
|
361
|
+
WHERE pgflow.step_states.run_id = complete_task.run_id
|
|
362
|
+
AND pgflow.step_states.step_slug = dependent_steps.dependent_step_slug
|
|
363
|
+
)
|
|
364
|
+
-- Only decrement remaining_steps, don't update status
|
|
365
|
+
UPDATE pgflow.runs
|
|
366
|
+
SET remaining_steps = pgflow.runs.remaining_steps - 1
|
|
367
|
+
FROM step_state
|
|
368
|
+
WHERE pgflow.runs.run_id = complete_task.run_id
|
|
369
|
+
AND step_state.status = 'completed';
|
|
370
|
+
|
|
371
|
+
PERFORM pgmq.archive(
|
|
372
|
+
queue_name => (SELECT run.flow_slug FROM pgflow.runs AS run WHERE run.run_id = complete_task.run_id),
|
|
373
|
+
msg_id => (SELECT message_id FROM pgflow.step_tasks AS step_task
|
|
374
|
+
WHERE step_task.run_id = complete_task.run_id
|
|
375
|
+
AND step_task.step_slug = complete_task.step_slug
|
|
376
|
+
AND step_task.task_index = complete_task.task_index)
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
PERFORM pgflow.start_ready_steps(complete_task.run_id);
|
|
380
|
+
|
|
381
|
+
PERFORM pgflow.maybe_complete_run(complete_task.run_id);
|
|
382
|
+
|
|
383
|
+
RETURN QUERY SELECT *
|
|
384
|
+
FROM pgflow.step_tasks AS step_task
|
|
385
|
+
WHERE step_task.run_id = complete_task.run_id
|
|
386
|
+
AND step_task.step_slug = complete_task.step_slug
|
|
387
|
+
AND step_task.task_index = complete_task.task_index;
|
|
388
|
+
|
|
389
|
+
end;
|
|
390
|
+
$$;
|
|
391
|
+
-- Create "create_flow" function
|
|
392
|
+
CREATE FUNCTION "pgflow"."create_flow" ("flow_slug" text, "max_attempts" integer DEFAULT 3, "base_delay" integer DEFAULT 5, "timeout" integer DEFAULT 60) RETURNS "pgflow"."flows" LANGUAGE sql SET "search_path" = '' AS $$
|
|
393
|
+
WITH
|
|
394
|
+
flow_upsert AS (
|
|
395
|
+
INSERT INTO pgflow.flows (flow_slug, opt_max_attempts, opt_base_delay, opt_timeout)
|
|
396
|
+
VALUES (flow_slug, max_attempts, base_delay, timeout)
|
|
397
|
+
ON CONFLICT (flow_slug) DO UPDATE
|
|
398
|
+
SET flow_slug = pgflow.flows.flow_slug -- Dummy update
|
|
399
|
+
RETURNING *
|
|
400
|
+
),
|
|
401
|
+
ensure_queue AS (
|
|
402
|
+
SELECT pgmq.create(flow_slug)
|
|
403
|
+
WHERE NOT EXISTS (
|
|
404
|
+
SELECT 1 FROM pgmq.list_queues() WHERE queue_name = flow_slug
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
SELECT f.*
|
|
408
|
+
FROM flow_upsert f
|
|
409
|
+
LEFT JOIN (SELECT 1 FROM ensure_queue) _dummy ON true; -- Left join ensures flow is returned
|
|
410
|
+
$$;
|
|
411
|
+
-- Create "fail_task" function
|
|
412
|
+
CREATE FUNCTION "pgflow"."fail_task" ("run_id" uuid, "step_slug" text, "task_index" integer, "error_message" text) RETURNS SETOF "pgflow"."step_tasks" LANGUAGE plpgsql SET "search_path" = '' AS $$
|
|
413
|
+
begin
|
|
414
|
+
|
|
415
|
+
WITH run_lock AS (
|
|
416
|
+
SELECT * FROM pgflow.runs
|
|
417
|
+
WHERE pgflow.runs.run_id = fail_task.run_id
|
|
418
|
+
FOR UPDATE
|
|
419
|
+
),
|
|
420
|
+
step_lock AS (
|
|
421
|
+
SELECT * FROM pgflow.step_states
|
|
422
|
+
WHERE pgflow.step_states.run_id = fail_task.run_id
|
|
423
|
+
AND pgflow.step_states.step_slug = fail_task.step_slug
|
|
424
|
+
FOR UPDATE
|
|
425
|
+
),
|
|
426
|
+
flow_info AS (
|
|
427
|
+
SELECT r.flow_slug
|
|
428
|
+
FROM pgflow.runs r
|
|
429
|
+
WHERE r.run_id = fail_task.run_id
|
|
430
|
+
),
|
|
431
|
+
config AS (
|
|
432
|
+
SELECT
|
|
433
|
+
COALESCE(s.opt_max_attempts, f.opt_max_attempts) AS opt_max_attempts,
|
|
434
|
+
COALESCE(s.opt_base_delay, f.opt_base_delay) AS opt_base_delay
|
|
435
|
+
FROM pgflow.steps s
|
|
436
|
+
JOIN pgflow.flows f ON f.flow_slug = s.flow_slug
|
|
437
|
+
JOIN flow_info fi ON fi.flow_slug = s.flow_slug
|
|
438
|
+
WHERE s.flow_slug = fi.flow_slug AND s.step_slug = fail_task.step_slug
|
|
439
|
+
),
|
|
440
|
+
|
|
441
|
+
fail_or_retry_task as (
|
|
442
|
+
UPDATE pgflow.step_tasks as task
|
|
443
|
+
SET
|
|
444
|
+
status = CASE
|
|
445
|
+
WHEN task.attempts_count < (SELECT opt_max_attempts FROM config) THEN 'queued'
|
|
446
|
+
ELSE 'failed'
|
|
447
|
+
END,
|
|
448
|
+
failed_at = CASE
|
|
449
|
+
WHEN task.attempts_count >= (SELECT opt_max_attempts FROM config) THEN now()
|
|
450
|
+
ELSE NULL
|
|
451
|
+
END,
|
|
452
|
+
error_message = fail_task.error_message
|
|
453
|
+
WHERE task.run_id = fail_task.run_id
|
|
454
|
+
AND task.step_slug = fail_task.step_slug
|
|
455
|
+
AND task.task_index = fail_task.task_index
|
|
456
|
+
AND task.status = 'queued'
|
|
457
|
+
RETURNING *
|
|
458
|
+
),
|
|
459
|
+
maybe_fail_step AS (
|
|
460
|
+
UPDATE pgflow.step_states
|
|
461
|
+
SET
|
|
462
|
+
status = CASE
|
|
463
|
+
WHEN (select fail_or_retry_task.status from fail_or_retry_task) = 'failed' THEN 'failed'
|
|
464
|
+
ELSE pgflow.step_states.status
|
|
465
|
+
END,
|
|
466
|
+
failed_at = CASE
|
|
467
|
+
WHEN (select fail_or_retry_task.status from fail_or_retry_task) = 'failed' THEN now()
|
|
468
|
+
ELSE NULL
|
|
469
|
+
END
|
|
470
|
+
FROM fail_or_retry_task
|
|
471
|
+
WHERE pgflow.step_states.run_id = fail_task.run_id
|
|
472
|
+
AND pgflow.step_states.step_slug = fail_task.step_slug
|
|
473
|
+
RETURNING pgflow.step_states.*
|
|
474
|
+
)
|
|
475
|
+
UPDATE pgflow.runs
|
|
476
|
+
SET status = CASE
|
|
477
|
+
WHEN (select status from maybe_fail_step) = 'failed' THEN 'failed'
|
|
478
|
+
ELSE status
|
|
479
|
+
END,
|
|
480
|
+
failed_at = CASE
|
|
481
|
+
WHEN (select status from maybe_fail_step) = 'failed' THEN now()
|
|
482
|
+
ELSE NULL
|
|
483
|
+
END
|
|
484
|
+
WHERE pgflow.runs.run_id = fail_task.run_id;
|
|
485
|
+
|
|
486
|
+
-- For queued tasks: delay the message for retry with exponential backoff
|
|
487
|
+
PERFORM (
|
|
488
|
+
WITH retry_config AS (
|
|
489
|
+
SELECT
|
|
490
|
+
COALESCE(s.opt_base_delay, f.opt_base_delay) AS base_delay
|
|
491
|
+
FROM pgflow.steps s
|
|
492
|
+
JOIN pgflow.flows f ON f.flow_slug = s.flow_slug
|
|
493
|
+
JOIN pgflow.runs r ON r.flow_slug = f.flow_slug
|
|
494
|
+
WHERE r.run_id = fail_task.run_id
|
|
495
|
+
AND s.step_slug = fail_task.step_slug
|
|
496
|
+
),
|
|
497
|
+
queued_tasks AS (
|
|
498
|
+
SELECT
|
|
499
|
+
r.flow_slug,
|
|
500
|
+
st.message_id,
|
|
501
|
+
pgflow.calculate_retry_delay((SELECT base_delay FROM retry_config), st.attempts_count) AS calculated_delay
|
|
502
|
+
FROM pgflow.step_tasks st
|
|
503
|
+
JOIN pgflow.runs r ON st.run_id = r.run_id
|
|
504
|
+
WHERE st.run_id = fail_task.run_id
|
|
505
|
+
AND st.step_slug = fail_task.step_slug
|
|
506
|
+
AND st.task_index = fail_task.task_index
|
|
507
|
+
AND st.status = 'queued'
|
|
508
|
+
)
|
|
509
|
+
SELECT pgmq.set_vt(qt.flow_slug, qt.message_id, qt.calculated_delay)
|
|
510
|
+
FROM queued_tasks qt
|
|
511
|
+
WHERE EXISTS (SELECT 1 FROM queued_tasks)
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
-- For failed tasks: archive the message
|
|
515
|
+
PERFORM (
|
|
516
|
+
WITH failed_tasks AS (
|
|
517
|
+
SELECT r.flow_slug, st.message_id
|
|
518
|
+
FROM pgflow.step_tasks st
|
|
519
|
+
JOIN pgflow.runs r ON st.run_id = r.run_id
|
|
520
|
+
WHERE st.run_id = fail_task.run_id
|
|
521
|
+
AND st.step_slug = fail_task.step_slug
|
|
522
|
+
AND st.task_index = fail_task.task_index
|
|
523
|
+
AND st.status = 'failed'
|
|
524
|
+
)
|
|
525
|
+
SELECT pgmq.archive(ft.flow_slug, ft.message_id)
|
|
526
|
+
FROM failed_tasks ft
|
|
527
|
+
WHERE EXISTS (SELECT 1 FROM failed_tasks)
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
return query select *
|
|
531
|
+
from pgflow.step_tasks st
|
|
532
|
+
where st.run_id = fail_task.run_id
|
|
533
|
+
and st.step_slug = fail_task.step_slug
|
|
534
|
+
and st.task_index = fail_task.task_index;
|
|
535
|
+
|
|
536
|
+
end;
|
|
537
|
+
$$;
|
|
538
|
+
-- Create "start_flow" function
|
|
539
|
+
CREATE FUNCTION "pgflow"."start_flow" ("flow_slug" text, "input" jsonb) RETURNS SETOF "pgflow"."runs" LANGUAGE plpgsql SET "search_path" = '' AS $$
|
|
540
|
+
declare
|
|
541
|
+
v_created_run pgflow.runs%ROWTYPE;
|
|
542
|
+
begin
|
|
543
|
+
|
|
544
|
+
WITH
|
|
545
|
+
flow_steps AS (
|
|
546
|
+
SELECT steps.flow_slug, steps.step_slug, steps.deps_count
|
|
547
|
+
FROM pgflow.steps
|
|
548
|
+
WHERE steps.flow_slug = start_flow.flow_slug
|
|
549
|
+
),
|
|
550
|
+
created_run AS (
|
|
551
|
+
INSERT INTO pgflow.runs (flow_slug, input, remaining_steps)
|
|
552
|
+
VALUES (
|
|
553
|
+
start_flow.flow_slug,
|
|
554
|
+
start_flow.input,
|
|
555
|
+
(SELECT count(*) FROM flow_steps)
|
|
556
|
+
)
|
|
557
|
+
RETURNING *
|
|
558
|
+
),
|
|
559
|
+
created_step_states AS (
|
|
560
|
+
INSERT INTO pgflow.step_states (flow_slug, run_id, step_slug, remaining_deps)
|
|
561
|
+
SELECT
|
|
562
|
+
fs.flow_slug,
|
|
563
|
+
(SELECT run_id FROM created_run),
|
|
564
|
+
fs.step_slug,
|
|
565
|
+
fs.deps_count
|
|
566
|
+
FROM flow_steps fs
|
|
567
|
+
)
|
|
568
|
+
SELECT * FROM created_run INTO v_created_run;
|
|
569
|
+
|
|
570
|
+
PERFORM pgflow.start_ready_steps(v_created_run.run_id);
|
|
571
|
+
|
|
572
|
+
RETURN QUERY SELECT * FROM pgflow.runs where run_id = v_created_run.run_id;
|
|
573
|
+
|
|
574
|
+
end;
|
|
575
|
+
$$;
|
|
576
|
+
-- Create "workers" table
|
|
577
|
+
CREATE TABLE "pgflow"."workers" ("worker_id" uuid NOT NULL, "queue_name" text NOT NULL, "function_name" text NOT NULL, "started_at" timestamptz NOT NULL DEFAULT now(), "stopped_at" timestamptz NULL, "last_heartbeat_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("worker_id"));
|
|
578
|
+
-- Create index "idx_workers_queue_name" to table: "workers"
|
|
579
|
+
CREATE INDEX "idx_workers_queue_name" ON "pgflow"."workers" ("queue_name");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pgflow/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"license": "AGPL-3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^22.14.1",
|
|
23
|
-
"supabase": "^2.
|
|
23
|
+
"supabase": "^2.21.1"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"postgres": "^3.4.5",
|
|
27
|
-
"@pgflow/dsl": "0.1.
|
|
27
|
+
"@pgflow/dsl": "0.1.20"
|
|
28
28
|
},
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public"
|