omen 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +24 -14
- data/app/models/omen/instructions.md +24 -7
- data/app/models/omen/instructions.rb +8 -3
- data/lib/omen/attributes.rb +35 -0
- data/lib/omen/distance.rb +1 -1
- data/lib/omen/grants.rb +40 -0
- data/lib/omen/inquirer.rb +31 -41
- data/lib/omen/renamed.rb +17 -0
- data/lib/omen/time_zone.rb +57 -0
- data/lib/omen/version.rb +1 -1
- data/lib/omen.rb +4 -1
- metadata +5 -2
- data/lib/omen/eastern.rb +0 -40
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd517ec9c5893a92f285613782d245ef9573bb83c8ef78378510f49a208626fc
|
|
4
|
+
data.tar.gz: 5d5f97f73bd6cf0b423f9264426522aee942df7ab4fb622e3442b75493da29c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f383e42b3c7e9fdd06f7c69c6ef7fd8563cb55f47db66583ee07c656b56bfabe6217972c63c89596db5407a96af4e8080f71e3561e7ee188a507c74d9ff28404
|
|
7
|
+
data.tar.gz: a8894e28c75abeeb62cd139ead7bf0abca83593a49c89bae50273dbe178016ef6cf915179a07355b4b7af38f4f17c06efc2e57f597e949637bf5f7528fbce8a7
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## 0.4.0 - 2026-08-24
|
|
11
|
+
|
|
12
|
+
* [Breaking change] Prefix every database function this gem creates, and drop the names they
|
|
13
|
+
had: `eastern` is now `omen_time_zone`, `miles_between` is `omen_miles_between`. A statement
|
|
14
|
+
stored before this names the old ones, so re-running one fails rather than answering from a
|
|
15
|
+
function nothing maintains -- an answer already drawn is unaffected, since its rows are stored
|
|
16
|
+
rather than recomputed
|
|
17
|
+
* [Feature] Create `omen_today()` and tell Claude to build every relative window on it, so a
|
|
18
|
+
statement kept and run again answers "last month" for the month it is run in rather than the
|
|
19
|
+
month it was written in. `STABLE`, and a date rather than a timestamp
|
|
20
|
+
* [Feature] Ask for a note that says a sliding window the way the statement says it, and then
|
|
21
|
+
what it comes to today, so a stored note does not stop being true on the second run
|
|
22
|
+
|
|
23
|
+
## 0.3.1 - 2026-08-24
|
|
24
|
+
|
|
25
|
+
* [Fix] Stop asserting the role attributes only a superuser may set, since `NOSUPERUSER`,
|
|
26
|
+
`NOBYPASSRLS` and `NOREPLICATION` are refused outright by every managed database -- a role is
|
|
27
|
+
created without them anyway, so they are read back and warned about rather than set
|
|
28
|
+
* [Fix] Run each statement of the grant in a savepoint of its own, so one a database refuses no
|
|
29
|
+
longer discards the grants, the revocations and both functions behind it
|
|
30
|
+
|
|
10
31
|
## 0.3.0 - 2026-08-24
|
|
11
32
|
|
|
12
33
|
* [Feature] Create a `miles_between(lat1, lng1, lat2, lng2)` function and name it in the prompt,
|
data/README.md
CHANGED
|
@@ -15,14 +15,15 @@ draws the answer (including encrypted attributes) for whoever asked. Nothing tha
|
|
|
15
15
|
gem install omen
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Or, in a `Gemfile`, pinned to the current
|
|
18
|
+
Or, in a `Gemfile`, pinned to the current minor while this is still below 1.0:
|
|
19
19
|
|
|
20
20
|
```ruby
|
|
21
|
-
gem 'omen', '~> 0.
|
|
21
|
+
gem 'omen', '~> 0.4.0'
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Omen follows [Semantic Versioning](https://semver.org)
|
|
25
|
-
|
|
24
|
+
Omen follows [Semantic Versioning](https://semver.org) from 1.0 onwards. Until then a release may
|
|
25
|
+
break whatever it likes, so the pin stops short of the next minor: `~> 0.4.0` takes every fix in
|
|
26
|
+
0.4 and nothing beyond it.
|
|
26
27
|
|
|
27
28
|
## Requirements
|
|
28
29
|
|
|
@@ -50,18 +51,23 @@ never crosses a breaking change.
|
|
|
50
51
|
question ever asked.
|
|
51
52
|
</details>
|
|
52
53
|
|
|
53
|
-
**
|
|
54
|
+
**Three database functions, all prefixed**
|
|
54
55
|
|
|
55
56
|
<details>
|
|
56
|
-
<summary>A timestamp and a distance
|
|
57
|
-
`db:omen:grant` creates
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
57
|
+
<summary>A timestamp, a day and a distance go through a function, never an expression. </summary>
|
|
58
|
+
`db:omen:grant` creates all three, each named `omen_` so that none of them can take a name an
|
|
59
|
+
app wanted for itself -- `today` especially. `omen_time_zone()` hands a stored timestamp back in
|
|
60
|
+
the zone the company works in, so every date means the same whole days. `omen_today()` answers
|
|
61
|
+
what day it is there, and the prompt builds every relative window on it, so a statement that is
|
|
62
|
+
kept and run again answers "last month" for the month it is run in rather than the month it was
|
|
63
|
+
written in. `omen_miles_between(lat1, lng1, lat2, lng2)` answers a great-circle distance in
|
|
64
|
+
miles. The prompt names each and forbids writing any of them by hand: a conversion assembled per
|
|
65
|
+
query drifts, and a great-circle expression runs to a dozen nested calls that a reply balances
|
|
66
|
+
by hand and gets wrong. The first and last are `IMMUTABLE`; `omen_today()` is `STABLE`, because
|
|
67
|
+
it reads the clock and an immutable function of the clock may be folded to a constant -- which
|
|
68
|
+
is exactly the sliding this one exists to keep. All are executable by anyone, so none needs a
|
|
69
|
+
grant. An app in another zone renames the first two; an app whose tables carry no coordinates
|
|
70
|
+
never calls the last. None can be a migration: Rails' `:ruby` schema format dumps no functions,
|
|
65
71
|
so `db:schema:load` would drop one a migration had made.
|
|
66
72
|
</details>
|
|
67
73
|
|
|
@@ -147,6 +153,10 @@ usually does — the role is made by hand and the revocation with it:
|
|
|
147
153
|
REVOKE SELECT ON omen_readings, omen_questions, omen_answers FROM omen_inquirer;
|
|
148
154
|
```
|
|
149
155
|
|
|
156
|
+
A managed database also refuses `ALTER ROLE ... NOSUPERUSER`, since only a superuser may say it.
|
|
157
|
+
Omen skips that statement and carries on rather than stopping, then reads the role back and says
|
|
158
|
+
so if it holds `SUPERUSER`, `BYPASSRLS` or `REPLICATION` -- which a role it created never does.
|
|
159
|
+
|
|
150
160
|
A missed table there means Claude is shown the log of every question ever asked.
|
|
151
161
|
|
|
152
162
|
## License
|
|
@@ -31,19 +31,32 @@ have made it, and switching quote style to avoid an apostrophe is how you make i
|
|
|
31
31
|
|
|
32
32
|
## Reading the schema
|
|
33
33
|
|
|
34
|
-
Timestamps are stored in UTC, and `%{
|
|
34
|
+
Timestamps are stored in UTC, and `%{zone_fn}()` is the one way to read one: it hands the same
|
|
35
35
|
moment back in the zone the company works in. Wrap every timestamp you touch in it -- in a
|
|
36
36
|
`WHERE`, in an `ORDER BY`, in a `GROUP BY`, and in a column you return -- so a question about a
|
|
37
|
-
day, a week or a month means whole days here: `date_trunc('day', %{
|
|
37
|
+
day, a week or a month means whole days here: `date_trunc('day', %{zone_fn}(created_at))`. Never
|
|
38
38
|
write a conversion of your own.
|
|
39
39
|
|
|
40
|
-
Today is %{today}
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
Today is %{today}, and `%{today_fn}()` is that same day asked of the database. Use it for every
|
|
41
|
+
window a question describes in relation to now:
|
|
42
|
+
|
|
43
|
+
```sql
|
|
44
|
+
WHERE %{zone_fn}(created_at) >= date_trunc('month', %{today_fn}()) - interval '1 month'
|
|
45
|
+
AND %{zone_fn}(created_at) < date_trunc('month', %{today_fn}())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Write it that way rather than working the dates out and putting them in, because a statement is
|
|
49
|
+
kept and run again: one carrying `'2026-08-01'` answers a question nobody asked the second time,
|
|
50
|
+
where one carrying `%{today_fn}()` still answers "last month" whenever it is run. A window a
|
|
51
|
+
question names outright -- "in July 2026", "since the 3rd" -- is not relative to now, and keeps
|
|
52
|
+
its literal dates.
|
|
53
|
+
|
|
54
|
+
Never `now()`, `current_date` or `current_timestamp`: those are the machine's clock in the
|
|
55
|
+
machine's zone. `%{today_fn}()` is the company's day, and it is a date, so it needs no truncating.
|
|
43
56
|
|
|
44
57
|
Where a table carries coordinates, the distance between two points in miles is
|
|
45
|
-
`%{
|
|
46
|
-
`WHERE %{
|
|
58
|
+
`%{miles_fn}(lat1, lng1, lat2, lng2)`, so a radius reads
|
|
59
|
+
`WHERE %{miles_fn}(l.lat, l.lng, u.lat, u.lng) <= 2`. Never write the trigonometry yourself: a
|
|
47
60
|
great-circle expression built by hand runs to a dozen nested calls, and one bracket out of place
|
|
48
61
|
either refuses the statement or, worse, measures something else and says nothing about it.
|
|
49
62
|
|
|
@@ -102,6 +115,10 @@ one of those.
|
|
|
102
115
|
Otherwise keep the note to a sentence or two: what the query returns, and any assumption you
|
|
103
116
|
made. These are colleagues reading quickly, not a report.
|
|
104
117
|
|
|
118
|
+
Say a sliding window the way the statement says it, and then what it comes to today: "in the
|
|
119
|
+
previous calendar month (July 2026)". A note reading "in July 2026" alone stops being true the
|
|
120
|
+
moment the statement is run again.
|
|
121
|
+
|
|
105
122
|
## The schema
|
|
106
123
|
|
|
107
124
|
```ruby
|
|
@@ -4,10 +4,14 @@ class Omen::Instructions
|
|
|
4
4
|
PROSE = File.expand_path 'instructions.md', __dir__
|
|
5
5
|
|
|
6
6
|
# The database function a stored timestamp is read through, created by the gem's rake task.
|
|
7
|
-
|
|
7
|
+
# Every one of these is prefixed, since `today` especially is a name a host may want itself.
|
|
8
|
+
TIME_ZONE = 'omen_time_zone'
|
|
9
|
+
|
|
10
|
+
# The database function that answers what day it is where the company works.
|
|
11
|
+
TODAY = 'omen_today'
|
|
8
12
|
|
|
9
13
|
# The database function a distance in miles is measured with, created by the same task.
|
|
10
|
-
MILES = '
|
|
14
|
+
MILES = 'omen_miles_between'
|
|
11
15
|
|
|
12
16
|
# The one shape a reply may take: both keys required, and no others admitted.
|
|
13
17
|
ANSWER = {
|
|
@@ -36,7 +40,8 @@ class Omen::Instructions
|
|
|
36
40
|
# Today's date is said out loud because "last month" is Claude's to resolve, and it has no clock.
|
|
37
41
|
# @return [String] the prose, with the schema, the subclasses and the host's notes filled in.
|
|
38
42
|
def text
|
|
39
|
-
format File.read(PROSE), today: Date.current.to_fs(:long),
|
|
43
|
+
format File.read(PROSE), today: Date.current.to_fs(:long), zone_fn: TIME_ZONE,
|
|
44
|
+
today_fn: TODAY, miles_fn: MILES,
|
|
40
45
|
schema: schema, types: types, readable: readable, refused: refused,
|
|
41
46
|
notes: Omen.config.notes
|
|
42
47
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Omen
|
|
2
|
+
# What the role a statement runs as may and may not be. A role is created with every dangerous
|
|
3
|
+
# attribute already off, so this gem sets only what whoever created it may set, and reads the
|
|
4
|
+
# rest back rather than asserting them: saying `NOSUPERUSER` needs the SUPERUSER attribute, so
|
|
5
|
+
# a managed database refuses the very statement that would have made the role safe.
|
|
6
|
+
module Attributes
|
|
7
|
+
# Set on the role, because whoever may create a role may set these.
|
|
8
|
+
SETTABLE = 'NOLOGIN NOCREATEDB NOCREATEROLE'
|
|
9
|
+
|
|
10
|
+
# Read back instead, since saying no to one of these needs the attribute itself.
|
|
11
|
+
DANGEROUS = { rolsuper: 'SUPERUSER', rolbypassrls: 'BYPASSRLS',
|
|
12
|
+
rolreplication: 'REPLICATION', }
|
|
13
|
+
|
|
14
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
15
|
+
# @return [Boolean] whether the role is there at all to be granted anything.
|
|
16
|
+
def self.exists?(connection) = held(connection).present?
|
|
17
|
+
|
|
18
|
+
# Nil where there is no such role, so that an empty list and a missing role read apart.
|
|
19
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
20
|
+
# @return [Hash, nil] the row of what the role is, or nothing where the role is not.
|
|
21
|
+
def self.held(connection)
|
|
22
|
+
connection.select_one <<~SQL.squish
|
|
23
|
+
SELECT #{DANGEROUS.keys.join ', '} FROM pg_roles
|
|
24
|
+
WHERE rolname = #{connection.quote Omen.config.narrow_role}
|
|
25
|
+
SQL
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
29
|
+
# @return [Array<String>] what the role holds that no reading should be able to reach.
|
|
30
|
+
def self.dangerous(connection)
|
|
31
|
+
row = held(connection) || {}
|
|
32
|
+
DANGEROUS.filter_map { |column, name| name if row[column.to_s] }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/omen/distance.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Omen
|
|
2
2
|
# The database function a reading measures a distance with. Created by the rake task rather
|
|
3
|
-
# than by a migration, for the reason Omen::
|
|
3
|
+
# than by a migration, for the reason Omen::TimeZone is: Rails' :ruby schema format dumps no
|
|
4
4
|
# functions, so db:schema:load would drop one a migration had made.
|
|
5
5
|
module Distance
|
|
6
6
|
# The earth's mean radius in miles, which is what makes the answer miles.
|
data/lib/omen/grants.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Omen
|
|
2
|
+
# Everything said to a database to make the role a statement runs as: what it may be, what it
|
|
3
|
+
# may read, and what it may not. Kept apart from the task that runs them, which is about a
|
|
4
|
+
# database it has to find and a refusal it has to survive rather than about privileges.
|
|
5
|
+
module Grants
|
|
6
|
+
# DDL, which Active Record has no expression for, and not a query.
|
|
7
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
8
|
+
# @param members [Array<String>] the roles that may SET LOCAL ROLE to this one. The owner
|
|
9
|
+
# running the task is one of them, and matters in tests, where Rails swaps the reading
|
|
10
|
+
# pool for the writing one and the test connection is the owner.
|
|
11
|
+
# @return [Array<String>] the statements to run, in order.
|
|
12
|
+
def self.statements(connection, members)
|
|
13
|
+
role = connection.quote_table_name Omen.config.narrow_role
|
|
14
|
+
[
|
|
15
|
+
'DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ' \
|
|
16
|
+
"#{connection.quote Omen.config.narrow_role}) THEN CREATE ROLE #{role} NOLOGIN; " \
|
|
17
|
+
'END IF; END $$',
|
|
18
|
+
"ALTER ROLE #{role} WITH #{Attributes::SETTABLE}",
|
|
19
|
+
"GRANT USAGE ON SCHEMA public TO #{role}",
|
|
20
|
+
"GRANT SELECT ON ALL TABLES IN SCHEMA public TO #{role}",
|
|
21
|
+
"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO #{role}",
|
|
22
|
+
*members.map { |member| "GRANT #{role} TO #{connection.quote_table_name member}" },
|
|
23
|
+
*revoked(connection, role),
|
|
24
|
+
*Omen::Renamed.statements,
|
|
25
|
+
*Omen::TimeZone.statements(connection),
|
|
26
|
+
*Omen::Distance.statements(connection),
|
|
27
|
+
]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Intersected, so a bare db:create with no table yet to revoke on is not a failure.
|
|
31
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
32
|
+
# @param role [String] the role to hide this feature's own tables from.
|
|
33
|
+
# @return [Array<String>] one REVOKE per table there is.
|
|
34
|
+
def self.revoked(connection, role)
|
|
35
|
+
(connection.tables & Omen.tables).map do |table|
|
|
36
|
+
"REVOKE SELECT ON #{connection.quote_table_name table} FROM #{role}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/omen/inquirer.rb
CHANGED
|
@@ -3,9 +3,6 @@ module Omen
|
|
|
3
3
|
# It needs no database.yml entry of its own: NOLOGIN, it is a privilege container reached
|
|
4
4
|
# with SET LOCAL ROLE and never connected as.
|
|
5
5
|
module Inquirer
|
|
6
|
-
# What this role may never be: a superuser bypasses GRANT outright.
|
|
7
|
-
ATTRIBUTES = 'NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOREPLICATION'
|
|
8
|
-
|
|
9
6
|
# Whom to ask, since the role a host reads through is one this gem has no name for.
|
|
10
7
|
WHOEVER = 'SELECT current_user'
|
|
11
8
|
|
|
@@ -13,6 +10,18 @@ module Omen
|
|
|
13
10
|
UNGRANTED = 'No %{role} connection is configured, so nothing was granted to whatever ' \
|
|
14
11
|
'reads through it. Run this again once config/database.yml names one.'
|
|
15
12
|
|
|
13
|
+
# Said where the role could not be made: a managed database never grants CREATEROLE.
|
|
14
|
+
UNMADE = 'Could not make %{role}, so every reading will say this app is misconfigured. Ask ' \
|
|
15
|
+
'for that role, NOLOGIN, granted SELECT on every table but %{tables}.'
|
|
16
|
+
|
|
17
|
+
# Said per statement, since the grants, the revocations and the functions need nothing from
|
|
18
|
+
# one another and one refusal should not discard the rest.
|
|
19
|
+
REFUSED = 'Skipped, refused by the database: %{statement} (%{error})'
|
|
20
|
+
|
|
21
|
+
# Said where a role that already existed is one a reading should not be able to reach through.
|
|
22
|
+
DANGEROUS = '%{role} holds %{held}. This gem cannot take that away without being a superuser ' \
|
|
23
|
+
'itself, so ask for it to be taken away.'
|
|
24
|
+
|
|
16
25
|
# Creates the role and the function, on every database this environment prepares.
|
|
17
26
|
# @return [void]
|
|
18
27
|
def self.grant
|
|
@@ -37,12 +46,26 @@ module Omen
|
|
|
37
46
|
read_by = reader
|
|
38
47
|
warn UNGRANTED % { role: Omen.config.reading_role } unless read_by
|
|
39
48
|
members = [ read_by, connection.select_value(WHOEVER) ].compact
|
|
40
|
-
statements(connection, members).each { |statement| connection
|
|
41
|
-
|
|
49
|
+
Grants.statements(connection, members).each { |statement| attempted connection, statement }
|
|
50
|
+
role = Omen.config.narrow_role
|
|
51
|
+
return warn UNMADE % { role: role, tables: Omen.tables.to_sentence } unless
|
|
52
|
+
Attributes.exists? connection
|
|
53
|
+
|
|
54
|
+
held = Attributes.dangerous connection
|
|
55
|
+
warn DANGEROUS % { role: role, held: held.to_sentence } if held.any?
|
|
56
|
+
puts "Granted SELECT on #{connection.current_database} to #{role}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# One statement at a time, so a database that refuses one still runs the others -- and each
|
|
60
|
+
# inside a savepoint of its own, since a refusal inside a transaction refuses everything
|
|
61
|
+
# after it too, and this task is as likely to be run from a console as from a deploy.
|
|
62
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
63
|
+
# @param statement [String] one of the statements Omen::Grants builds.
|
|
64
|
+
# @return [void]
|
|
65
|
+
def self.attempted(connection, statement)
|
|
66
|
+
connection.transaction(requires_new: true) { connection.execute statement }
|
|
42
67
|
rescue ActiveRecord::StatementInvalid => error
|
|
43
|
-
warn
|
|
44
|
-
'misconfigured. Ask for that role, NOLOGIN, granted SELECT on every table but ' \
|
|
45
|
-
"#{Omen.tables.to_sentence}: #{error.message}"
|
|
68
|
+
warn REFUSED % { statement: statement.squish, error: error.message.lines.first.strip }
|
|
46
69
|
end
|
|
47
70
|
|
|
48
71
|
# Discovered rather than named: SET LOCAL ROLE needs the connecting role to be a member of
|
|
@@ -55,38 +78,5 @@ module Omen
|
|
|
55
78
|
rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::ConnectionNotEstablished
|
|
56
79
|
nil
|
|
57
80
|
end
|
|
58
|
-
|
|
59
|
-
# DDL, which Active Record has no expression for, and not a query.
|
|
60
|
-
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
61
|
-
# @param members [Array<String>] the roles that may SET LOCAL ROLE to this one. The owner
|
|
62
|
-
# running the task is one of them, and matters in tests, where Rails swaps the reading
|
|
63
|
-
# pool for the writing one and the test connection is the owner.
|
|
64
|
-
# @return [Array<String>] the statements to run, in order.
|
|
65
|
-
def self.statements(connection, members)
|
|
66
|
-
role = connection.quote_table_name Omen.config.narrow_role
|
|
67
|
-
[
|
|
68
|
-
'DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ' \
|
|
69
|
-
"#{connection.quote Omen.config.narrow_role}) THEN CREATE ROLE #{role} NOLOGIN; " \
|
|
70
|
-
'END IF; END $$',
|
|
71
|
-
"ALTER ROLE #{role} WITH NOLOGIN #{ATTRIBUTES}",
|
|
72
|
-
"GRANT USAGE ON SCHEMA public TO #{role}",
|
|
73
|
-
"GRANT SELECT ON ALL TABLES IN SCHEMA public TO #{role}",
|
|
74
|
-
"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO #{role}",
|
|
75
|
-
*members.map { |member| "GRANT #{role} TO #{connection.quote_table_name member}" },
|
|
76
|
-
*revoked(connection, role),
|
|
77
|
-
*Eastern.statements(connection),
|
|
78
|
-
*Distance.statements(connection),
|
|
79
|
-
]
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Intersected, so a bare db:create with no table yet to revoke on is not a failure.
|
|
83
|
-
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
84
|
-
# @param role [String] the role to hide this feature's own tables from.
|
|
85
|
-
# @return [Array<String>] one REVOKE per table there is.
|
|
86
|
-
def self.revoked(connection, role)
|
|
87
|
-
(connection.tables & Omen.tables).map do |table|
|
|
88
|
-
"REVOKE SELECT ON #{connection.quote_table_name table} FROM #{role}"
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
81
|
end
|
|
92
82
|
end
|
data/lib/omen/renamed.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Omen
|
|
2
|
+
# The names these functions had before 1.0, dropped so a database matches the gem installed in
|
|
3
|
+
# it. A statement stored before the rename names them, and dropping them makes re-running one
|
|
4
|
+
# fail where leaving them would have it quietly answer -- from a function nothing maintains any
|
|
5
|
+
# more. An answer already drawn is unaffected: its rows are stored, not recomputed.
|
|
6
|
+
#
|
|
7
|
+
# Delete this file once no database still has them.
|
|
8
|
+
module Renamed
|
|
9
|
+
# Signatures rather than names, since a function is dropped by the arguments it takes.
|
|
10
|
+
BEFORE = [ 'eastern(timestamp)', 'eastern(timestamptz)',
|
|
11
|
+
'miles_between(double precision, double precision, double precision, ' \
|
|
12
|
+
'double precision)', ]
|
|
13
|
+
|
|
14
|
+
# @return [Array<String>] one DROP per function this gem used to create.
|
|
15
|
+
def self.statements = BEFORE.map { |signature| "DROP FUNCTION IF EXISTS #{signature}" }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Omen
|
|
2
|
+
# The zone the company works in, and the two questions a reading asks about it: what a stored
|
|
3
|
+
# timestamp says here, and what day it is here. Created by the rake task rather than by a
|
|
4
|
+
# migration, because Rails' :ruby schema format dumps no functions, so db:schema:load would
|
|
5
|
+
# drop one a migration had made -- and the format cannot become :sql, since db/schema.rb is
|
|
6
|
+
# what Claude is shown.
|
|
7
|
+
module TimeZone
|
|
8
|
+
# The zone as Postgres names one: Time.zone.name is not a name it takes.
|
|
9
|
+
ZONE = 'America/New_York'
|
|
10
|
+
|
|
11
|
+
# DDL, which Active Record has no expression for, and not a query.
|
|
12
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
13
|
+
# @return [Array<String>] the statements to run, in order.
|
|
14
|
+
def self.statements(connection) = [ stored(connection), instant(connection), today(connection) ]
|
|
15
|
+
|
|
16
|
+
# A stored timestamp says nothing about its own zone, so it is named UTC and then rendered.
|
|
17
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
18
|
+
# @return [String] the statement declaring the function over a naked timestamp.
|
|
19
|
+
def self.stored(connection)
|
|
20
|
+
"CREATE OR REPLACE FUNCTION #{name connection}(ts timestamp) RETURNS timestamp AS " \
|
|
21
|
+
"$$ SELECT ts AT TIME ZONE 'UTC' AT TIME ZONE #{connection.quote ZONE} $$ " \
|
|
22
|
+
'LANGUAGE sql IMMUTABLE'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# One conversion and not two: an instant already knows which moment it is, so naming it UTC
|
|
26
|
+
# first would convert it twice and answer hours out. Overloaded because Postgres will not
|
|
27
|
+
# cast an instant to a timestamp to resolve a call, so now() reaches neither without it.
|
|
28
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
29
|
+
# @return [String] the statement declaring the function over an instant.
|
|
30
|
+
def self.instant(connection)
|
|
31
|
+
"CREATE OR REPLACE FUNCTION #{name connection}(ts timestamptz) RETURNS timestamp AS " \
|
|
32
|
+
"$$ SELECT ts AT TIME ZONE #{connection.quote ZONE} $$ LANGUAGE sql IMMUTABLE"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# What makes a window slide: a statement saying `date_trunc('month', omen_today())` answers
|
|
36
|
+
# for whichever month it is run in, where a literal answers for the month it was written in.
|
|
37
|
+
#
|
|
38
|
+
# STABLE and not IMMUTABLE, unlike its neighbours, which are pure functions of what they are
|
|
39
|
+
# given. This one reads the clock: marked immutable, Postgres is entitled to fold it to a
|
|
40
|
+
# constant, and a window would stop moving in the way this function exists to prevent. Stable
|
|
41
|
+
# is also what has it evaluated once per statement, so a query naming it five times cannot
|
|
42
|
+
# straddle midnight.
|
|
43
|
+
#
|
|
44
|
+
# A date and not a timestamp, so a window cannot silently mean "up to the current hour".
|
|
45
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
46
|
+
# @return [String] the statement declaring the function.
|
|
47
|
+
def self.today(connection)
|
|
48
|
+
"CREATE OR REPLACE FUNCTION #{connection.quote_table_name Omen::Instructions::TODAY}() " \
|
|
49
|
+
"RETURNS date AS $$ SELECT (now() AT TIME ZONE #{connection.quote ZONE})::date $$ " \
|
|
50
|
+
'LANGUAGE sql STABLE'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
54
|
+
# @return [String] the function's name, quoted.
|
|
55
|
+
def self.name(connection) = connection.quote_table_name Omen::Instructions::TIME_ZONE
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/omen/version.rb
CHANGED
data/lib/omen.rb
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
require 'active_job/performs'
|
|
2
2
|
require 'anthropic'
|
|
3
3
|
|
|
4
|
+
require 'omen/attributes'
|
|
4
5
|
require 'omen/config'
|
|
5
6
|
require 'omen/distance'
|
|
6
|
-
require 'omen/
|
|
7
|
+
require 'omen/grants'
|
|
8
|
+
require 'omen/renamed'
|
|
9
|
+
require 'omen/time_zone'
|
|
7
10
|
require 'omen/inquirer'
|
|
8
11
|
require 'omen/requirements'
|
|
9
12
|
require 'omen/version'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: omen
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Claudio Baccigalupo
|
|
@@ -97,13 +97,16 @@ files:
|
|
|
97
97
|
- lib/generators/omen/install/install_generator.rb
|
|
98
98
|
- lib/generators/omen/install/templates/omen.rb
|
|
99
99
|
- lib/omen.rb
|
|
100
|
+
- lib/omen/attributes.rb
|
|
100
101
|
- lib/omen/config.rb
|
|
101
102
|
- lib/omen/distance.rb
|
|
102
|
-
- lib/omen/eastern.rb
|
|
103
103
|
- lib/omen/engine.rb
|
|
104
|
+
- lib/omen/grants.rb
|
|
104
105
|
- lib/omen/inquirer.rb
|
|
106
|
+
- lib/omen/renamed.rb
|
|
105
107
|
- lib/omen/requirements.rb
|
|
106
108
|
- lib/omen/stubs.rb
|
|
109
|
+
- lib/omen/time_zone.rb
|
|
107
110
|
- lib/omen/version.rb
|
|
108
111
|
- lib/tasks/omen.rake
|
|
109
112
|
homepage: https://github.com/claudiob/omen
|
data/lib/omen/eastern.rb
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
module Omen
|
|
2
|
-
# The database function a reading's SQL reads every timestamp through. Created by the rake
|
|
3
|
-
# task rather than by a migration, because Rails' :ruby schema format dumps no functions, so
|
|
4
|
-
# db:schema:load would drop one a migration had made -- and the format cannot become :sql,
|
|
5
|
-
# since db/schema.rb is what Claude is shown.
|
|
6
|
-
module Eastern
|
|
7
|
-
# The zone the company works in, as Postgres names one: Time.zone.name is not one it takes.
|
|
8
|
-
ZONE = 'America/New_York'
|
|
9
|
-
|
|
10
|
-
# DDL, which Active Record has no expression for, and not a query.
|
|
11
|
-
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
12
|
-
# @return [Array<String>] the statements to run, in order.
|
|
13
|
-
def self.statements(connection)
|
|
14
|
-
[ stored(connection), instant(connection) ]
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# A stored timestamp says nothing about its own zone, so it is named UTC and then rendered.
|
|
18
|
-
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
19
|
-
# @return [String] the statement declaring the function over a naked timestamp.
|
|
20
|
-
def self.stored(connection)
|
|
21
|
-
"CREATE OR REPLACE FUNCTION #{name connection}(ts timestamp) RETURNS timestamp AS " \
|
|
22
|
-
"$$ SELECT ts AT TIME ZONE 'UTC' AT TIME ZONE #{connection.quote ZONE} $$ " \
|
|
23
|
-
'LANGUAGE sql IMMUTABLE'
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# One conversion and not two: an instant already knows which moment it is, so naming it UTC
|
|
27
|
-
# first would convert it twice and answer hours out. Overloaded because Postgres will not
|
|
28
|
-
# cast an instant to a timestamp to resolve a call, so now() reaches neither without it.
|
|
29
|
-
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
30
|
-
# @return [String] the statement declaring the function over an instant.
|
|
31
|
-
def self.instant(connection)
|
|
32
|
-
"CREATE OR REPLACE FUNCTION #{name connection}(ts timestamptz) RETURNS timestamp AS " \
|
|
33
|
-
"$$ SELECT ts AT TIME ZONE #{connection.quote ZONE} $$ LANGUAGE sql IMMUTABLE"
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
37
|
-
# @return [String] the function's name, quoted.
|
|
38
|
-
def self.name(connection) = connection.quote_table_name Omen::Instructions::EASTERN
|
|
39
|
-
end
|
|
40
|
-
end
|