omen 0.3.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f96e34cbde627e0781a12488ba4308d704c6896a71fc44b031b7d412512c22eb
4
- data.tar.gz: 408ad7c0b4a13f8ef3aef1b1c271e81ac116791ff627ffa2d056f5ecb6c45e34
3
+ metadata.gz: dd517ec9c5893a92f285613782d245ef9573bb83c8ef78378510f49a208626fc
4
+ data.tar.gz: 5d5f97f73bd6cf0b423f9264426522aee942df7ab4fb622e3442b75493da29c0
5
5
  SHA512:
6
- metadata.gz: fd53d5b7acb9fea6560bf0edbd5e1fb8f26200d99edaa52dfd9a81c9945dbd154a69514a7ba0028a025f36284bbd04be01e7aa57df61b7c01fa68ceb974638fd
7
- data.tar.gz: a5b4226127e861f2600ebf62eb1d0c10ae2921886caf8e27b8041b411ac1b904c5e3406a0265f11e1f4c54a80ae9fa33994f396ec9adbc0df74c57b2304946df
6
+ metadata.gz: f383e42b3c7e9fdd06f7c69c6ef7fd8563cb55f47db66583ee07c656b56bfabe6217972c63c89596db5407a96af4e8080f71e3561e7ee188a507c74d9ff28404
7
+ data.tar.gz: a8894e28c75abeeb62cd139ead7bf0abca83593a49c89bae50273dbe178016ef6cf915179a07355b4b7af38f4f17c06efc2e57f597e949637bf5f7528fbce8a7
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ 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
+
10
23
  ## 0.3.1 - 2026-08-24
11
24
 
12
25
  * [Fix] Stop asserting the role attributes only a superuser may set, since `NOSUPERUSER`,
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 major:
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.2'
21
+ gem 'omen', '~> 0.4.0'
22
22
  ```
23
23
 
24
- Omen follows [Semantic Versioning](https://semver.org), so `~> major.minor` means `bundle update`
25
- never crosses a breaking change.
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
- **Two database functions**
54
+ **Three database functions, all prefixed**
54
55
 
55
56
  <details>
56
- <summary>A timestamp and a distance are read through a function, never an expression. </summary>
57
- `db:omen:grant` creates both. `eastern()` hands a stored timestamp back in the zone the company
58
- works in, so every date in the prompt means the same whole days, and
59
- `miles_between(lat1, lng1, lat2, lng2)` answers a great-circle distance in miles. The prompt
60
- names each and forbids writing either by hand: a conversion assembled per query drifts, and a
61
- great-circle expression runs to a dozen nested calls that a reply balances by hand and gets
62
- wrong. Both are `LANGUAGE sql IMMUTABLE` and executable by anyone, so neither needs a grant.
63
- An app in another zone renames the first; an app whose tables carry no coordinates simply never
64
- calls the second. Neither can be a migration: Rails' `:ruby` schema format dumps no functions,
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
 
@@ -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 `%{eastern}()` is the one way to read one: it hands the same
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', %{eastern}(created_at))`. Never
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}. Resolve every relative date yourself; the query has no idea what "last
41
- month" means, and it must never ask the database what time it is -- `now()` and
42
- `current_timestamp` are the clock of the machine, not the date above.
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
- `%{miles}(lat1, lng1, lat2, lng2)`, so a radius reads
46
- `WHERE %{miles}(l.lat, l.lng, u.lat, u.lng) <= 2`. Never write the trigonometry yourself: a
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
- EASTERN = 'eastern'
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 = 'miles_between'
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), eastern: EASTERN, miles: MILES,
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
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::Eastern is: Rails' :ruby schema format dumps no
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 CHANGED
@@ -21,7 +21,8 @@ module Omen
21
21
  "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO #{role}",
22
22
  *members.map { |member| "GRANT #{role} TO #{connection.quote_table_name member}" },
23
23
  *revoked(connection, role),
24
- *Omen::Eastern.statements(connection),
24
+ *Omen::Renamed.statements,
25
+ *Omen::TimeZone.statements(connection),
25
26
  *Omen::Distance.statements(connection),
26
27
  ]
27
28
  end
@@ -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
@@ -1,4 +1,4 @@
1
1
  module Omen
2
2
  # The version of this gem, as RubyGems knows it.
3
- VERSION = '0.3.1'
3
+ VERSION = '0.4.0'
4
4
  end
data/lib/omen.rb CHANGED
@@ -4,8 +4,9 @@ require 'anthropic'
4
4
  require 'omen/attributes'
5
5
  require 'omen/config'
6
6
  require 'omen/distance'
7
- require 'omen/eastern'
8
7
  require 'omen/grants'
8
+ require 'omen/renamed'
9
+ require 'omen/time_zone'
9
10
  require 'omen/inquirer'
10
11
  require 'omen/requirements'
11
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.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -100,12 +100,13 @@ files:
100
100
  - lib/omen/attributes.rb
101
101
  - lib/omen/config.rb
102
102
  - lib/omen/distance.rb
103
- - lib/omen/eastern.rb
104
103
  - lib/omen/engine.rb
105
104
  - lib/omen/grants.rb
106
105
  - lib/omen/inquirer.rb
106
+ - lib/omen/renamed.rb
107
107
  - lib/omen/requirements.rb
108
108
  - lib/omen/stubs.rb
109
+ - lib/omen/time_zone.rb
109
110
  - lib/omen/version.rb
110
111
  - lib/tasks/omen.rake
111
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